Coverage for core/langgraph/_instructions.py: 97%

66 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-20 03:43 +0000

1""" 

2_instructions.py — Core-owned prompt-text and tool registry. 

3 

4The calling agent declares `tools`; Core resolves them through this registry 

5into native function-tool entries. This module owns the per-step how-to text 

6for Core `builtin` steps; consumer `StepFunction` steps own their text via 

7`spec.agent_instructions`. Each resolved tool carries its own how-to text as 

8the native `function.description` — there is no generic common instruction and 

9no per-tool decoration. 

10 

11The registry is the single source for builtin instruction texts — runtime 

12injection and the build-time `AGENT_INSTRUCTION_MISSING` validator both resolve 

13through `builtin_instruction`. 

14""" 

15from __future__ import annotations 

16 

17from typing import Any 

18 

19# Reserved resolved-step key carrying a step's resolved tool entries 

20# (one per entry in the calling agent's `tools`, in declaration order). 

21# Distinct from the COMMUNICATION context key in _data_structure.py. 

22USAGE_INSTRUCTIONS_KEY = "_usage_instructions" 

23 

24_BUILTIN_INSTRUCTIONS: dict[str, str] = { 

25 "list-dir-entries": ( 

26 "List one level of a directory with `list-dir-entries` instead of " 

27 "inspecting files one by one. Pass `params.path` (a path; defaults to " 

28 "the project root) and read the returned `entries` and `stats` to " 

29 "decide what to inspect next." 

30 ), 

31 "aggregate": ( 

32 "Fold the collected per-iteration results deterministically with " 

33 "`aggregate` instead of recomputing them yourself. Provide " 

34 "`input.results` (or `input.sources`), a `fields` list of " 

35 "`{target, source, op, dedupe_key/dedupe_keys, on_error}` entries, and " 

36 "optionally the `op`/`on_error` defaults; each `target` is written to " 

37 "the step result." 

38 ), 

39 "ticket-publisher": ( 

40 "Publish an approved ticket by passing its markdown as the step " 

41 "`input` to `ticket-publisher`. Set `params.status_from`/`status_to` to " 

42 "flip the status and `params.publish_dir` (default `docs/tickets`) as " 

43 "the destination; the result reports the published `path` and `slug`." 

44 ), 

45 "run-finalizer": ( 

46 "Finalize the run with `run-finalizer` after all work steps. It " 

47 "composes the run status and stats from state and appends a runlog " 

48 "line; pass `params.pipeline_id` (and optionally `params.runlog`) so " 

49 "the record is attributed correctly." 

50 ), 

51 "git-checkout": ( 

52 "Prepare the working tree with `git-checkout` before any step that " 

53 "reads or writes repository files. It clones the target repository for " 

54 "the active run profile and exposes the checkout location and branch " 

55 "facts in state; do not clone the repository yourself." 

56 ), 

57 "files-write": ( 

58 "Write files with `files-write` instead of ad-hoc shell writes. Supply " 

59 "`params.entries` as one `{path, content}` object or a list of them; " 

60 "paths are workspace-relative and the result reports the written paths." 

61 ), 

62 "files-delete": ( 

63 "Delete workspace files with `files-delete` instead of a shell `rm`. " 

64 "Supply `params.paths` as one workspace-relative path or a list of " 

65 "them; every path is validated up front and the result reports the " 

66 "`deleted` paths." 

67 ), 

68 "question-block-form": ( 

69 "Form the human-interface question block with `question-block-form` " 

70 "when the agentic effort is exhausted. Pass the exhaustion sources as " 

71 "the step `input` (`coverage`, `decompose`, `research`, `refine`, " 

72 "`close_readers`, `close_synthesis` — all optional); the result " 

73 "reports `status` and a `question_block` carrying the problems and " 

74 "open questions for the human." 

75 ), 

76 "github-pr-comment": ( 

77 "Post the pull-request review with `github-pr-comment` instead of " 

78 "calling the forge API yourself. Set `params.findings_key` (default " 

79 "`review_output`) to the state dot-path holding the findings and " 

80 "`params.fail_on_severity` (default `Critical`) to the severity that " 

81 "forces `REQUEST_CHANGES`; the result reports `findings_count` and the " 

82 "WIP title sync status." 

83 ), 

84 "ticket-publish": ( 

85 "Publish the TICKET run's output with `ticket-publish`: it commits the " 

86 "workspace, pushes the feature branch, and ensures the pull request. " 

87 "It reads `project_dir`, `ticket_branch` and `ticket_base_branch` " 

88 "(set by `git-checkout`); the result reports `branch`, `base`, " 

89 "`pushed`, `pr_action` and, once a PR is ensured, `pr_url`." 

90 ), 

91 "ticket-context": ( 

92 "Load the ticket context with `ticket-context`: it reads the " 

93 "`TICKET_CONTEXT` environment JSON (description plus author-tagged " 

94 "comments) and stores it under the step id and the top-level " 

95 "`ticket_context` key for later steps to read." 

96 ), 

97 "ticket-lane": ( 

98 "Convert the agent's lane decision with `ticket-lane` by passing the " 

99 "lane string (or an object `{lane, reason, questions}`) as the step " 

100 "`input`; it continues on `pr_open` and otherwise stops the run with " 

101 "the matching outcome code." 

102 ), 

103 "ticket-dor": ( 

104 "Gate the ticket with `ticket-dor` before implementation: it reads " 

105 "`TICKET_CONTEXT` and asks the model whether the ticket inputs are " 

106 "sufficient, stopping the run with outcome 10 and the open questions " 

107 "when they are not." 

108 ), 

109 "ticket-review": ( 

110 "Wait for and classify the pull-request review with `ticket-review` " 

111 "after `ticket-publish`: it polls until the PR head commit carries a " 

112 "review and maps it to an outcome (approved continues; fixable / " 

113 "too_bad / cannot_fix stop the run). Set `params.poll_interval_seconds` " 

114 "(default 15) and `params.timeout_seconds` (default 900)." 

115 ), 

116 "ticket-search": ( 

117 "List the open work items with `ticket-search` instead of querying the " 

118 "project-management system yourself; the result reports `tickets` " 

119 "(`id`, `title`, `done`, `description`) resolved through the run's PM " 

120 "configuration." 

121 ), 

122 "ticket-create": ( 

123 "Create a single work item with `ticket-create` (idempotent): set " 

124 "`params.title` (required), optional `params.description` and optional " 

125 "`params.parent_task_id`; a matching open title is skipped and the " 

126 "result reports `created` and `skipped`." 

127 ), 

128 "ticket-approval": ( 

129 "Detect the human approval marker with `ticket-approval`: pass the " 

130 "`ticket_context` object as the step `input`; a human comment whose " 

131 "entire content is `APPROVED` (case-insensitive) passes, and the " 

132 "result reports `passed` and the matching `evidence`." 

133 ), 

134} 

135 

136 

137def builtin_instruction(name: str) -> str | None: 

138 """The registry text for a supported builtin, or None. 

139 

140 Accepts the hyphenated canonical name and its underscore-normalized alias, 

141 mirroring `BuiltinExecutor`'s hyphen→underscore name normalization. 

142 """ 

143 return _BUILTIN_INSTRUCTIONS.get(name) or _BUILTIN_INSTRUCTIONS.get( 

144 name.replace("_", "-") 

145 ) 

146 

147 

148def instruction_text(name: str, builtins: Any, step_functions: Any) -> str | None: 

149 """Resolve one whitelist entry's usage instruction, builtin-first. 

150 

151 Mirrors the runtime dispatch order (ADR-0037 D2): a name in the builtin 

152 register resolves to the Core-owned registry text, otherwise to the 

153 StepFunction's ``agent_instructions``. 

154 """ 

155 if builtins.has(name): 

156 return builtin_instruction(name) 

157 if step_functions is not None: 

158 return step_functions.instruction_for(name) 

159 return None 

160 

161 

162def resolve_usage_instructions( 

163 allow: list[str] | None, builtins: Any, step_functions: Any 

164) -> list[dict]: 

165 """Resolve a step's `allow` whitelist into ordered instruction entries. 

166 

167 Retained legacy/test-only helper: it is not the source of runtime usage 

168 instructions, which now derive from the calling agent's resolved `tools` 

169 via `resolve_tool_entries`. Each resolvable entry carries the whitelisted 

170 name, its per-step text and its declared params (StepFunctions only). 

171 Declaration order is preserved. An empty or absent whitelist yields an 

172 empty list. 

173 """ 

174 entries: list[dict] = [] 

175 for name in allow or []: 

176 text = instruction_text(name, builtins, step_functions) 

177 if not text: 

178 continue 

179 entries.append( 

180 { 

181 "name": name, 

182 "text": text, 

183 "params": _declared_params(name, builtins, step_functions), 

184 } 

185 ) 

186 return entries 

187 

188 

189def _declared_params( 

190 name: str, builtins: Any, step_functions: Any 

191) -> list[dict] | None: 

192 """A resolved StepFunction tool's declared `interface.params`, else None. 

193 

194 Builtins carry no formally declared param interface — their registry text 

195 conveys the accepted params. 

196 """ 

197 if builtins.has(name) or step_functions is None: 

198 return None 

199 return step_functions.params_for(name) 

200 

201 

202# --- Single Core-owned tool registry ------------------------------------- 

203# One registry = file-access tools ∪ deterministic step tools (platform 

204# `builtin` ∪ consumer `StepFunction`). An agent declares `tools: [<name>]`; 

205# `resolve_tool_entries` turns that list into ordered, native-function-calling 

206# ready entries. The two file-access definitions live here so the registry owns 

207# every definition. 

208 

209AGENT_INSTRUCTION_MISSING = "AGENT_INSTRUCTION_MISSING" 

210 

211READ_FILE_TOOL_NAME = "read_file" 

212LIST_DIR_TOOL_NAME = "list_dir" 

213 

214READ_FILE_TOOL: dict[str, Any] = { 

215 "type": "function", 

216 "function": { 

217 "name": READ_FILE_TOOL_NAME, 

218 "description": ( 

219 "Read a text file from the project root. The path is resolved " 

220 "against the project root; any read that resolves outside the " 

221 "project root is rejected. Content is truncated and flagged when " 

222 "it exceeds the caps, and can be windowed by offset/limit (lines)." 

223 ), 

224 "parameters": { 

225 "type": "object", 

226 "properties": { 

227 "path": { 

228 "type": "string", 

229 "description": "Path to the file, relative to the project root.", 

230 }, 

231 "offset": { 

232 "type": "integer", 

233 "description": "Number of lines to skip (0-based). Optional.", 

234 }, 

235 "limit": { 

236 "type": "integer", 

237 "description": "Max number of lines to return. Optional.", 

238 }, 

239 }, 

240 "required": ["path"], 

241 "additionalProperties": False, 

242 }, 

243 }, 

244} 

245 

246LIST_DIR_TOOL: dict[str, Any] = { 

247 "type": "function", 

248 "function": { 

249 "name": LIST_DIR_TOOL_NAME, 

250 "description": ( 

251 "List ONE level of a directory inside the project root. The path is " 

252 "resolved against the project root; any listing that resolves " 

253 "outside the project root is rejected. Non-recursive: nested " 

254 "subdirectories are not expanded. Only structural skip dirs are " 

255 "omitted. The listing is capped and flagged when it exceeds the cap." 

256 ), 

257 "parameters": { 

258 "type": "object", 

259 "properties": { 

260 "path": { 

261 "type": "string", 

262 "description": "Directory path, relative to the project root.", 

263 }, 

264 }, 

265 "required": ["path"], 

266 "additionalProperties": False, 

267 }, 

268 }, 

269} 

270 

271_FILE_ACCESS_TOOLS: dict[str, dict[str, Any]] = { 

272 READ_FILE_TOOL_NAME: READ_FILE_TOOL, 

273 LIST_DIR_TOOL_NAME: LIST_DIR_TOOL, 

274} 

275 

276 

277class ToolRegistryError(ValueError): 

278 """A declared agent tool cannot be resolved to a registry definition.""" 

279 

280 def __init__(self, code: str, name: str) -> None: 

281 super().__init__(f"{code}: tool '{name}' has no registry definition") 

282 self.code = code 

283 self.name = name 

284 

285 

286def resolve_tool_entries( 

287 tools: list[str] | None, builtins: Any, step_functions: Any 

288) -> list[dict]: 

289 """Resolve an agent's declared `tools` list into ordered tool entries. 

290 

291 Each entry carries the tool's `name`, its OpenAI `definition`, its 

292 instruction `text` (None for file-access tools) and its declared `params` 

293 (StepFunctions only). Declaration order is preserved; an empty or absent 

294 list yields no entries. An unresolvable name, or a deterministic step 

295 without instruction text, is a hard `ToolRegistryError`. 

296 """ 

297 entries: list[dict] = [] 

298 for name in tools or []: 

299 entries.append(_resolve_tool_entry(name, builtins, step_functions)) 

300 return entries 

301 

302 

303def _resolve_tool_entry(name: str, builtins: Any, step_functions: Any) -> dict: 

304 if name in _FILE_ACCESS_TOOLS: 

305 return { 

306 "name": name, 

307 "definition": _FILE_ACCESS_TOOLS[name], 

308 "text": None, 

309 "params": None, 

310 } 

311 if builtins is not None and builtins.has(name): 

312 text = _required_instruction(builtin_instruction(name), name) 

313 return { 

314 "name": name, 

315 "definition": _generic_function_tool(name, text), 

316 "text": text, 

317 "params": None, 

318 } 

319 if step_functions is not None and step_functions.has(name): 

320 text = _required_instruction(step_functions.instruction_for(name), name) 

321 params = step_functions.params_for(name) 

322 return { 

323 "name": name, 

324 "definition": _step_function_tool(name, text, params), 

325 "text": text, 

326 "params": params, 

327 } 

328 raise ToolRegistryError(AGENT_INSTRUCTION_MISSING, name) 

329 

330 

331def _required_instruction(text: str | None, name: str) -> str: 

332 if not text: 

333 raise ToolRegistryError(AGENT_INSTRUCTION_MISSING, name) 

334 return text 

335 

336 

337def _generic_function_tool(name: str, description: str) -> dict: 

338 return { 

339 "type": "function", 

340 "function": { 

341 "name": name, 

342 "description": description, 

343 "parameters": { 

344 "type": "object", 

345 "properties": {}, 

346 "additionalProperties": True, 

347 }, 

348 }, 

349 } 

350 

351 

352def _step_function_tool( 

353 name: str, description: str, params: list[dict] | None 

354) -> dict: 

355 return { 

356 "type": "function", 

357 "function": { 

358 "name": name, 

359 "description": description, 

360 "parameters": _tool_parameters(params), 

361 }, 

362 } 

363 

364 

365def _tool_parameters(params: list[dict] | None) -> dict: 

366 declared = params or [] 

367 schema: dict[str, Any] = { 

368 "type": "object", 

369 "properties": {param["name"]: {} for param in declared}, 

370 "additionalProperties": False, 

371 } 

372 required = [param["name"] for param in declared if param.get("required")] 

373 if required: 

374 schema["required"] = required 

375 return schema