Coverage for core/langgraph/tools/ticket_dor.py: 87%

55 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-25 00:49 +0000

1"""Per-tool module for the `ticket-dor` Definition-of-Ready decision builtin. 

2 

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. 

7 

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 

14 

15import json 

16import os 

17from typing import Any 

18 

19from .contract import ToolContext, ToolResult 

20 

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 ) 

31 

32TICKET_DOR_TOOL_NAME = "ticket-dor" 

33DEFAULT_STEP_ID = "ticket_dor" 

34 

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} 

52 

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. 

56 

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. 

68 

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""" 

77 

78 

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 

86 

87 

88def ticket_dor(step: dict, state: dict, invoke_agent: Any) -> dict: 

89 """TICKET-profile Definition-of-Ready decision (LLM, outcome code 10). 

90 

91 The LLM assesses whether the ticket inputs (TICKET_CONTEXT: description + 

92 comments) are sufficient for implementation: 

93 

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. 

100 

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 ) 

112 

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 ) 

132 

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 ) 

141 

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") 

149 

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}} 

161 

162 

163class _TicketDorTool: 

164 name = TICKET_DOR_TOOL_NAME 

165 

166 def definition(self) -> dict[str, Any]: 

167 return TICKET_DOR_DEFINITION 

168 

169 def instruction(self) -> str | None: 

170 return None 

171 

172 def params(self) -> list[dict[str, Any]] | None: 

173 return None 

174 

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 ) 

192 

193 

194TOOL = _TicketDorTool()