Coverage for core/langgraph/tools/run_finalizer.py: 87%
98 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-28 00:19 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-28 00:19 +0000
1"""Per-tool module for the `run-finalizer` deterministic builtin.
3Owns the tool's model-facing definition, its instruction surface (none) and the
4finalize execution itself — composing the run status/stats and appending the
5runlog line (ADR-0012). The definition and the execution here are the single
6source; the legacy builtin site delegates to the pure `run_finalizer` handler.
7"""
8from __future__ import annotations
10import json
11import logging
12import os
13from datetime import date
14from pathlib import Path
15from typing import Any
17from .contract import ToolContext, ToolResult
19log = logging.getLogger(__name__)
21RUN_FINALIZER_TOOL_NAME = "run-finalizer"
22DEFAULT_STEP_ID = "finalize"
24RUN_FINALIZER_DEFINITION: dict[str, Any] = {
25 "type": "function",
26 "function": {
27 "name": RUN_FINALIZER_TOOL_NAME,
28 "description": (
29 "Finalize the run with `run-finalizer` after all work steps. It "
30 "composes the run status and stats from state and appends a runlog "
31 "line; pass `pipeline_id` (and optionally `runlog`) so "
32 "the record is attributed correctly."
33 ),
34 "parameters": {
35 "type": "object",
36 "properties": {
37 "pipeline_id": {"type": "string"},
38 "runlog": {"type": "string"},
39 },
40 "required": [],
41 "additionalProperties": False,
42 },
43 },
44}
46_PARAM_KEYS = ("pipeline_id", "runlog")
49def _hard_failure_type() -> type[Exception]:
50 """The runner's hard-failure type, resolved without an import cycle."""
51 try:
52 from runner import RunHardFailure
53 except ImportError: # pragma: no cover — main.py run (qualified imports)
54 from langgraph.runner import RunHardFailure
55 return RunHardFailure
58def _hard_failure(code: str, message: str, details: dict | None = None) -> Exception:
59 return _hard_failure_type()(code, message, details)
62def run_finalizer(step: dict, state: dict) -> dict:
63 """
64 Compose the final status + runlog line + flush Langfuse.
65 params:
66 runlog: str (default .agent-platform/tmp/runlog)
67 pipeline_id: str
68 output (state[step.id]): {run_status, stats, runlog_path?}
69 """
70 step_id = step.get("id", "finalize")
71 params = step.get("params") or {}
72 pipeline_id = str(params.get("pipeline_id", "pipeline"))
73 root = Path(state.get("project_root") or os.getenv("PROJECT_ROOT", "/project"))
75 stats: dict[str, Any] = dict(state.get("stats") or {})
77 # Aggregate counts from common state fields if present
78 # foreach stores the publish step output (= {"path": rel}) as-is in the
79 # results list, not wrapped in {"publish": ...}. Both forms are supported.
80 process_results = state.get("process_findings") or []
81 if isinstance(process_results, list):
82 def _published_path(r: Any) -> str | None:
83 if not isinstance(r, dict):
84 return None
85 # Direct form: {"path": "..."} (foreach's publish_out)
86 if r.get("path"):
87 return r.get("path")
88 # Wrapped form: {"publish": {"path": "..."}}
89 return ((r.get("publish") or {}).get("path"))
91 published = [r for r in process_results if _published_path(r)]
92 stats["tickets_published"] = len(published)
93 stats["tickets_created"] = len(published)
95 merged = state.get("merge_findings")
96 if isinstance(merged, dict):
97 findings = merged.get("findings") or []
98 stats["findings_merged"] = len(findings)
100 gather = state.get("gather")
101 if isinstance(gather, dict) and gather.get("stats"):
102 for k, v in gather["stats"].items():
103 stats.setdefault(k, v)
105 run_status = state.get("run_status") or "ok"
106 if state.get("errors"):
107 run_status = "partial" if run_status != "failed" else run_status
109 # Append runlog line (best-effort)
110 runlog_path: str | None = None
111 runlog_dir = root / str(params.get("runlog", ".agent-platform/tmp/runlog"))
112 try:
113 runlog_dir.mkdir(parents=True, exist_ok=True)
114 line = {
115 "pipeline": pipeline_id,
116 "status": run_status,
117 "stats": stats,
118 "errors": (state.get("errors") or [])[:10],
119 }
120 target = runlog_dir / f"{date.today().isoformat()}-{pipeline_id}.jsonl"
121 with target.open("a", encoding="utf-8") as fh:
122 fh.write(json.dumps(line, ensure_ascii=False) + "\n")
123 runlog_path = str(target.relative_to(root)) if target.is_relative_to(root) else str(target)
124 except OSError as exc:
125 log.warning("[run-finalizer] runlog write failed: %s", exc)
126 except (TypeError, ValueError) as exc:
127 raise _hard_failure(
128 "EXECUTION_ERROR",
129 f"run-finalizer: runlog serialization failed: {exc}",
130 ) from exc
132 out_state = {
133 **state,
134 step_id: {"run_status": run_status, "stats": stats, "runlog_path": runlog_path},
135 "run_status": run_status,
136 "stats": stats,
137 }
138 return out_state
141def _state_patch(step_id: str, output: dict[str, Any], input_state: dict) -> dict[str, Any]:
142 """The legacy handler's new state keys; run_status/stats only when changed."""
143 patch: dict[str, Any] = {step_id: output}
144 for key in ("run_status", "stats"):
145 if input_state.get(key) != output[key]:
146 patch[key] = output[key]
147 return patch
150def _content_envelope(output: dict[str, Any]) -> str:
151 payload: dict[str, Any] = {"name": RUN_FINALIZER_TOOL_NAME, "ok": True}
152 for key in ("run_status", "runlog_path"):
153 value = output.get(key)
154 if value is not None:
155 payload[key] = value
156 return json.dumps(payload, ensure_ascii=False)
159def _failure_content(error: str) -> str:
160 return json.dumps(
161 {"name": RUN_FINALIZER_TOOL_NAME, "ok": False, "error": error},
162 ensure_ascii=False,
163 )
166class _RunFinalizerTool:
167 name = RUN_FINALIZER_TOOL_NAME
169 def definition(self) -> dict[str, Any]:
170 return RUN_FINALIZER_DEFINITION
172 def instruction(self) -> str | None:
173 return None
175 def params(self) -> list[dict[str, Any]] | None:
176 return None
178 def execute(self, context: ToolContext) -> ToolResult:
179 step_id = (context.step or {}).get("id") or DEFAULT_STEP_ID
180 args = context.args or {}
181 params = {key: args[key] for key in _PARAM_KEYS if key in args}
182 synthetic_step = {"id": step_id, "params": params}
183 input_state = context.state or {}
184 try:
185 new_state = run_finalizer(synthetic_step, input_state)
186 except _hard_failure_type() as exc:
187 return ToolResult(
188 ok=False,
189 content=_failure_content(exc.message),
190 error=exc.message,
191 )
192 output = new_state[step_id]
193 return ToolResult(
194 ok=True,
195 content=_content_envelope(output),
196 state_patch=_state_patch(step_id, output, input_state),
197 )
200TOOL = _RunFinalizerTool()