Coverage for core/langgraph/_builtins_ticket.py: 93%

403 statements  

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

1"""_builtins_ticket.py — TICKET-profile builtins (lazy-loaded, needs httpx). 

2 

3The TICKET profile (RUN_PROFILE=TICKET) is the Vikunja-driven run shape: 

4checkout from the base branch (git-checkout builtin) → per-run feature 

5branch `ticket/<issue_key>-<slug(title)>` → ticket-publish commits the 

6pipeline output and pushes the branch (only when a commit was created — 

7the branch is born with the first real commit; no empty branch publish 

8and never a forced push) → the PR is ensured idempotently (reuse open → 

9"reused", closed → "reopened", else create → "created"; base = the 

10ticket's base branch). Later steps and continuation runs continue on the 

11SAME branch and PR. A push conflict is a hard error (resolved MVP 

12decision). PR title/body come from the ticket (PR_TITLE / PR_DESCRIPTION). 

13 

14The publish builtin promotes the ensured PR url to the top-level `pr_url` 

15state key — the entrypoint's final status-callback PATCH 

16(UpdateRunRequest.pr_url, TICKET profile) reads it for the exit-0 pr_open 

17lane transition. 

18 

19Decision builtins (ticket-dor / ticket-review) stop the run with 

20outcome-coded TicketOutcome exceptions instead of returning state: 

21 

22 | code | meaning | lane (API-owned mapping) | 

23 |------|----------------------------|--------------------------| 

24 | 10 | DoR inputs insufficient | open_questions | 

25 | 11 | agent: blocked | blocked | 

26 | 20 | review: fixable issues | needs_rework | 

27 | 21 | review: too severe | pr_rejected | 

28 | 22 | review: cannot fix | open_questions | 

29 | 0 | approved → state returned | pr_open | 

30 

31The entrypoint maps TicketOutcome → phase=failed + exit_code + optional 

32`questions` in the final status-callback PATCH (UpdateRunRequest.questions; 

33API posts them as a ticket comment when the lane mapping yields 

34open_questions). The LLM decisions use the pipeline's invoke_agent_fn — 

35GraphBuilder stores it on the BuiltinExecutor (invoke_agent_fn) and 

36register_all binds it to the builtins. 

37""" 

38from __future__ import annotations 

39 

40import json 

41import os 

42import re 

43import subprocess 

44import time 

45from typing import Any 

46from urllib.parse import urlparse 

47 

48import httpx 

49 

50_COMMIT_AUTHOR_NAME = "agent-platform" 

51_COMMIT_AUTHOR_EMAIL = "agent-platform@localhost" 

52_COMMIT_MESSAGE_PREFIX = "agent-platform:" 

53 

54 

55def _require_env(name: str) -> str: 

56 value = os.environ.get(name) 

57 if not value: 

58 raise ValueError(f"ticket-publish: required env var {name!r} is not set") 

59 return value 

60 

61 

62def _repo_full_name(git_repo_url: str) -> str: 

63 """Derive owner/repo from the code-repo URL (repo fact). 

64 

65 The API sets REPO_FULL_NAME only in the PR profile; the TICKET profile 

66 derives it deterministically from GIT_REPO_URL (path without .git). 

67 """ 

68 path = urlparse(git_repo_url).path.strip("/") 

69 if path.endswith(".git"): 

70 path = path[:-len(".git")] 

71 if not path or "/" not in path: 

72 raise ValueError( 

73 f"ticket-publish: cannot derive repo full name from GIT_REPO_URL {git_repo_url!r}" 

74 ) 

75 return path 

76 

77 

78def _git(project_dir: str, args: list[str], *, check: bool = True) -> subprocess.CompletedProcess: 

79 """Run a git command in the project dir; failures raise clear errors.""" 

80 try: 

81 return subprocess.run( 

82 ["git", "-c", "safe.directory=*", *args], 

83 cwd=project_dir, 

84 capture_output=True, 

85 text=True, 

86 check=check, 

87 ) 

88 except subprocess.CalledProcessError as exc: 

89 raise ValueError( 

90 f"ticket-publish: 'git {' '.join(args)}' failed " 

91 f"(exit {exc.returncode}): {(exc.stderr or exc.stdout or '').strip()}" 

92 ) from exc 

93 

94 

95def _git_commit_and_push(project_dir: str, branch: str) -> bool: 

96 """Commit the pipeline output and push the feature branch. 

97 

98 The push is plain (never a forced push) and happens ONLY when a commit 

99 was created — nothing staged → no commit → no push (no empty branch 

100 publish). The branch is born with the first real commit; later steps 

101 and continuation runs continue on the same branch and PR. Returns 

102 True when a commit was created and pushed, False otherwise. A failed 

103 push is a hard error (resolved MVP decision). 

104 """ 

105 _git(project_dir, ["add", "-A"]) 

106 staged = _git(project_dir, ["diff", "--cached", "--quiet"], check=False) 

107 if staged.returncode not in (0, 1): 

108 raise ValueError( 

109 f"ticket-publish: 'git diff --cached --quiet' failed " 

110 f"(exit {staged.returncode}): {(staged.stderr or '').strip()}" 

111 ) 

112 if staged.returncode == 0: 

113 return False 

114 author_name = os.environ.get("GIT_AUTHOR_NAME", _COMMIT_AUTHOR_NAME) 

115 author_email = os.environ.get("GIT_AUTHOR_EMAIL", _COMMIT_AUTHOR_EMAIL) 

116 _git(project_dir, [ 

117 "-c", f"user.name={author_name}", 

118 "-c", f"user.email={author_email}", 

119 "commit", "-m", f"{_COMMIT_MESSAGE_PREFIX} {branch}", 

120 ]) 

121 _git(project_dir, ["push", "origin", branch]) 

122 return True 

123 

124 

125def _ensure_pr( 

126 base_url: str, 

127 token: str, 

128 repo_full_name: str, 

129 *, 

130 head: str, 

131 base: str, 

132 title: str, 

133 body: str, 

134) -> tuple[str, str]: 

135 """Ensure the PR for the feature branch exists (idempotent, LOW-2). 

136 

137 Order: open PR for the branch → reuse; closed PR for the branch → 

138 reopen; otherwise create. Returns (pr_url, action) where action is 

139 "reused" | "reopened" | "created". 

140 """ 

141 headers = {"Authorization": f"token {token}", "Content-Type": "application/json"} 

142 api = f"{base_url}/api/v1/repos/{repo_full_name}/pulls" 

143 

144 def _find(pulls: list) -> dict | None: 

145 for pr in pulls: 

146 if isinstance(pr, dict) and (pr.get("head") or {}).get("ref") == head: 

147 return pr 

148 return None 

149 

150 open_resp = httpx.get(f"{api}?state=open&limit=50", headers=headers) 

151 if open_resp.status_code >= 400: 

152 raise ValueError( 

153 f"ticket-publish: open PRs GET failed ({open_resp.status_code}): {open_resp.text[:500]}" 

154 ) 

155 existing = _find(open_resp.json()) 

156 if existing is not None: 

157 return existing["html_url"], "reused" 

158 

159 closed_resp = httpx.get(f"{api}?state=closed&limit=50", headers=headers) 

160 if closed_resp.status_code >= 400: 

161 raise ValueError( 

162 f"ticket-publish: closed PRs GET failed ({closed_resp.status_code}): {closed_resp.text[:500]}" 

163 ) 

164 closed = _find(closed_resp.json()) 

165 if closed is not None: 

166 number = closed["number"] 

167 reopen_resp = httpx.patch( 

168 f"{api}/{number}", 

169 json={"state": "open"}, 

170 headers=headers, 

171 ) 

172 if reopen_resp.status_code >= 400: 

173 raise ValueError( 

174 f"ticket-publish: PR reopen PATCH failed ({reopen_resp.status_code}): {reopen_resp.text[:500]}" 

175 ) 

176 return reopen_resp.json().get("html_url", closed["html_url"]), "reopened" 

177 

178 create_resp = httpx.post( 

179 api, 

180 json={"title": title, "body": body, "head": head, "base": base}, 

181 headers=headers, 

182 ) 

183 if create_resp.status_code >= 400: 

184 raise ValueError( 

185 f"ticket-publish: PR create POST failed ({create_resp.status_code}): {create_resp.text[:500]}" 

186 ) 

187 try: 

188 pr_url = create_resp.json().get("html_url") 

189 except ValueError: 

190 raise ValueError( 

191 f"ticket-publish: PR create POST returned invalid JSON: {create_resp.text[:500]}" 

192 ) from None 

193 if not isinstance(pr_url, str) or not pr_url: 

194 raise ValueError( 

195 f"ticket-publish: PR create POST response missing html_url: {create_resp.text[:500]}" 

196 ) 

197 return pr_url, "created" 

198 

199 

200def builtin_ticket_publish(step: dict, state: dict) -> dict: 

201 """Publish the pipeline output for a TICKET run: commit → push → PR. 

202 

203 The first commit step performs branch publish + PR open: the first 

204 real code commit is pushed (no empty branch publish, never a forced 

205 push), then the PR is ensured idempotently (reuse open → 

206 "reused", closed → "reopened", else create → "created"; base = the 

207 ticket's base branch). Later steps and continuation runs continue on 

208 the SAME branch and PR. A push conflict is a hard error (resolved MVP 

209 decision). Nothing staged → no commit, no push, no PR — the step 

210 result reports pushed: False. 

211 

212 Step result: {"branch", "base", "pushed", "pr_action"} + "pr_url" 

213 only when a PR was ensured; the ensured pr_url is also promoted to 

214 the top-level state key (the entrypoint reads it for the final 

215 status-callback PATCH, TICKET profile). 

216 

217 Env vars used: 

218 RUN_PROFILE — must be "TICKET" (guard; anything else raises) 

219 GIT_REPO_URL — code repo URL (owner/repo derivation) 

220 GITEA_BASE_URL, GIT_TOKEN — Gitea REST credentials (PR ensure) 

221 PR_TITLE, PR_DESCRIPTION — PR title/body from the ticket 

222 (PR_DESCRIPTION may be empty) 

223 GIT_AUTHOR_NAME, GIT_AUTHOR_EMAIL — commit identity overrides 

224 (optional; defaults agent-platform / agent-platform@localhost) 

225 State used (set by git-checkout): 

226 project_dir, ticket_branch, ticket_base_branch 

227 """ 

228 run_profile = os.environ.get("RUN_PROFILE") 

229 if run_profile != "TICKET": 

230 raise ValueError( 

231 f"ticket-publish requires RUN_PROFILE=TICKET (got {run_profile!r})" 

232 ) 

233 

234 project_dir = state.get("project_dir", "/project") 

235 branch = state.get("ticket_branch") 

236 base = state.get("ticket_base_branch") 

237 if not isinstance(branch, str) or not branch: 

238 raise ValueError( 

239 "ticket-publish: state['ticket_branch'] is missing — " 

240 "the git-checkout step must run first (TICKET profile)" 

241 ) 

242 if not isinstance(base, str) or not base: 

243 raise ValueError( 

244 "ticket-publish: state['ticket_base_branch'] is missing — " 

245 "the git-checkout step must run first (TICKET profile)" 

246 ) 

247 

248 repo_full_name = _repo_full_name(_require_env("GIT_REPO_URL")) 

249 title = _require_env("PR_TITLE") 

250 body = os.environ.get("PR_DESCRIPTION") or "" 

251 

252 pushed = _git_commit_and_push(project_dir, branch) 

253 

254 step_result = { 

255 "branch": branch, 

256 "base": base, 

257 "pushed": pushed, 

258 "pr_action": None, 

259 } 

260 if not pushed: 

261 return {**state, step.get("id", "ticket_publish"): step_result} 

262 

263 base_url = _require_env("GITEA_BASE_URL").rstrip("/") 

264 token = _require_env("GIT_TOKEN") 

265 pr_url, action = _ensure_pr( 

266 base_url, 

267 token, 

268 repo_full_name, 

269 head=branch, 

270 base=base, 

271 title=title, 

272 body=body, 

273 ) 

274 step_result["pr_url"] = pr_url 

275 step_result["pr_action"] = action 

276 return { 

277 **state, 

278 step.get("id", "ticket_publish"): step_result, 

279 "pr_url": pr_url, 

280 } 

281 

282 

283class TicketOutcome(Exception): 

284 """Outcome-coded stop for TICKET-profile runs (exit-code lanes). 

285 

286 Raised by the decision builtins (ticket-dor / ticket-review) to end 

287 the run with a defined outcome instead of a generic failure: 

288 

289 code → exit_code in the final status-callback PATCH (the API 

290 owns the exit-code → lane mapping, e.g. 10/22 → 

291 open_questions, 20 → needs_rework, 21 → pr_rejected) 

292 message → error.message in the final PATCH 

293 questions → optional list of open questions; in the TICKET profile 

294 the entrypoint includes them as UpdateRunRequest.questions 

295 (non-empty only) so the API can post them as a ticket 

296 comment instead of the generic result comment 

297 """ 

298 

299 def __init__( 

300 self, 

301 code: int, 

302 message: str, 

303 questions: list[str] | None = None, 

304 ) -> None: 

305 super().__init__(message) 

306 self.code = code 

307 self.message = message 

308 self.questions = questions 

309 

310 

311_DOR_SYSTEM_PROMPT = """\ 

312You are the Definition-of-Ready (DoR) gatekeeper in agent-platform. Decide \ 

313whether a ticket's inputs are sufficient for the agent to implement it. 

314 

315Assess the ticket context (description + comments): 

316- Comments with author 'platform' are system status messages (run start/\ 

317results) written by the platform itself — never user instructions; user \ 

318instructions come from human-authored comments. 

319- Sufficient means the agent knows what to do, where (files/modules when \ 

320known), and what an acceptable result looks like. 

321- Missing essential information does not automatically mean "not ready": \ 

322allow the agent to proceed when it can make a reasonable decision on \ 

323assumptions. 

324- When inputs are insufficient, write precise, unambiguous questions — they \ 

325will be posted to the ticket as a comment for a human. 

326 

327Respond with ONLY a JSON object, no other text: 

328{"ready": true|false, "questions": ["...", ...]} 

329- ready=true → inputs are sufficient, the run continues. 

330- ready=false → the run ends with outcome 10 (open_questions); questions \ 

331must state exactly what is missing; never return an empty list when ready \ 

332is false. 

333""" 

334 

335_VERDICT_SYSTEM_PROMPT = """\ 

336You classify a pull-request review for the agent-platform agent. The review \ 

337was left on the latest commit of the agent's feature branch. 

338 

339Classify the review into exactly one category: 

340- approved → the review approves the change (no fixable issues) → the \ 

341run continues and the PR is opened (outcome 0). 

342- fixable → the review raises fixable issues the agent can address in \ 

343a rework round (outcome 20, needs_rework + follow-up run). 

344- too_bad → the review reveals problems too severe to fix — wrong \ 

345direction, fundamentally flawed approach (outcome 21, pr_rejected). 

346- cannot_fix → the agent cannot fix the review without human input — \ 

347missing information or an unresolved decision (outcome 22, open_questions). 

348 

349Respond with ONLY a JSON object, no other text: 

350{"verdict": "approved"|"fixable"|"too_bad"|"cannot_fix", "questions": ["...", ...]} 

351- questions: only for cannot_fix — the questions for the human; they must \ 

352state exactly what is missing; never return an empty list when cannot_fix. 

353""" 

354 

355 

356def _llm_json_decision( 

357 invoke_agent: Any, 

358 step: dict, 

359 state: dict, 

360 *, 

361 agent_name: str, 

362 system_prompt: str, 

363 context: Any, 

364) -> dict: 

365 """Fixed-prompt LLM decision: invoke_agent(spec, step, state) → JSON dict. 

366 

367 The decision builtins have no manifest agent spec — they decide with 

368 a fixed system prompt through the pipeline's invoke_agent_fn 

369 (GraphBuilder wires it via BuiltinExecutor.invoke_agent_fn). The 

370 result must be a JSON object; anything else (non-JSON output, 

371 LLM-call error, missing step result) is a hard error (ADR-0012 — 

372 no silent fallback). 

373 """ 

374 step_id = step.get("id", agent_name) 

375 if invoke_agent is None: 

376 raise ValueError( 

377 f"{agent_name}: LLM invoker is not wired — " 

378 "GraphBuilder must set BuiltinExecutor.invoke_agent_fn" 

379 ) 

380 agent_spec: dict[str, Any] = { 

381 "metadata": {"name": agent_name}, 

382 "spec": { 

383 "model": {}, 

384 "system_prompt": system_prompt, 

385 "output": {"format": "json"}, 

386 }, 

387 } 

388 resolved_step: dict[str, Any] = {"id": step_id, "input": context, "params": {}} 

389 result = invoke_agent(agent_spec, resolved_step, state) 

390 out = result.get(step_id) if isinstance(result, dict) else None 

391 if not isinstance(out, dict): 

392 raise ValueError( 

393 f"{agent_name}: LLM call did not return a JSON object " 

394 f"(got {type(out).__name__})" 

395 ) 

396 if out.get("error"): 

397 raise ValueError(f"{agent_name}: LLM call failed: {out['error']}") 

398 return out 

399 

400 

401def _as_questions(raw: Any, builtin: str) -> list[str]: 

402 """Validate the LLM 'questions' field: list of strings (None → []).""" 

403 if raw is None: 

404 return [] 

405 if not isinstance(raw, list) or not all(isinstance(q, str) for q in raw): 

406 raise ValueError( 

407 f"{builtin}: 'questions' must be a list of strings, got {raw!r}" 

408 ) 

409 return raw 

410 

411 

412def builtin_ticket_dor(step: dict, state: dict, invoke_agent: Any) -> dict: 

413 """TICKET-profiilin Definition-of-Ready -päätös (LLM, outcome-koodi 10). 

414 

415 LLM arvioi tiketin lähtötietojen (TICKET_CONTEXT: kuvaus + kommentit) 

416 riittävyyden implementaatioon: 

417 

418 - Valmis → palaa state (run jatkuu; loppu-PATCH exit 0 → pr_open). 

419 - Ei-valmis → TicketOutcome(10) kysymyksineen: ajo päättyy, myöhempiä 

420 steppejä ei ajeta; kysymykset postataan tiketin kommenttina 

421 (UpdateRunRequest.questions → open_questions-lane). 

422 - TICKET_CONTEXT puuttuu → kova virhe (fail-fast, ADR-0012) — step 

423 vaatii kontekstin; väärä RUN_PROFILE → kova virhe. 

424 

425 Env vars used: 

426 RUN_PROFILE — must be "TICKET" (guard; anything else raises) 

427 TICKET_CONTEXT — JSON {"description": str, "comments": 

428 [{"author": str, "text": str}, ...]} 

429 """ 

430 run_profile = os.environ.get("RUN_PROFILE") 

431 if run_profile != "TICKET": 

432 raise ValueError( 

433 f"ticket-dor requires RUN_PROFILE=TICKET (got {run_profile!r})" 

434 ) 

435 

436 raw_context = os.environ.get("TICKET_CONTEXT") 

437 if not raw_context: 

438 raise ValueError( 

439 "ticket-dor: required env var 'TICKET_CONTEXT' is not set — " 

440 "the API could not fetch the ticket context (Vikunja-driven runs only)" 

441 ) 

442 try: 

443 ticket_context = json.loads(raw_context) 

444 except json.JSONDecodeError as exc: 

445 raise ValueError(f"ticket-dor: TICKET_CONTEXT is not valid JSON: {exc}") from exc 

446 if not isinstance(ticket_context, dict): 

447 raise ValueError( 

448 f"ticket-dor: TICKET_CONTEXT must be a JSON object, got {type(ticket_context).__name__}" 

449 ) 

450 

451 decision = _llm_json_decision( 

452 invoke_agent, 

453 step, 

454 state, 

455 agent_name="ticket-dor", 

456 system_prompt=_DOR_SYSTEM_PROMPT, 

457 context=ticket_context, 

458 ) 

459 

460 ready = decision.get("ready") 

461 if not isinstance(ready, bool): 

462 raise ValueError( 

463 f"ticket-dor: LLM decision missing boolean 'ready' field: {decision!r}" 

464 ) 

465 questions = _as_questions(decision.get("questions"), "ticket-dor") 

466 

467 step_id = step.get("id", "ticket_dor") 

468 if not ready: 

469 raise TicketOutcome( 

470 code=10, 

471 message=( 

472 "ticket-dor: ticket inputs insufficient for implementation — " 

473 f"{len(questions)} open question(s)" 

474 ), 

475 questions=questions, 

476 ) 

477 return {**state, step_id: {"ready": True, "questions": questions}} 

478 

479 

480def _pr_number_from_url(pr_url: str) -> str: 

481 """Derive the PR number from a PR URL (…/pulls/{number}); fail-fast.""" 

482 number = pr_url.rstrip("/").split("/")[-1] 

483 if not number.isdigit(): 

484 raise ValueError( 

485 f"ticket-review: cannot derive PR number from pr_url {pr_url!r}" 

486 ) 

487 return number 

488 

489 

490def _wait_for_head_review( 

491 base_url: str, 

492 token: str, 

493 repo_full_name: str, 

494 pr_number: str, 

495 *, 

496 poll_interval_seconds: int, 

497 timeout_seconds: int, 

498) -> dict: 

499 """Poll Gitea until the PR's head commit carries a review. 

500 

501 GET /pulls/{number} → head.sha; GET /pulls/{number}/reviews → keep 

502 only reviews with commit_id == head.sha. Reviews on earlier commits 

503 are stale (harmful) and must never trigger classification — the poll 

504 continues past them. Returns the newest head-commit review 

505 (submitted_at order). No review within timeout_seconds → ValueError 

506 (run-failure → failed lane). 

507 """ 

508 headers = {"Authorization": f"token {token}"} 

509 api = f"{base_url}/api/v1/repos/{repo_full_name}/pulls/{pr_number}" 

510 deadline = time.monotonic() + timeout_seconds 

511 

512 while True: 

513 pr_resp = httpx.get(api, headers=headers) 

514 if pr_resp.status_code >= 400: 

515 raise ValueError( 

516 f"ticket-review: PR GET failed ({pr_resp.status_code}): {pr_resp.text[:500]}" 

517 ) 

518 try: 

519 pr_data = pr_resp.json() 

520 except ValueError: 

521 raise ValueError( 

522 f"ticket-review: PR GET returned invalid JSON: {pr_resp.text[:500]}" 

523 ) from None 

524 head_sha = (pr_data.get("head") or {}).get("sha") if isinstance(pr_data, dict) else None 

525 if not isinstance(head_sha, str) or not head_sha: 

526 raise ValueError( 

527 f"ticket-review: PR GET response missing head.sha: {pr_resp.text[:500]}" 

528 ) 

529 

530 reviews_resp = httpx.get(f"{api}/reviews", headers=headers) 

531 if reviews_resp.status_code >= 400: 

532 raise ValueError( 

533 f"ticket-review: reviews GET failed ({reviews_resp.status_code}): {reviews_resp.text[:500]}" 

534 ) 

535 try: 

536 reviews = reviews_resp.json() 

537 except ValueError: 

538 raise ValueError( 

539 f"ticket-review: reviews GET returned invalid JSON: {reviews_resp.text[:500]}" 

540 ) from None 

541 if not isinstance(reviews, list): 

542 raise ValueError( 

543 f"ticket-review: reviews GET returned non-list JSON: {reviews_resp.text[:500]}" 

544 ) 

545 

546 head_reviews = [ 

547 r for r in reviews 

548 if isinstance(r, dict) and r.get("commit_id") == head_sha 

549 ] 

550 if head_reviews: 

551 head_reviews.sort(key=lambda r: r.get("submitted_at") or "") 

552 return head_reviews[-1] 

553 

554 if time.monotonic() >= deadline: 

555 raise ValueError( 

556 f"ticket-review: no review on the latest commit (head_sha {head_sha}) " 

557 f"within {timeout_seconds}s — giving up" 

558 ) 

559 time.sleep(poll_interval_seconds) 

560 

561 

562def builtin_ticket_review(step: dict, state: dict, invoke_agent: Any) -> dict: 

563 """Odota PR review ja luokittele se (LLM, outcome-koodit 0/20/21/22). 

564 

565 Tyypillisesti ticket-publishin jälkeen. Pollaa Gitea-API:a kunnes 

566 PR:n head-commitilla on review (vain commit_id == head.sha — aiempien 

567 committien reviewt ohitetaan haitallisina) ja luokittelee viimeisimmän 

568 review'n LLM:llä: 

569 

570 approved → palaa state (run jatkuu; loppu-PATCH exit 0 → pr_open) 

571 fixable → TicketOutcome(20) — needs_rework (+ jatkoajo) 

572 too_bad → TicketOutcome(21) — pr_rejected 

573 cannot_fix → TicketOutcome(22) + kysymykset — open_questions 

574 

575 Review ei saavu aikarajassa (timeout_seconds, oletus 900 s; 

576 poll_interval_seconds oletus 15 s) → kova virhe (run-failure → 

577 failed-kaista). Väärä RUN_PROFILE → kova virhe (ADR-0012). 

578 

579 Env vars used: 

580 RUN_PROFILE — must be "TICKET" (guard; anything else raises) 

581 GITEA_BASE_URL, GIT_TOKEN — Gitea REST credentials 

582 GIT_REPO_URL — code repo URL (repo full name derivation) 

583 State used (set by ticket-publish): 

584 pr_url (top-level), ticket_publish.step result: branch / base 

585 Step params: 

586 poll_interval_seconds (default 15), timeout_seconds (default 900) 

587 """ 

588 run_profile = os.environ.get("RUN_PROFILE") 

589 if run_profile != "TICKET": 

590 raise ValueError( 

591 f"ticket-review requires RUN_PROFILE=TICKET (got {run_profile!r})" 

592 ) 

593 

594 publish_result = state.get("ticket_publish") 

595 if not isinstance(publish_result, dict): 

596 publish_result = state.get("ticket-publish") 

597 pr_url = state.get("pr_url") 

598 if publish_result: 

599 pr_url = pr_url or publish_result.get("pr_url") 

600 if not isinstance(pr_url, str) or not pr_url: 

601 raise ValueError( 

602 "ticket-review: state['pr_url'] is missing — " 

603 "the ticket-publish step must run first (TICKET profile)" 

604 ) 

605 branch = publish_result.get("branch") if isinstance(publish_result, dict) else None 

606 base = publish_result.get("base") if isinstance(publish_result, dict) else None 

607 

608 params = step.get("params") or {} 

609 try: 

610 poll_interval = int(params.get("poll_interval_seconds", 15)) 

611 timeout = int(params.get("timeout_seconds", 900)) 

612 except (TypeError, ValueError) as exc: 

613 raise ValueError(f"ticket-review: invalid polling params: {exc}") from exc 

614 if poll_interval < 0 or timeout < 0: 

615 raise ValueError( 

616 "ticket-review: poll_interval_seconds and timeout_seconds must be >= 0" 

617 ) 

618 

619 base_url = _require_env("GITEA_BASE_URL").rstrip("/") 

620 token = _require_env("GIT_TOKEN") 

621 repo_full_name = _repo_full_name(_require_env("GIT_REPO_URL")) 

622 pr_number = _pr_number_from_url(pr_url) 

623 

624 review = _wait_for_head_review( 

625 base_url, 

626 token, 

627 repo_full_name, 

628 pr_number, 

629 poll_interval_seconds=poll_interval, 

630 timeout_seconds=timeout, 

631 ) 

632 

633 verdict = _llm_json_decision( 

634 invoke_agent, 

635 step, 

636 state, 

637 agent_name="ticket-review", 

638 system_prompt=_VERDICT_SYSTEM_PROMPT, 

639 context={ 

640 "review": { 

641 "body": review.get("body"), 

642 "state": review.get("state"), 

643 }, 

644 "pr_url": pr_url, 

645 "branch": branch, 

646 "base": base, 

647 }, 

648 ) 

649 

650 label = verdict.get("verdict") 

651 if label not in ("approved", "fixable", "too_bad", "cannot_fix"): 

652 raise ValueError( 

653 f"ticket-review: unknown verdict {label!r} from LLM: {verdict!r}" 

654 ) 

655 questions = _as_questions(verdict.get("questions"), "ticket-review") 

656 

657 step_id = step.get("id", "ticket_review") 

658 if label == "approved": 

659 return {**state, step_id: {"verdict": "approved", "questions": []}} 

660 

661 outcomes = { 

662 "fixable": ( 

663 20, 

664 "ticket-review: review found fixable issues — run needs rework (outcome 20)", 

665 ), 

666 "too_bad": ( 

667 21, 

668 "ticket-review: review found issues too severe to fix (outcome 21)", 

669 ), 

670 "cannot_fix": ( 

671 22, 

672 "ticket-review: review cannot be fixed without human input — " 

673 "questions posted to the ticket (outcome 22)", 

674 ), 

675 } 

676 code, message = outcomes[label] 

677 raise TicketOutcome(code=code, message=message, questions=questions or None) 

678 

679 

680def builtin_ticket_context(step: dict, state: dict) -> dict: 

681 """Tallenna TICKET_CONTEXT-envi stateen pipeline-agentin inputiksi. 

682 

683 Env: TICKET_CONTEXT — JSON {"description": str, "comments": 

684 [{"author": str, "text": str}, ...]} (tiketin kuvaus + koko 

685 kommenttihistoria author-merkinnöin). Tulos tallennetaan 

686 step-id-avaimella (oletus "ticket_context") JA top-level-avaimella 

687 "ticket_context" (state-dot-path, esim. ticket_context.comments). 

688 

689 Puuttuva env tai epävalidi JSON/rakenne → kova virhe (fail-fast, 

690 ADR-0012): API hard-fail-takaa envin asetuksen (Phase 2), joten tämä 

691 on viimeinen suojakerros. Vanha comments-muoto (list of strings) on 

692 kova virhe — comments on lista author-tekijäobjekteja. Väärä 

693 RUN_PROFILE → kova virhe. 

694 """ 

695 run_profile = os.environ.get("RUN_PROFILE") 

696 if run_profile != "TICKET": 

697 raise ValueError( 

698 f"ticket-context requires RUN_PROFILE=TICKET (got {run_profile!r})" 

699 ) 

700 

701 raw_context = os.environ.get("TICKET_CONTEXT") 

702 if not raw_context: 

703 raise ValueError( 

704 "ticket-context: required env var 'TICKET_CONTEXT' is not set — " 

705 "the API could not fetch the ticket context (Vikunja-driven runs only)" 

706 ) 

707 try: 

708 ticket_context = json.loads(raw_context) 

709 except json.JSONDecodeError as exc: 

710 raise ValueError( 

711 f"ticket-context: TICKET_CONTEXT is not valid JSON: {exc}" 

712 ) from exc 

713 if not isinstance(ticket_context, dict): 

714 raise ValueError( 

715 "ticket-context: TICKET_CONTEXT must be a JSON object, " 

716 f"got {type(ticket_context).__name__}" 

717 ) 

718 if not isinstance(ticket_context.get("description"), str): 

719 raise ValueError( 

720 "ticket-context: TICKET_CONTEXT['description'] must be a string" 

721 ) 

722 comments = ticket_context.get("comments") 

723 if not isinstance(comments, list) or not all( 

724 isinstance(c, dict) 

725 and isinstance(c.get("author"), str) 

726 and isinstance(c.get("text"), str) 

727 for c in comments 

728 ): 

729 raise ValueError( 

730 "ticket-context: TICKET_CONTEXT['comments'] must be a list of " 

731 "objects with string 'author' and 'text' fields" 

732 ) 

733 

734 step_id = step.get("id", "ticket_context") 

735 return {**state, step_id: ticket_context, "ticket_context": ticket_context} 

736 

737 

738def builtin_ticket_lane(step: dict, state: dict) -> dict: 

739 """Mapaa agentin päätöksen (step.input) lane-outcome-koodiin. 

740 

741 Input-sopimus (lukittu): 

742 - String-input sallittu: pr_open / open_questions / needs_rework / 

743 pr_rejected (reason tyhjä, questions tyhjä). 

744 - Objekti-input {"lane": str, "reason": str, "questions": [str, ...]?} 

745 sallittu kaikille; questions on valinnainen lista stringejä ja 

746 välittyy vain open_questions-outcomeen. 

747 - blocked VAATII objekti-inputin reason-syyn — string-input, objekti 

748 ilman reason-kenttää (tai ei-string reason) → kova virhe 

749 (fail-fast, ADR-0012): blocked-kommentti edellyttää syyn. Runner 

750 resolvoi step.inputin ennen builtin-kutsua. 

751 

752 Lane → outcome: 

753 pr_open → palaa state (run jatkuu → exit 0 → pr_open-kaista) 

754 open_questions→ TicketOutcome(22) + questions (22, EI 10 — 10 on 

755 DoR-spesifi; D3-fail-fast entrypointissa vaatii 

756 ei-tyhjän questionsin) 

757 blocked → TicketOutcome(11), message = paljas reason — 

758 kommentin "Blocked: <reason>" -formatin omistaa API 

759 needs_rework → TicketOutcome(20) 

760 pr_rejected → TicketOutcome(21) 

761 done / in_progress / failed / tuntematon → kova virhe (ValueError; 

762 done vain merge-webhook, in_progress runin aikainen tila, failed 

763 varattu teknisille virheille — kontrakti). 

764 

765 Muut kuin open_questions-lane-arvot: questions ohitetaan (ei aseteta). 

766 Väärä RUN_PROFILE → kova virhe (ADR-0012). 

767 """ 

768 run_profile = os.environ.get("RUN_PROFILE") 

769 if run_profile != "TICKET": 

770 raise ValueError( 

771 f"ticket-lane requires RUN_PROFILE=TICKET (got {run_profile!r})" 

772 ) 

773 

774 decision = step.get("input") 

775 if isinstance(decision, str): 

776 lane = decision 

777 reason = "" 

778 questions = None 

779 elif isinstance(decision, dict): 

780 lane = decision.get("lane") 

781 if not isinstance(lane, str): 

782 raise ValueError( 

783 "ticket-lane: object input must have a string 'lane' " 

784 f"field, got {decision!r}" 

785 ) 

786 reason = decision.get("reason", "") 

787 if not isinstance(reason, str): 

788 raise ValueError( 

789 f"ticket-lane: 'reason' must be a string, got {reason!r}" 

790 ) 

791 questions_raw = decision.get("questions") 

792 questions = ( 

793 None 

794 if questions_raw is None 

795 else _as_questions(questions_raw, "ticket-lane") 

796 ) 

797 else: 

798 raise ValueError( 

799 "ticket-lane: agent decision must be a lane string or " 

800 f"an object, got {type(decision).__name__}" 

801 ) 

802 

803 if lane == "pr_open": 

804 step_id = step.get("id", "ticket_lane") 

805 return {**state, step_id: {"lane": "pr_open"}} 

806 

807 if lane == "blocked": 

808 if not reason: 

809 raise ValueError( 

810 "ticket-lane: lane 'blocked' requires an object input with " 

811 "a non-empty 'reason' — the blocked comment needs the reason" 

812 ) 

813 raise TicketOutcome(code=11, message=reason) 

814 

815 if lane == "open_questions": 

816 raise TicketOutcome( 

817 code=22, 

818 message=( 

819 "ticket-lane: agent decided open_questions — run stopped, " 

820 "human input needed (outcome 22)" 

821 ), 

822 questions=questions, 

823 ) 

824 if lane == "needs_rework": 

825 raise TicketOutcome( 

826 code=20, 

827 message=( 

828 "ticket-lane: agent decided needs_rework — rework round " 

829 "required (outcome 20)" 

830 ), 

831 ) 

832 if lane == "pr_rejected": 

833 raise TicketOutcome( 

834 code=21, 

835 message="ticket-lane: agent decided pr_rejected (outcome 21)", 

836 ) 

837 if lane in ("done", "in_progress", "failed"): 

838 raise ValueError( 

839 f"ticket-lane: lane {lane!r} is not a valid agent decision — " 

840 "done (merge-webhook), in_progress (mid-run state) and failed " 

841 "(technical errors) are API-owned" 

842 ) 

843 raise ValueError(f"ticket-lane: unknown lane {lane!r}") 

844 

845 

846_APPROVAL_MARKER = re.compile(r"^\s*APPROVED\s*$", re.IGNORECASE) 

847 

848 

849def _approval_marker_text(text: str) -> str | None: 

850 """Return the trimmed comment when its whole content is the APPROVED marker.""" 

851 stripped = text.strip() 

852 if _APPROVAL_MARKER.fullmatch(stripped): 

853 return stripped 

854 return None 

855 

856 

857def builtin_ticket_approval(step: dict, state: dict) -> dict: 

858 """Deterministinen Stage A -hyväksyntämarkkerin tunnistus (ei LLM-kutsua). 

859 

860 Skannaa resolved step.inputin (ticket_context-objekti {"description", 

861 "comments", "blocked_by"}; runner resolvoi inputin ennen builtin-kutsua) 

862 ihmiskommentit (author ei "platform"/"unknown") ja tunnistaa 

863 hyväksynnän vain kommentista, jonka koko sisältö whitespace-trimmatttuna 

864 on APPROVED (kirjainkoosta riippumaton) — muuta tekstiä sisältävät 

865 kommentit (proosa, kysymykset, perustelut, moniriviset kommentit) 

866 eivät ole hyväksyntä. Tulos step-resulttiin {"passed": bool, "evidence": 

867 {"author", "comment_index", "snippet"} | null} — evidence ensimmäisestä 

868 osumasta (snippet = koko trimmatttu kommenttiteksti), null kun ei osumaa. 

869 

870 Epävalidi/muuttunut input-rakenne → kova virhe (fail-fast, ADR-0012) — 

871 virhe nimeää puutteen. Väärä RUN_PROFILE → kova virhe. 

872 """ 

873 run_profile = os.environ.get("RUN_PROFILE") 

874 if run_profile != "TICKET": 

875 raise ValueError( 

876 f"ticket-approval requires RUN_PROFILE=TICKET (got {run_profile!r})" 

877 ) 

878 

879 ticket_context = step.get("input") 

880 if not isinstance(ticket_context, dict): 

881 raise ValueError( 

882 "ticket-approval: step input must be a ticket_context object " 

883 f"with 'description' and 'comments', got {type(ticket_context).__name__}" 

884 ) 

885 if not isinstance(ticket_context.get("description"), str): 

886 raise ValueError( 

887 "ticket-approval: ticket_context['description'] must be a string" 

888 ) 

889 comments = ticket_context.get("comments") 

890 if not isinstance(comments, list) or not all( 

891 isinstance(c, dict) 

892 and isinstance(c.get("author"), str) 

893 and isinstance(c.get("text"), str) 

894 for c in comments 

895 ): 

896 raise ValueError( 

897 "ticket-approval: ticket_context['comments'] must be a list of " 

898 "objects with string 'author' and 'text' fields" 

899 ) 

900 

901 for index, comment in enumerate(comments): 

902 if comment["author"] in ("platform", "unknown"): 

903 continue 

904 snippet = _approval_marker_text(comment["text"]) 

905 if snippet is not None: 

906 step_id = step.get("id", "ticket_approval") 

907 evidence = {"author": comment["author"], "comment_index": index, "snippet": snippet} 

908 return {**state, step_id: {"passed": True, "evidence": evidence}} 

909 

910 step_id = step.get("id", "ticket_approval") 

911 return {**state, step_id: {"passed": False, "evidence": None}} 

912 

913 

914def _require_pm_env(builtin: str) -> tuple[str, str, str]: 

915 """Guard RUN_PROFILE=TICKET and require the PM API envs (fail-fast). 

916 

917 Returns (api_url, api_key, run_id); API_URL is joined without a 

918 trailing slash so the caller can append the internal API path. 

919 """ 

920 run_profile = os.environ.get("RUN_PROFILE") 

921 if run_profile != "TICKET": 

922 raise ValueError( 

923 f"{builtin} requires RUN_PROFILE=TICKET (got {run_profile!r})" 

924 ) 

925 missing = [name for name in ("API_URL", "API_KEY", "RUN_ID") if not os.environ.get(name)] 

926 if missing: 

927 raise ValueError( 

928 f"{builtin}: required env var(s) {', '.join(repr(n) for n in missing)} not set" 

929 ) 

930 return ( 

931 os.environ["API_URL"].rstrip("/"), 

932 os.environ["API_KEY"], 

933 os.environ["RUN_ID"], 

934 ) 

935 

936 

937def _valid_tickets(tickets: Any) -> bool: 

938 """Validate the pinned tickets-list shape: list of typed objects.""" 

939 if not isinstance(tickets, list): 

940 return False 

941 for ticket in tickets: 

942 if not ( 

943 isinstance(ticket, dict) 

944 and isinstance(ticket.get("id"), int) 

945 and isinstance(ticket.get("title"), str) 

946 and isinstance(ticket.get("done"), bool) 

947 and isinstance(ticket.get("description"), str) 

948 ): 

949 return False 

950 return True 

951 

952 

953def _fetch_open_pm_tickets(api_url: str, api_key: str, run_id: str, builtin: str) -> tuple[Any, str]: 

954 """GET the open PM tickets via the internal endpoint; fail-fast (ADR-0012). 

955 

956 Returns (payload, raw_text) so callers can include the response body in 

957 shape-validation errors. Non-2xx response or invalid JSON → ValueError. 

958 """ 

959 resp = httpx.get( 

960 f"{api_url}/api/v1/internal/pm/tickets", 

961 headers={"X-API-Key": api_key, "X-Run-ID": run_id}, 

962 ) 

963 if resp.status_code >= 400: 

964 raise ValueError( 

965 f"{builtin}: PM tickets GET failed ({resp.status_code}): {resp.text[:500]}" 

966 ) 

967 try: 

968 payload = resp.json() 

969 except ValueError: 

970 raise ValueError( 

971 f"{builtin}: PM tickets GET returned invalid JSON: {resp.text[:500]}" 

972 ) from None 

973 return payload, resp.text 

974 

975 

976def builtin_ticket_search(step: dict, state: dict) -> dict: 

977 """Hae PM-järjestelmän avoimet työkohteet API:n internal-endpointista. 

978 

979 GET {API_URL}/api/v1/internal/pm/tickets X-API-Key- ja X-Run-ID- 

980 headerein; API resolvoi runin pipeline-konfiguraation PM-asetuksiin 

981 (engine on PM-agnostinen — ei tuotesanastoa). Tulos step-resulttiin 

982 {"tickets": [{id, title, done, description}]} — tyhjä lista sallittu. 

983 Ei-2xx-vastaus tai odottamaton payload-muoto → kova virhe 

984 (fail-fast, ADR-0012). 

985 

986 Env vars used: 

987 RUN_PROFILE — must be "TICKET" (guard; anything else raises) 

988 API_URL, API_KEY, RUN_ID — API:n internal-portin credentials 

989 (X-Run-ID välittyy X-Run-ID-headerina) 

990 """ 

991 api_url, api_key, run_id = _require_pm_env("ticket-search") 

992 payload, raw = _fetch_open_pm_tickets(api_url, api_key, run_id, "ticket-search") 

993 tickets = payload.get("tickets") if isinstance(payload, dict) else None 

994 if not _valid_tickets(tickets): 

995 raise ValueError( 

996 "ticket-search: PM tickets GET response 'tickets' must be a list of " 

997 f"{{id: int, title: str, done: bool, description: str}}: {raw[:500]}" 

998 ) 

999 return {**state, step.get("id", "ticket_search"): {"tickets": tickets}} 

1000 

1001 

1002def _valid_dedupe_tickets(tickets: Any) -> bool: 

1003 """Validate the dedupe tickets shape: open items keyed by title.""" 

1004 if not isinstance(tickets, list): 

1005 return False 

1006 for ticket in tickets: 

1007 if not isinstance(ticket, dict): 

1008 return False 

1009 parent = ticket.get("parent_task_id") 

1010 if parent is not None and not isinstance(parent, int): 

1011 return False 

1012 if not ( 

1013 isinstance(ticket.get("id"), int) 

1014 and isinstance(ticket.get("title"), str) 

1015 and isinstance(ticket.get("done"), bool) 

1016 and isinstance(ticket.get("description"), str) 

1017 ): 

1018 return False 

1019 return True 

1020 

1021 

1022def _dedupe_skip(tickets: list, title: str, parent_task_id: int | None) -> dict | None: 

1023 """Return the would-be created item when an open ticket matches the title. 

1024 

1025 Match = ticket title equals the params title (both trimmed). The 

1026 skipped item carries the would-be created values, not the matched 

1027 ticket's. 

1028 """ 

1029 normalized = title.strip() 

1030 for ticket in tickets: 

1031 if ticket["title"].strip() == normalized: 

1032 return {"title": normalized, "parent_task_id": parent_task_id} 

1033 return None 

1034 

1035 

1036def builtin_ticket_create(step: dict, state: dict) -> dict: 

1037 """Luo yksi PM-työkohde API:n internal-endpointista (yksi per kutsu). 

1038 

1039 POST {API_URL}/api/v1/internal/pm/tickets X-API-Key- ja X-Run-ID- 

1040 headerein; API resolvoi runin PM-asetuksiin ja liittää X-Run-ID:n 

1041 traceability-merkintään. Body: title (pakollinen), description 

1042 (valinnainen, oletus ""), parent_task_id (valinnainen int — poissa 

1043 → ei vanhemmuusrelaatiota). 201 → step-resultti 

1044 {"created": [{id, url, title}]}. Ei-2xx-vastaus tai odottamaton 

1045 payload-muoto → kova virhe (fail-fast, ADR-0012). 

1046 

1047 Luontia edeltää deterministinen dedupe-tarkistus: avoimista 

1048 PM-työkohteista (ticket-search-mekaniikka) etsitään samaa titleä 

1049 (trimmatttuna); osuma → luontia ei tehdä (idempotentti re-run) ja 

1050 step-resultti {"created": [], "skipped": [{title, parent_task_id}]}. 

1051 GET epäonnistuu tai payload on odottamaton → kova virhe (ei luontia). 

1052 

1053 Env vars used: 

1054 RUN_PROFILE — must be "TICKET" (guard; anything else raises) 

1055 API_URL, API_KEY, RUN_ID — API:n internal-portin credentials 

1056 (X-Run-ID välittyy X-Run-ID-headerina) 

1057 Step params: 

1058 title — pakollinen non-empty string 

1059 description — valinnainen string (oletus "") 

1060 parent_task_id — valinnainen int 

1061 """ 

1062 api_url, api_key, run_id = _require_pm_env("ticket-create") 

1063 

1064 params = step.get("params") or {} 

1065 title = params.get("title") 

1066 if not isinstance(title, str) or not title.strip(): 

1067 raise ValueError( 

1068 "ticket-create: params['title'] is required (non-empty string)" 

1069 ) 

1070 description = params.get("description") 

1071 if description is None: 

1072 description = "" 

1073 elif not isinstance(description, str): 

1074 raise ValueError( 

1075 f"ticket-create: params['description'] must be a string, got {description!r}" 

1076 ) 

1077 parent_task_id = params.get("parent_task_id") 

1078 if parent_task_id is not None: 

1079 if not isinstance(parent_task_id, int): 

1080 raise ValueError( 

1081 "ticket-create: params['parent_task_id'] must be an int, " 

1082 f"got {parent_task_id!r}" 

1083 ) 

1084 

1085 payload, raw = _fetch_open_pm_tickets(api_url, api_key, run_id, "ticket-create") 

1086 tickets = payload.get("tickets") if isinstance(payload, dict) else None 

1087 if not _valid_dedupe_tickets(tickets): 

1088 raise ValueError( 

1089 "ticket-create: PM tickets GET response 'tickets' must be a list of " 

1090 f"{{id: int, title: str, done: bool, description: str}}: {raw[:500]}" 

1091 ) 

1092 skipped = _dedupe_skip(tickets, title, parent_task_id) 

1093 if skipped is not None: 

1094 return {**state, step.get("id", "ticket_create"): {"created": [], "skipped": [skipped]}} 

1095 

1096 body: dict[str, Any] = {"title": title, "description": description} 

1097 if parent_task_id is not None: 

1098 body["parent_task_id"] = parent_task_id 

1099 

1100 resp = httpx.post( 

1101 f"{api_url}/api/v1/internal/pm/tickets", 

1102 json=body, 

1103 headers={"X-API-Key": api_key, "X-Run-ID": run_id}, 

1104 ) 

1105 if resp.status_code >= 400: 

1106 raise ValueError( 

1107 f"ticket-create: PM tickets POST failed ({resp.status_code}): {resp.text[:500]}" 

1108 ) 

1109 try: 

1110 payload = resp.json() 

1111 except ValueError: 

1112 raise ValueError( 

1113 f"ticket-create: PM tickets POST returned invalid JSON: {resp.text[:500]}" 

1114 ) from None 

1115 if not ( 

1116 isinstance(payload, dict) 

1117 and isinstance(payload.get("id"), int) 

1118 and isinstance(payload.get("url"), str) 

1119 and isinstance(payload.get("title"), str) 

1120 ): 

1121 raise ValueError( 

1122 "ticket-create: PM tickets POST response must be " 

1123 f"{{id: int, url: str, title: str}}: {resp.text[:500]}" 

1124 ) 

1125 created = {"id": payload["id"], "url": payload["url"], "title": payload["title"]} 

1126 return {**state, step.get("id", "ticket_create"): {"created": [created], "skipped": []}} 

1127 

1128 

1129def _invoker_bound(builtin_fn: Any, executor: Any) -> Any: 

1130 """Bind a decision builtin to its executor's LLM invoker. 

1131 

1132 The engine calls builtins with the (step, state) signature; the 

1133 TICKET decision builtins additionally need the pipeline's 

1134 invoke_agent_fn, which GraphBuilder stores on the BuiltinExecutor 

1135 (invoke_agent_fn). The wrapper reads the attribute on every call, so 

1136 the binding order (executor setup vs. lazy module registration) does 

1137 not matter. 

1138 """ 

1139 

1140 def wrapper(step: dict, state: dict) -> dict: 

1141 return builtin_fn(step, state, executor.invoke_agent_fn) 

1142 

1143 return wrapper 

1144 

1145 

1146def register_all(executor: Any) -> None: 

1147 executor.register("ticket-publish", builtin_ticket_publish) 

1148 executor.register("ticket-context", builtin_ticket_context) 

1149 executor.register("ticket-lane", builtin_ticket_lane) 

1150 executor.register("ticket-dor", _invoker_bound(builtin_ticket_dor, executor)) 

1151 executor.register("ticket-review", _invoker_bound(builtin_ticket_review, executor)) 

1152 executor.register("ticket-search", builtin_ticket_search) 

1153 executor.register("ticket-create", builtin_ticket_create) 

1154 executor.register("ticket-approval", builtin_ticket_approval)