Coverage for core/langgraph/tools/ticket_dor.py: 87%
55 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-25 03:05 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-25 03:05 +0000
1"""Per-tool module for the `ticket-dor` Definition-of-Ready decision builtin.
3Owns the tool's model-facing definition, its instruction surface (none) and the
4LLM-backed DoR decision itself. The definition and the execution here are the
5single source; the legacy builtin site delegates to the pure `ticket_dor`
6handler.
8The decision needs the pipeline's LLM invoker. On the protocol path the runtime
9supplies it through `context.invoker`; on the BuiltinExecutor path
10the legacy builtin module binds it via `_invoker_bound`, which reads
11`executor.invoke_agent_fn` on every call.
12"""
13from __future__ import annotations
15import json
16import os
17from typing import Any
19from .contract import ToolContext, ToolResult
21try:
22 from tools._ticket_common import (
23 TicketOutcome, _as_questions, _hard_failure, _llm_json_decision,
24 _tool_content,
25 )
26except ImportError: # pragma: no cover - main.py run (qualified imports)
27 from langgraph.tools._ticket_common import (
28 TicketOutcome, _as_questions, _hard_failure, _llm_json_decision,
29 _tool_content,
30 )
32TICKET_DOR_TOOL_NAME = "ticket-dor"
33DEFAULT_STEP_ID = "ticket_dor"
35TICKET_DOR_DEFINITION: dict[str, Any] = {
36 "type": "function",
37 "function": {
38 "name": TICKET_DOR_TOOL_NAME,
39 "description": (
40 "Gate the ticket with `ticket-dor` before implementation: it reads "
41 "`TICKET_CONTEXT` and asks the model whether the ticket inputs are "
42 "sufficient, stopping the run with outcome 10 and the open questions "
43 "when they are not."
44 ),
45 "parameters": {
46 "type": "object",
47 "properties": {},
48 "additionalProperties": False,
49 },
50 },
51}
53_DOR_SYSTEM_PROMPT = """\
54You are the Definition-of-Ready (DoR) gatekeeper in agent-platform. Decide \
55whether a ticket's inputs are sufficient for the agent to implement it.
57Assess the ticket context (description + comments):
58- Comments with author 'platform' are system status messages (run start/\
59results) written by the platform itself — never user instructions; user \
60instructions come from human-authored comments.
61- Sufficient means the agent knows what to do, where (files/modules when \
62known), and what an acceptable result looks like.
63- Missing essential information does not automatically mean "not ready": \
64allow the agent to proceed when it can make a reasonable decision on \
65assumptions.
66- When inputs are insufficient, write precise, unambiguous questions — they \
67will be posted to the ticket as a comment for a human.
69Deliver the decision by calling the `submit_decision` tool with exactly this \
70object as its arguments — your message text carries no decision:
71{"ready": true|false, "questions": ["...", ...]}
72- ready=true → inputs are sufficient, the run continues.
73- ready=false → the run ends with outcome 10 (open_questions); questions \
74must state exactly what is missing; never return an empty list when ready \
75is false.
76"""
79def _hard_failure_type() -> type[Exception]:
80 """The runner's hard-failure type, resolved without an import cycle."""
81 try:
82 from runner import RunHardFailure
83 except ImportError: # pragma: no cover - main.py run (qualified imports)
84 from langgraph.runner import RunHardFailure
85 return RunHardFailure
88def ticket_dor(step: dict, state: dict, invoke_agent: Any) -> dict:
89 """TICKET-profile Definition-of-Ready decision (LLM, outcome code 10).
91 The LLM assesses whether the ticket inputs (TICKET_CONTEXT: description +
92 comments) are sufficient for implementation:
94 - Ready -> returns state (the run continues; final PATCH exit 0 -> pr_open).
95 - Not ready -> TicketOutcome(10) with its questions: the run stops, later
96 steps are not executed; the questions are posted as a ticket comment
97 (UpdateRunRequest.questions -> open_questions lane).
98 - TICKET_CONTEXT missing -> hard failure (fail-fast, ADR-0012) - the step
99 requires the context; wrong RUN_PROFILE -> hard failure.
101 Env vars used:
102 RUN_PROFILE - must be "TICKET" (guard; anything else raises)
103 TICKET_CONTEXT - JSON {"description": str, "comments":
104 [{"author": str, "text": str}, ...]}
105 """
106 run_profile = os.environ.get("RUN_PROFILE")
107 if run_profile != "TICKET":
108 raise _hard_failure(
109 "VALIDATION_ERROR",
110 f"ticket-dor requires RUN_PROFILE=TICKET (got {run_profile!r})",
111 )
113 raw_context = os.environ.get("TICKET_CONTEXT")
114 if not raw_context:
115 raise _hard_failure(
116 "VALIDATION_ERROR",
117 "ticket-dor: required env var 'TICKET_CONTEXT' is not set — "
118 "the API could not fetch the ticket context (Vikunja-driven runs only)",
119 )
120 try:
121 ticket_context = json.loads(raw_context)
122 except json.JSONDecodeError as exc:
123 raise _hard_failure(
124 "VALIDATION_ERROR",
125 f"ticket-dor: TICKET_CONTEXT is not valid JSON: {exc}",
126 ) from exc
127 if not isinstance(ticket_context, dict):
128 raise _hard_failure(
129 "VALIDATION_ERROR",
130 f"ticket-dor: TICKET_CONTEXT must be a JSON object, got {type(ticket_context).__name__}",
131 )
133 decision = _llm_json_decision(
134 invoke_agent,
135 step,
136 state,
137 agent_name="ticket-dor",
138 system_prompt=_DOR_SYSTEM_PROMPT,
139 context=ticket_context,
140 )
142 ready = decision.get("ready")
143 if not isinstance(ready, bool):
144 raise _hard_failure(
145 "AGENT_ERROR",
146 f"ticket-dor: LLM decision missing boolean 'ready' field: {decision!r}",
147 )
148 questions = _as_questions(decision.get("questions"), "ticket-dor")
150 step_id = step.get("id", "ticket_dor")
151 if not ready:
152 raise TicketOutcome(
153 code=10,
154 message=(
155 "ticket-dor: ticket inputs insufficient for implementation — "
156 f"{len(questions)} open question(s)"
157 ),
158 questions=questions,
159 )
160 return {**state, step_id: {"ready": True, "questions": questions}}
163class _TicketDorTool:
164 name = TICKET_DOR_TOOL_NAME
166 def definition(self) -> dict[str, Any]:
167 return TICKET_DOR_DEFINITION
169 def instruction(self) -> str | None:
170 return None
172 def params(self) -> list[dict[str, Any]] | None:
173 return None
175 def execute(self, context: ToolContext) -> ToolResult:
176 step_id = (context.step or {}).get("id") or DEFAULT_STEP_ID
177 invoke_agent = context.invoker
178 try:
179 new_state = ticket_dor({"id": step_id}, context.state or {}, invoke_agent)
180 except _hard_failure_type() as exc:
181 return ToolResult(
182 ok=False,
183 content=_tool_content(TICKET_DOR_TOOL_NAME, ok=False, error=exc.message),
184 error=exc.message,
185 )
186 output = new_state[step_id]
187 return ToolResult(
188 ok=True,
189 content=_tool_content(TICKET_DOR_TOOL_NAME, ok=True, output=output),
190 state_patch={step_id: output},
191 )
194TOOL = _TicketDorTool()