Coverage for core/langgraph/tools/ticket_context.py: 96%
52 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-10-05 00:44 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-10-05 00:44 +0000
1"""Per-tool module for the `ticket-context` deterministic builtin.
3Owns the tool's model-facing definition, its instruction surface (none) and the
4TICKET_CONTEXT env → state loading itself. The definition and the execution here
5are the single source; the legacy builtin site delegates to the pure
6`ticket_context` handler.
7"""
8from __future__ import annotations
10import json
11import os
12from typing import Any
14from .contract import ToolContext, ToolResult
16try:
17 from tools._ticket_common import _hard_failure, _tool_content
18except ImportError: # pragma: no cover — main.py run (qualified imports)
19 from langgraph.tools._ticket_common import _hard_failure, _tool_content
21TICKET_CONTEXT_TOOL_NAME = "ticket-context"
22DEFAULT_STEP_ID = "ticket_context"
24TICKET_CONTEXT_DEFINITION: dict[str, Any] = {
25 "type": "function",
26 "function": {
27 "name": TICKET_CONTEXT_TOOL_NAME,
28 "description": (
29 "Load the ticket context with `ticket-context`: it reads the "
30 "`TICKET_CONTEXT` environment JSON (description plus author-tagged "
31 "comments) and stores it under the step id and the top-level "
32 "`ticket_context` key for later steps to read."
33 ),
34 "parameters": {
35 "type": "object",
36 "properties": {},
37 "additionalProperties": False,
38 },
39 },
40}
43def _hard_failure_type() -> type[Exception]:
44 """The runner's hard-failure type, resolved without an import cycle."""
45 try:
46 from runner import RunHardFailure
47 except ImportError: # pragma: no cover — main.py run (qualified imports)
48 from langgraph.runner import RunHardFailure
49 return RunHardFailure
52def ticket_context(step: dict, state: dict) -> dict:
53 """Store the TICKET_CONTEXT env in state as pipeline-agent input.
55 Env: TICKET_CONTEXT — JSON {"description": str, "comments":
56 [{"author": str, "text": str}, ...]} (ticket description + full
57 comment history with author tags). The result is stored under the
58 step-id key (default "ticket_context") AND the top-level key
59 "ticket_context" (state dot-path, e.g. ticket_context.comments).
61 Missing env or invalid JSON/structure → hard failure (fail-fast,
62 ADR-0012): the API hard-fails on the env setting (Phase 2), so this
63 is the last line of defense. The legacy comments form (list of
64 strings) is a hard failure — comments is a list of author-tagged
65 objects. Wrong RUN_PROFILE → hard failure.
66 """
67 run_profile = os.environ.get("RUN_PROFILE")
68 if run_profile != "TICKET":
69 raise _hard_failure(
70 "VALIDATION_ERROR",
71 f"ticket-context requires RUN_PROFILE=TICKET (got {run_profile!r})",
72 )
74 raw_context = os.environ.get("TICKET_CONTEXT")
75 if not raw_context:
76 raise _hard_failure(
77 "VALIDATION_ERROR",
78 "ticket-context: required env var 'TICKET_CONTEXT' is not set — "
79 "the API could not fetch the ticket context (Vikunja-driven runs only)",
80 )
81 try:
82 ticket_context = json.loads(raw_context)
83 except json.JSONDecodeError as exc:
84 raise _hard_failure(
85 "VALIDATION_ERROR",
86 f"ticket-context: TICKET_CONTEXT is not valid JSON: {exc}",
87 ) from exc
88 if not isinstance(ticket_context, dict):
89 raise _hard_failure(
90 "VALIDATION_ERROR",
91 "ticket-context: TICKET_CONTEXT must be a JSON object, "
92 f"got {type(ticket_context).__name__}",
93 )
94 if not isinstance(ticket_context.get("description"), str):
95 raise _hard_failure(
96 "VALIDATION_ERROR",
97 "ticket-context: TICKET_CONTEXT['description'] must be a string",
98 )
99 comments = ticket_context.get("comments")
100 if not isinstance(comments, list) or not all(
101 isinstance(c, dict)
102 and isinstance(c.get("author"), str)
103 and isinstance(c.get("text"), str)
104 for c in comments
105 ):
106 raise _hard_failure(
107 "VALIDATION_ERROR",
108 "ticket-context: TICKET_CONTEXT['comments'] must be a list of "
109 "objects with string 'author' and 'text' fields",
110 )
112 step_id = step.get("id", "ticket_context")
113 return {**state, step_id: ticket_context, "ticket_context": ticket_context}
116class _TicketContextTool:
117 name = TICKET_CONTEXT_TOOL_NAME
119 def definition(self) -> dict[str, Any]:
120 return TICKET_CONTEXT_DEFINITION
122 def instruction(self) -> str | None:
123 return None
125 def params(self) -> list[dict[str, Any]] | None:
126 return None
128 def execute(self, context: ToolContext) -> ToolResult:
129 step_id = (context.step or {}).get("id") or DEFAULT_STEP_ID
130 synthetic_step = {"id": step_id}
131 try:
132 new_state = ticket_context(synthetic_step, context.state or {})
133 except _hard_failure_type() as exc:
134 return ToolResult(
135 ok=False,
136 content=_tool_content(TICKET_CONTEXT_TOOL_NAME, ok=False, error=exc.message),
137 error=exc.message,
138 )
139 output = new_state[step_id]
140 return ToolResult(
141 ok=True,
142 content=_tool_content(TICKET_CONTEXT_TOOL_NAME, ok=True, output=output),
143 state_patch={step_id: output, "ticket_context": new_state["ticket_context"]},
144 )
147TOOL = _TicketContextTool()