Coverage for core/langgraph/runner.py: 94%

2547 statements  

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

1""" 

2runner.py — YAML-manifest-pohjainen pipeline-moottori. 

3 

4Arkkitehtuuri: 

5 ManifestLoader → löytää ja parsii *.yaml manifestit 

6 ManifestRegistry → indeksoi Agent / AgentInstance / Pipeline -resurssit 

7 AgentResolver → yhdistää AgentInstance + based_on Agent (merge) 

8 GraphBuilder → rakentaa LangGraph StateGraphin Pipeline-manifestista 

9 build_workflow() → palauttaa compilatun LangGraph-workflown nimellä 

10 

11Käyttö: 

12 from langgraph.runner import build_workflow 

13 workflow = build_workflow("review-pr") 

14 result = workflow.invoke({"input": "..."}) 

15""" 

16from __future__ import annotations 

17 

18import hashlib 

19import logging 

20import operator 

21import os 

22import re 

23import threading 

24from dataclasses import dataclass 

25from concurrent.futures import ThreadPoolExecutor, as_completed 

26from pathlib import Path 

27from typing import Annotated, Any 

28 

29import yaml 

30 

31from langgraph.types import Send 

32 

33# SkillLoadError-import (ei sirkulaari-riskiä — _llm importtaa vain stdlibin): 

34# testit ja discover-wrapperit lataavat _llm:n tasaisena moduulina (sys.path 

35# insert core/langgraph/), main.py qualified (langgraph._llm, PYTHONPATH=/app). 

36# Kummatkin pinnat käännetään samaan _llm-tiedostoon; try/except varmistaa, 

37# että luokka on sama objekti kuin nostavalla puolella molemmissa konteksteissa. 

38try: 

39 from _llm import DEFAULT_MAX_PARALLEL, SkillLoadError, _PARENT_STACK # noqa: F401 

40except ImportError: # pragma: no cover — main.py-ajossa (qualified importit) 

41 from langgraph._llm import ( # noqa: F401 

42 DEFAULT_MAX_PARALLEL, 

43 SkillLoadError, 

44 _PARENT_STACK, 

45 ) 

46 

47# Same dual-import pattern as _llm (flat sys.path and qualified PYTHONPATH=/app) 

48try: 

49 from _step_functions import StepFunctionExecutor # noqa: F401 

50except ImportError: # pragma: no cover — main.py-ajossa (qualified importit) 

51 from langgraph._step_functions import StepFunctionExecutor # noqa: F401 

52 

53# Agent-instruction error code emitted by the engine error set — same 

54# dual-import pattern as _llm. 

55try: 

56 from _instructions import AGENT_INSTRUCTION_MISSING 

57except ImportError: # pragma: no cover — main.py-ajossa (qualified importit) 

58 from langgraph._instructions import AGENT_INSTRUCTION_MISSING 

59 

60# Structural tool resolution layer — same dual-import pattern as _llm. 

61try: 

62 from tools import ( 

63 RESOLVED_TOOLS_KEY, 

64 ToolCollisionError, 

65 ToolRegistryError, 

66 ToolResolver, 

67 build_platform_registry, 

68 ) 

69except ImportError: # pragma: no cover — main.py-ajossa (qualified importit) 

70 from langgraph.tools import ( 

71 RESOLVED_TOOLS_KEY, 

72 ToolCollisionError, 

73 ToolRegistryError, 

74 ToolResolver, 

75 build_platform_registry, 

76 ) 

77 

78# COMMUNICATION-context projection (declared input.channels reads) — same 

79# dual-import pattern as _llm. 

80try: 

81 from _data_structure import ( 

82 COMMUNICATION_CONTEXT_KEY, 

83 resolve_communication_context, 

84 ) 

85except ImportError: # pragma: no cover — main.py-ajossa (qualified importit) 

86 from langgraph._data_structure import ( 

87 COMMUNICATION_CONTEXT_KEY, 

88 resolve_communication_context, 

89 ) 

90 

91# Core-owned channel-context storage/commit/seeding primitives — same dual-import 

92# pattern. The channel path commits through these; it no longer materializes 

93# native LangGraph channels. 

94try: 

95 from _channels import ( 

96 CORE_KEY, 

97 LAST_VALUE, 

98 ChannelSpec, 

99 ChannelWorkingSet, 

100 parse_channel_specs, 

101 seed_committed, 

102 unwrap, 

103 wrap, 

104 ) 

105except ImportError: # pragma: no cover — main.py-ajossa (qualified importit) 

106 from langgraph._channels import ( 

107 CORE_KEY, 

108 LAST_VALUE, 

109 ChannelSpec, 

110 ChannelWorkingSet, 

111 parse_channel_specs, 

112 seed_committed, 

113 unwrap, 

114 wrap, 

115 ) 

116 

117# Core-owned reducer layer — same dual-import pattern. The map-reduce 

118# accumulator reducer folds concurrent ``Send`` worker writes per step id. 

119try: 

120 from _reducers import accumulate_map_reduce 

121except ImportError: # pragma: no cover — main.py-ajossa (qualified importit) 

122 from langgraph._reducers import accumulate_map_reduce 

123 

124# Agent output envelope error code — same dual-import pattern. 

125try: 

126 from _envelope import ENVELOPE_ERROR_CODE, ENVELOPE_STATUSES, is_error_envelope 

127except ImportError: # pragma: no cover — main.py-ajossa (qualified importit) 

128 from langgraph._envelope import ( # noqa: F401 

129 ENVELOPE_ERROR_CODE, 

130 ENVELOPE_STATUSES, 

131 is_error_envelope, 

132 ) 

133 

134log = logging.getLogger(__name__) 

135 

136 

137class RunHardFailure(Exception): 

138 """Run-tason kova virhe: ajo pysähtyy heti (phase failed, exit 1). 

139 

140 Nostetaan kun step-tulos kantaa kovan virheen: TRUNCATION_ERROR-koodi 

141 (kuratoidun kontekstin katkaisu), EXECUTION_ERROR-koodi (builtinien/stepien 

142 deterministinen tekninen vika), TOOL_FAILURE-koodi (agentin työkalupinnan 

143 tekninen vika), AGENT_ERROR-koodi (status:error-envelope, 

144 ENVELOPE_ERROR_CODE), CALL_NOT_ALLOWED/CALL_INVALID_PARAMS/CALL_FAILED 

145 (natiivi deterministic-tool-kutsu), BUILTIN_NAME_COLLISION (nimi sekä 

146 builtin- että StepFunction-rekisterissä) tai kontekstiylityksen 

147 signatuurin kantava VALIDATION_ERROR (assembled_tokens + context_budget, 

148 _llm._context_overflow_result). 

149 Kaikki muut virheluokat (budget:*, parse_error, timeout, LLM-virheet) 

150 säilyvät nykyisessä partial-semantiikassa. Stepin details kulkeutuu 

151 attribuuttina loppu-PATCHin error.details-kenttään. 

152 """ 

153 

154 ENGINE_ERROR_CODES: frozenset[str] = frozenset( 

155 { 

156 "TIMEOUT", 

157 "VALIDATION_ERROR", 

158 "CHANNEL_BINDING_ERROR", 

159 "INVALID_CHILD_PLAN", 

160 AGENT_INSTRUCTION_MISSING, 

161 "UNRESOLVED_OUTPUT", 

162 "BUILTIN_NAME_COLLISION", 

163 "EXECUTION_ERROR", 

164 ENVELOPE_ERROR_CODE, 

165 "TOOL_FAILURE", 

166 "CALL_NOT_ALLOWED", 

167 "CALL_INVALID_PARAMS", 

168 "CALL_FAILED", 

169 "RESOURCE_ERROR", 

170 "SPAWN_BUDGET_EXHAUSTED", 

171 "EXHAUSTED", 

172 "SPAWN_OVER_FLIGHT", 

173 "TRUNCATION_ERROR", 

174 # BLOCKED is API-owned, not engine-emitted; it stays in the set so 

175 # the guard never rejects a contract-valid value. 

176 "BLOCKED", 

177 } 

178 ) 

179 

180 HARD_ERROR_CODES: frozenset[str] = frozenset( 

181 { 

182 "TRUNCATION_ERROR", 

183 "TOOL_FAILURE", 

184 ENVELOPE_ERROR_CODE, 

185 "CALL_NOT_ALLOWED", 

186 "CALL_INVALID_PARAMS", 

187 "CALL_FAILED", 

188 "BUILTIN_NAME_COLLISION", 

189 } 

190 ) 

191 

192 def __init__(self, code: str, message: str, details: dict | None = None) -> None: 

193 if code not in self.ENGINE_ERROR_CODES: 

194 raise ValueError( 

195 f"RunHardFailure code {code!r} is not a member of the documented " 

196 f"engine error-code set {sorted(self.ENGINE_ERROR_CODES)}" 

197 ) 

198 if not isinstance(message, str) or not message: 

199 raise ValueError("RunHardFailure message must be a non-empty str") 

200 super().__init__(message) 

201 self.code = code 

202 self.message = message 

203 self.details = details 

204 

205 

206ENGINE_ERROR_CODES = RunHardFailure.ENGINE_ERROR_CODES 

207 

208 

209class SpawnBudget: 

210 """Per-tree agent-call budget shared across all spawn nodes. 

211 

212 The whole spawn tree draws from a single counter: every agent invocation 

213 (the director included) consumes one unit, so the total number of 

214 agent invocations the tree may make is bounded by `max_agent_calls`. This is a 

215 per-agent-run budget, NOT an LLM-roundtrip budget: a single agent run may 

216 make several LLM calls (e.g. an agent-side tool loop over its declared 

217 `tools`, resolved into tool entries); those LLM roundtrips are bounded by 

218 the run's `max_llm_calls`, not by 

219 `max_agent_calls`. There is no engine-level agent retry loop. Thread-safe so 

220 a concurrent caller cannot over-commit the shared counter. 

221 """ 

222 

223 def __init__(self, max_agent_calls: int | None) -> None: 

224 self._remaining: int | None = ( 

225 None if max_agent_calls is None else max(0, int(max_agent_calls)) 

226 ) 

227 self._lock = threading.Lock() 

228 

229 def consume(self) -> bool: 

230 """Reserve one agent call. Returns False when the budget is exhausted.""" 

231 with self._lock: 

232 if self._remaining is None: 

233 return True 

234 if self._remaining <= 0: 

235 return False 

236 self._remaining -= 1 

237 return True 

238 

239 

240_SPAWN_SOFT_FAIL_KEYS: frozenset[str] = frozenset({"error", "parse_error", "escalate"}) 

241 

242_SPAWN_DEFAULT_MAX_AGENT_CALLS: int = 64 

243_SPAWN_DEFAULT_MAX_DEPTH: int = 1 

244_SPAWN_DEFAULT_MAX_SPAWN_PER_NODE: int = 16 

245 

246# The spawn tree's superstep count is bounded by the agent-call budget, not by 

247# LangGraph's default recursion cap; lift it so a deep-but-budgeted tree runs. 

248_SPAWN_RECURSION_LIMIT: int = 100_000 

249 

250# --------------------------------------------------------------------------- 

251# Polut 

252# --------------------------------------------------------------------------- 

253 

254PROJECT_ROOT = Path(os.getenv("PROJECT_ROOT", "/project")) 

255MANIFEST_DIRS = [ 

256 PROJECT_ROOT / ".agent-platform" / "workflows", 

257 PROJECT_ROOT / ".agent-platform" / "agents", 

258] 

259SCHEMA_PATH = Path(__file__).parent.parent / "schemas" / "manifest.schema.json" 

260 

261 

262def resolve_manifest_dirs(clone_root: Path) -> list[Path]: 

263 """Palauttaa olemassa olevat manifestihakemistot kloonatusta manifest-reposta. 

264 

265 Skannaa ainoastaan `.agent-platform/workflows` ja `.agent-platform/agents` — 

266 ei koskaan koko kloonia. [] jos kumpaakaan ei ole (kutsuja → fail-fast). 

267 """ 

268 return [ 

269 d for d in ( 

270 clone_root / ".agent-platform" / "workflows", 

271 clone_root / ".agent-platform" / "agents", 

272 ) 

273 if d.is_dir() 

274 ] 

275 

276# --------------------------------------------------------------------------- 

277# Template-resoluutio 

278# --------------------------------------------------------------------------- 

279 

280_TMPL_RE = re.compile(r'\{\{\s*(.*?)\s*\}\}') 

281 

282# Sentinel for an unresolvable dot-path segment (distinct from a resolved None). 

283_UNRESOLVED = object() 

284 

285 

286def _split_key_indices(part: str) -> tuple[str, list[int]]: 

287 """Split a dot-path segment into a base key and a list of indices. 

288 

289 ``'a[0][1]'`` → ``('a', [0, 1])``; ``'a'`` → ``('a', [])``; ``'[0]'`` → 

290 ``('', [0])``. Only non-negative indices are supported (list access). 

291 """ 

292 m = re.fullmatch(r'([^\[]*)((\[\d+\])*)', part) 

293 base = m.group(1) 

294 indices = [int(x) for x in re.findall(r'\d+', m.group(2))] 

295 return base, indices 

296 

297 

298def _apply_indices(obj: Any, indices: list[int]) -> Any: 

299 """Index a (possibly chained) list/tuple; ``_UNRESOLVED`` on out-of-range.""" 

300 for idx in indices: 

301 if not isinstance(obj, (list, tuple)) or not (0 <= idx < len(obj)): 

302 return _UNRESOLVED 

303 obj = obj[idx] 

304 return obj 

305 

306 

307def _dot_get_segment(obj: Any, part: str) -> Any: 

308 """Resolve one dot-path segment against a dict or an indexed sequence. 

309 

310 A segment may be a bare key (``'a'``), a keyed list-index (``'a[0]'``, 

311 ``'a[0][1]'``), or a bare index into a sequence. Returns the resolved value 

312 or the ``_UNRESOLVED`` sentinel when the segment cannot be resolved (missing 

313 key, non-dict/non-sequence mid-path, or out-of-range index). 

314 """ 

315 base, indices = _split_key_indices(part) 

316 if isinstance(obj, dict): 

317 if base: 

318 if base not in obj: 

319 return _UNRESOLVED 

320 obj = obj[base] 

321 return _apply_indices(obj, indices) 

322 if isinstance(obj, (list, tuple)): 

323 if indices: 

324 return _apply_indices(obj, indices) 

325 if base and base.isdigit(): 

326 return _apply_indices(obj, [int(base)]) 

327 return _UNRESOLVED 

328 return _UNRESOLVED 

329 

330 

331def _dot_get(obj: Any, path: str) -> Any: 

332 """Navigate nested dicts/lists via dot-path: 'a.b.c' → obj['a']['b']['c']. 

333 

334 Supports list-index segments (``results[1].field``) and dot-into-aggregate 

335 traversal (missing key / non-dict-non-list mid-path / out-of-range index 

336 resolve to None — lenient semantics for non-output refs). 

337 """ 

338 for part in path.split('.'): 

339 obj = _dot_get_segment(obj, part) 

340 if obj is _UNRESOLVED: 

341 return None 

342 return obj 

343 

344 

345class _ExpressionError(ValueError): 

346 """A malformed template/condition expression at the evaluation boundary. 

347 

348 Raised by the expression tokenizer, parser, and evaluator so 

349 ``_resolve_template`` relabels only design-level expression failures as 

350 ``VALIDATION_ERROR``. Subclasses ``ValueError`` so callers that already 

351 treat expression failures as ``ValueError`` keep working. 

352 """ 

353 

354 

355class _UnresolvedReference(_ExpressionError): 

356 """A strict dot-path read hit an absent key or a non-mapping segment. 

357 

358 Subclasses ``ValueError`` (via ``_ExpressionError``) so existing lenient 

359 callers that treat any strict read failure as "unresolved" 

360 (``__call_emit`` optional outputs, ``_builtins._resolve_dot_path``) keep 

361 working while the template/condition boundary can still tell an unresolved 

362 reference apart from a parse error. 

363 """ 

364 

365 

366def _dot_get_strict(obj: Any, path: str) -> Any: 

367 """Dot-path navigation that raises on an unresolved reference. 

368 

369 A reference is unresolved when a key on the path is absent or a segment is 

370 not a mapping — used by condition evaluation so an undefined or skipped 

371 step's field is never silently evaluated to falsy (ADR-0012 spirit). Also 

372 used by ``__call_emit`` to distinguish a resolved output source from an 

373 unresolved one (required output → hard error, ADR-0012). 

374 """ 

375 for part in path.split('.'): 

376 if obj is None: 

377 raise _UnresolvedReference( 

378 f"Unresolved reference '{path}': '{part}' is not a mapping" 

379 ) 

380 obj = _dot_get_segment(obj, part) 

381 if obj is _UNRESOLVED: 

382 raise _UnresolvedReference( 

383 f"Unresolved reference '{path}': '{part}' is not a mapping" 

384 ) 

385 return obj 

386 

387 

388def materialize_pipeline_output(pipeline_spec: dict, final_state: dict) -> Any: 

389 """Resolve a Pipeline's declared run-end output. 

390 

391 Reads ``spec.output.source`` (a dot-path) out of ``final_state`` via 

392 ``_dot_get`` and returns the surfaced value. Returns ``None`` when no 

393 ``spec.output`` is declared, when it has no ``source``, or when the source 

394 is unresolved — this is a run-end surfacing (distinct from the emit-time 

395 hard-error used for function-interface outputs): an optional run output 

396 that cannot be resolved surfaces as None, never a run-end failure. 

397 """ 

398 out_spec = (pipeline_spec or {}).get("output") 

399 if not out_spec: 

400 return None 

401 source = out_spec.get("source") 

402 if not source: 

403 return None 

404 return _dot_get(final_state, source) 

405 

406 

407def _merge_side_channel(base: dict | None, additions: dict) -> dict: 

408 """Yhdistä rinnakkaisen side-channel-envelopen (step_context_metrics / 

409 step_tool_meta) kerrokset: myöhempi kirjoitus voittaa (sama semantiikka kuin 

410 state-merge {**state, ...}).""" 

411 merged = dict(base or {}) 

412 merged.update(additions) 

413 return merged 

414 

415 

416def _assemble_spawn_outcome(records: dict) -> tuple[dict, dict, dict]: 

417 """Assemble the spawn result envelope and side channels from leaf records. 

418 

419 ``records`` maps each node's plan-order path key to its record. Children 

420 keys are rebuilt in plan order (paths sort before their descendants), and 

421 the director's own envelope is the tree root at ``()``. Side channels fold 

422 in plan order (director first, then children). 

423 """ 

424 ordered = sorted(records) 

425 envelopes: dict = {} 

426 for path in reversed(ordered): 

427 record = records[path] 

428 children: dict = {} 

429 failing: list = [] 

430 for candidate in ordered: 

431 child = records[candidate] 

432 if child.get("parent_path") != path: 

433 continue 

434 child_envelope = envelopes[candidate] 

435 children[child["child_id"]] = child_envelope 

436 if not child_envelope["passed"]: 

437 failing.append(child["child_id"]) 

438 envelopes[path] = { 

439 "result": record["result"], 

440 "children": children, 

441 "passed": record["self_passed"] and not failing, 

442 "failing": failing, 

443 "exhausted": record["exhausted"], 

444 } 

445 metrics: dict = {} 

446 tool_meta: dict = {} 

447 for path in ordered: 

448 metrics = _merge_side_channel(metrics, records[path].get("metrics") or {}) 

449 tool_meta = _merge_side_channel( 

450 tool_meta, records[path].get("tool_meta") or {} 

451 ) 

452 return envelopes[()], metrics, tool_meta 

453 

454 

455_MULTI_OPS = ('<=', '>=', '==', '!=') 

456_SINGLE_OPS = '+-<>' 

457_WORD_BREAK = set("()[],+<=>!'-") 

458_CMP_OPS = frozenset(('==', '!=', '<=', '>=', '<', '>')) 

459 

460 

461def _tokenize_expr(expr: str) -> list[tuple[str, Any]]: 

462 """Split an expression into (kind, value) tokens. 

463 

464 Kinds: 'op' (+ - <= >= == != < >), 'punct' ( ) [ ] ,, 'num', 'str', 

465 'name'. A 'name' is a dot-path reference or a bare word (e.g. an unquoted 

466 comparison literal, true/false/None, and/or/not). 

467 """ 

468 tokens: list[tuple[str, Any]] = [] 

469 i, n = 0, len(expr) 

470 while i < n: 

471 ch = expr[i] 

472 if ch.isspace(): 

473 i += 1 

474 continue 

475 if ch in '()[],': 

476 tokens.append(('punct', ch)) 

477 i += 1 

478 continue 

479 if ch in '+-<=>!': 

480 two = expr[i:i + 2] 

481 if two in _MULTI_OPS: 

482 tokens.append(('op', two)) 

483 i += 2 

484 continue 

485 if ch in _SINGLE_OPS: 

486 tokens.append(('op', ch)) 

487 i += 1 

488 continue 

489 raise _ExpressionError(f"Invalid operator in expression: {two!r}") 

490 if ch in ('\'', '"'): 

491 j = i + 1 

492 buf: list[str] = [] 

493 while j < n and expr[j] != ch: 

494 buf.append(expr[j]) 

495 j += 1 

496 if j >= n: 

497 raise _ExpressionError(f"Unterminated string literal in expression: {expr!r}") 

498 tokens.append(('str', ''.join(buf))) 

499 i = j + 1 

500 continue 

501 if ch.isdigit() or (ch == '.' and i + 1 < n and expr[i + 1].isdigit()): 

502 j = i 

503 if ch == '.': 

504 j += 1 

505 while j < n and expr[j].isdigit(): 

506 j += 1 

507 tokens.append(('num', float(expr[i:j]))) 

508 else: 

509 while j < n and expr[j].isdigit(): 

510 j += 1 

511 if j < n and expr[j] == '.' and j + 1 < n and expr[j + 1].isdigit(): 

512 j += 1 

513 while j < n and expr[j].isdigit(): 

514 j += 1 

515 tokens.append(('num', float(expr[i:j]))) 

516 else: 

517 tokens.append(('num', int(expr[i:j]))) 

518 i = j 

519 continue 

520 j = i 

521 while j < n and not expr[j].isspace() and expr[j] not in _WORD_BREAK: 

522 j += 1 

523 if j == i: 

524 raise _ExpressionError(f"Unexpected character in expression: {expr[i:]!r}") 

525 tokens.append(('name', expr[i:j])) 

526 i = j 

527 return tokens 

528 

529 

530# AST node shapes: 

531# ('lit', value) literal (str/num/bool/None) 

532# ('ref', name) dot-path reference 

533# ('list', [node, ...]) [a, b, c] literal 

534# ('neg', node) unary minus 

535# ('not', node) not X 

536# ('cmp', op, left, right) comparison 

537# ('binop', 'and'|'or'|'+', l, r) binary operator 

538 

539 

540class _ExprParser: 

541 """Recursive-descent parser for template/condition expressions. 

542 

543 Precedence (loosest first): or < and < not < comparison < '+' < unary. 

544 Grammar: 

545 expr := or_expr 

546 or_expr := and_expr ('or' and_expr)* 

547 and_expr := not_expr ('and' not_expr)* 

548 not_expr := 'not' not_expr | cmp_expr 

549 cmp_expr := add_expr (cmp_op add_expr)? 

550 add_expr := unary_expr ('+' unary_expr)* 

551 unary_expr:= '-' unary_expr | primary 

552 primary := num | str | true/false/None | list | '(' expr ')' | name 

553 `and`/`or` parse into a full AST but EVALUATE with left-to-right 

554 short-circuit, so the right operand is only resolved if needed and an 

555 unresolved read there never raises. 

556 """ 

557 

558 def __init__(self, tokens: list[tuple[str, Any]]) -> None: 

559 self._tokens = tokens 

560 self._i = 0 

561 

562 def parse(self) -> tuple: 

563 node = self._or_expr() 

564 if self._i != len(self._tokens): 

565 tok = self._tokens[self._i] 

566 raise _ExpressionError(f"Unexpected token in expression: {tok[1]!r}") 

567 return node 

568 

569 def _peek(self) -> tuple[str, Any] | None: 

570 return self._tokens[self._i] if self._i < len(self._tokens) else None 

571 

572 def _next(self) -> tuple[str, Any]: 

573 tok = self._tokens[self._i] 

574 self._i += 1 

575 return tok 

576 

577 def _peek_name(self, kw: str) -> bool: 

578 tok = self._peek() 

579 return tok is not None and tok[0] == 'name' and tok[1] == kw 

580 

581 def _peek_op(self, op: str) -> bool: 

582 tok = self._peek() 

583 return tok is not None and tok[0] == 'op' and tok[1] == op 

584 

585 def _expect_punct(self, value: str) -> None: 

586 tok = self._next() 

587 if tok != ('punct', value): 

588 raise _ExpressionError(f"Expected '{value}' in expression, got {tok[1]!r}") 

589 

590 def _or_expr(self) -> tuple: 

591 node = self._and_expr() 

592 while self._peek_name('or'): 

593 self._next() 

594 right = self._and_expr() 

595 node = ('binop', 'or', node, right) 

596 return node 

597 

598 def _and_expr(self) -> tuple: 

599 node = self._not_expr() 

600 while self._peek_name('and'): 

601 self._next() 

602 right = self._not_expr() 

603 node = ('binop', 'and', node, right) 

604 return node 

605 

606 def _not_expr(self) -> tuple: 

607 if self._peek_name('not'): 

608 self._next() 

609 return ('not', self._not_expr()) 

610 return self._cmp_expr() 

611 

612 def _cmp_expr(self) -> tuple: 

613 node = self._add_expr() 

614 tok = self._peek() 

615 if tok is not None and tok[0] == 'op' and tok[1] in _CMP_OPS: 

616 self._next() 

617 right = self._add_expr() 

618 node = ('cmp', tok[1], node, right) 

619 return node 

620 

621 def _add_expr(self) -> tuple: 

622 node = self._unary_expr() 

623 while self._peek_op('+'): 

624 self._next() 

625 right = self._unary_expr() 

626 node = ('binop', '+', node, right) 

627 return node 

628 

629 def _unary_expr(self) -> tuple: 

630 if self._peek_op('-'): 

631 self._next() 

632 node = self._unary_expr() 

633 if node[0] == 'lit' and isinstance(node[1], (int, float)) and not isinstance(node[1], bool): 

634 return ('lit', -node[1]) 

635 return ('neg', node) 

636 return self._primary() 

637 

638 def _primary(self) -> tuple: 

639 tok = self._peek() 

640 if tok is None: 

641 raise _ExpressionError("Unexpected end of expression") 

642 kind, value = tok 

643 if kind == 'num': 

644 self._next() 

645 return ('lit', value) 

646 if kind == 'str': 

647 self._next() 

648 return ('lit', value) 

649 if kind == 'punct': 

650 if value == '(': 

651 self._next() 

652 node = self._or_expr() 

653 self._expect_punct(')') 

654 return node 

655 if value == '[': 

656 self._next() 

657 items: list[tuple] = [] 

658 if self._peek() != ('punct', ']'): 

659 while True: 

660 items.append(self._or_expr()) 

661 if self._peek() == ('punct', ','): 

662 self._next() 

663 continue 

664 break 

665 self._expect_punct(']') 

666 return ('list', items) 

667 raise _ExpressionError(f"Unexpected punctuation in expression: {value!r}") 

668 if kind == 'name': 

669 self._next() 

670 lowered = value.lower() 

671 if lowered == 'true': 

672 return ('lit', True) 

673 if lowered == 'false': 

674 return ('lit', False) 

675 if lowered in ('none', 'null'): 

676 return ('lit', None) 

677 if value in ('and', 'or', 'not'): 

678 raise _ExpressionError(f"Unexpected keyword in expression: {value!r}") 

679 return ('ref', value) 

680 raise _ExpressionError(f"Unexpected token in expression: {value!r}") 

681 

682 

683def _is_number(value: Any) -> bool: 

684 return isinstance(value, (int, float)) and not isinstance(value, bool) 

685 

686 

687def _add_values(left: Any, right: Any) -> Any: 

688 """Binary `+`. Lists concatenate, numbers add, strings concatenate.""" 

689 if isinstance(left, list) and isinstance(right, list): 

690 return left + right 

691 if _is_number(left) and _is_number(right): 

692 return left + right 

693 if isinstance(left, str) and isinstance(right, str): 

694 return left + right 

695 raise _ExpressionError( 

696 f"Unsupported operands for '+': {type(left).__name__} + {type(right).__name__}" 

697 ) 

698 

699 

700def _eval_comparison_rhs(node: tuple, ctx: dict, strict: bool) -> Any: 

701 """Comparison right-hand side. A bare non-dotted reference compares as a 

702 string literal (preserving `profile == PR`); everything else evaluates 

703 normally (numbers, quoted strings, dotted refs, lists, grouped exprs).""" 

704 if node[0] == 'ref' and '.' not in node[1]: 

705 return node[1] 

706 return _eval_ast(node, ctx, strict) 

707 

708 

709def _eval_cmp(op: str, left_node: tuple, right_node: tuple, ctx: dict, strict: bool) -> bool: 

710 left = _eval_ast(left_node, ctx, strict) 

711 right = _eval_comparison_rhs(right_node, ctx, strict) 

712 try: 

713 if op == '==': 

714 return left == right 

715 if op == '!=': 

716 return left != right 

717 if op == '<=': 

718 return left <= right 

719 if op == '>=': 

720 return left >= right 

721 if op == '<': 

722 return left < right 

723 if op == '>': 

724 return left > right 

725 except TypeError: 

726 return False 

727 raise _ExpressionError(f"Unknown comparison operator: {op!r}") 

728 

729 

730def _eval_ast(node: tuple, ctx: dict, strict: bool) -> Any: 

731 """Evaluate a parsed AST. `strict=True` raises on an unresolved reference 

732 read (condition/exit_condition); `and`/`or` short-circuit left-to-right.""" 

733 kind = node[0] 

734 if kind == 'lit': 

735 return node[1] 

736 if kind == 'ref': 

737 return _dot_get_strict(ctx, node[1]) if strict else _dot_get(ctx, node[1]) 

738 if kind == 'list': 

739 return [_eval_ast(item, ctx, strict) for item in node[1]] 

740 if kind == 'neg': 

741 operand = _eval_ast(node[1], ctx, strict) 

742 if not _is_number(operand): 

743 raise _ExpressionError( 

744 f"Unsupported operand for unary '-': {type(operand).__name__}" 

745 ) 

746 return -operand 

747 if kind == 'not': 

748 return not bool(_eval_ast(node[1], ctx, strict)) 

749 if kind == 'cmp': 

750 return _eval_cmp(node[1], node[2], node[3], ctx, strict) 

751 if kind == 'binop': 

752 op, left_node, right_node = node[1], node[2], node[3] 

753 if op == 'and': 

754 if not bool(_eval_ast(left_node, ctx, strict)): 

755 return False 

756 return bool(_eval_ast(right_node, ctx, strict)) 

757 if op == 'or': 

758 if bool(_eval_ast(left_node, ctx, strict)): 

759 return True 

760 return bool(_eval_ast(right_node, ctx, strict)) 

761 if op == '+': 

762 left = _eval_ast(left_node, ctx, strict) 

763 right = _eval_ast(right_node, ctx, strict) 

764 return _add_values(left, right) 

765 raise _ExpressionError(f"Unknown binary operator: {op!r}") 

766 raise _ExpressionError(f"Unknown expression node: {kind!r}") 

767 

768 

769def _eval_expr(expr: str, ctx: dict, strict: bool = False) -> Any: 

770 """Evaluate a template expression against context dict. 

771 

772 `strict` is used by condition evaluation: dot-path access to an unresolved 

773 reference raises (ValueError) instead of silently resolving to falsy, and 

774 and/or short-circuit left-to-right so a value is never read when the left 

775 side already determines the result. 

776 """ 

777 expr = expr.strip() 

778 tokens = _tokenize_expr(expr) 

779 if not tokens: 

780 return _dot_get_strict(ctx, expr) if strict else _dot_get(ctx, expr) 

781 return _eval_ast(_ExprParser(tokens).parse(), ctx, strict) 

782 

783 

784def _resolve_template(value: Any, ctx: dict, strict: bool = False) -> Any: 

785 """Recursively resolve {{ expr }} templates in strings / dicts / lists. 

786 

787 The template-evaluation boundary: a design-level failure (an unresolved 

788 strict reference, or a malformed expression) surfaces as a typed 

789 ``RunHardFailure`` here, never as a raw parser ``ValueError`` that a 

790 concurrent worker would record as a code-less per-item error. 

791 """ 

792 try: 

793 return _resolve_template_inner(value, ctx, strict) 

794 except _UnresolvedReference as exc: 

795 raise RunHardFailure( 

796 "UNRESOLVED_OUTPUT", 

797 str(exc), 

798 ) from exc 

799 except _ExpressionError as exc: 

800 raise RunHardFailure( 

801 "VALIDATION_ERROR", 

802 f"Invalid template/condition expression: {exc}", 

803 ) from exc 

804 

805 

806def _resolve_template_inner(value: Any, ctx: dict, strict: bool = False) -> Any: 

807 if isinstance(value, str): 

808 stripped = value.strip() 

809 # Entire string is one template → replace with actual type (not string) 

810 m = _TMPL_RE.fullmatch(stripped) 

811 if m: 

812 return _eval_expr(m.group(1), ctx, strict) 

813 # Inline substitution → always returns string 

814 def _replace(m: re.Match) -> str: 

815 result = _eval_expr(m.group(1), ctx, strict) 

816 return '' if result is None else str(result) 

817 return _TMPL_RE.sub(_replace, value) 

818 if isinstance(value, dict): 

819 return {k: _resolve_template_inner(v, ctx, strict) for k, v in value.items()} 

820 if isinstance(value, list): 

821 return [_resolve_template_inner(v, ctx, strict) for v in value] 

822 return value 

823 

824 

825def _resolve_input(input_spec: Any, state: dict, ctx: dict) -> Any: 

826 """ 

827 Resolve step input spec to a concrete value: 

828 "{{ item }}" → template 

829 "[host-ai-context-map-path]" → literal placeholder (verbatim pass-through) 

830 "gather.doc_files" → dot-path into state 

831 {"k": "a.b"} → dict with each value resolved 

832 None → None 

833 """ 

834 if input_spec is None: 

835 return None 

836 if isinstance(input_spec, str): 

837 if '{{' in input_spec: 

838 return _resolve_template(input_spec, ctx) 

839 stripped = input_spec.lstrip() 

840 # A bracket-prefixed literal (e.g. a host-ai-context-map-path 

841 # placeholder) is NOT a dot-path reference into state — hand it to the 

842 # builtin verbatim so it resolves the placeholder itself. 

843 if stripped.startswith('['): 

844 return input_spec 

845 # Dot-path into state (no braces) 

846 return _dot_get(state, stripped) 

847 if isinstance(input_spec, dict): 

848 return {k: _resolve_input(v, state, ctx) for k, v in input_spec.items()} 

849 if isinstance(input_spec, list): 

850 return [_resolve_input(v, state, ctx) for v in input_spec] 

851 return input_spec 

852 

853 

854def _eval_condition(cond: Any, ctx: dict) -> bool: 

855 """Evaluate a step condition. None → True (always run). 

856 

857 A list condition is the logical AND of its elements; this is how a 

858 call-level condition is combined with a body step's own condition. 

859 

860 Strict (short-circuit + hard error on unresolved reads) per R1: an 

861 undefined or skipped-step field read without short-circuit protection 

862 raises, never silently evaluates to falsy. 

863 """ 

864 if cond is None: 

865 return True 

866 if isinstance(cond, (list, tuple)): 

867 return all(_eval_condition(part, ctx) for part in cond) 

868 resolved = _resolve_template(cond, ctx, strict=True) 

869 if isinstance(resolved, bool): 

870 return resolved 

871 if isinstance(resolved, str): 

872 return resolved.lower() not in ('false', '0', '', 'none', 'null') 

873 return bool(resolved) 

874 

875 

876def _and_conditions(outer: Any, inner: Any) -> Any: 

877 """Combine two step conditions with logical AND. None → the other side.""" 

878 if outer is None: 

879 return inner 

880 if inner is None: 

881 return outer 

882 if isinstance(inner, list): 

883 return [outer, *inner] 

884 return [outer, inner] 

885 

886 

887def _resolve_int(value: Any, ctx: dict, default: int) -> int: 

888 v = _resolve_template(value, ctx) if isinstance(value, str) else value 

889 try: 

890 return int(v) 

891 except (TypeError, ValueError): 

892 return default 

893 

894 

895# --------------------------------------------------------------------------- 

896# ManifestLoader 

897# --------------------------------------------------------------------------- 

898 

899 

900def _parse_manifest_file(path: Path) -> list[dict]: 

901 """Palauttaa kaikki apiVersion: agent-platform/v1 -dokumentit tiedostosta.""" 

902 try: 

903 content = path.read_text(encoding="utf-8") 

904 except OSError as exc: 

905 log.warning("Cannot read %s: %s", path, exc) 

906 return [] 

907 

908 results: list[dict] = [] 

909 for raw in content.split("\n---"): 

910 raw_clean = "\n".join( 

911 line for line in raw.splitlines() 

912 if not line.strip().startswith("#") 

913 ) 

914 try: 

915 doc = yaml.safe_load(raw_clean) 

916 except yaml.YAMLError as exc: 

917 log.warning("YAML parse error in %s: %s", path, exc) 

918 continue 

919 if isinstance(doc, dict) and doc.get("apiVersion") == "agent-platform/v1": 

920 results.append(doc) 

921 return results 

922 

923 

924class ManifestLoader: 

925 """Skannaa hakemistot ja lataa kaikki YAML-manifestit.""" 

926 

927 def __init__(self, dirs: list[Path] | None = None) -> None: 

928 self._dirs = dirs or MANIFEST_DIRS 

929 

930 def load_all(self) -> list[dict]: 

931 manifests: list[dict] = [] 

932 for d in self._dirs: 

933 if not d.exists(): 

934 log.debug("Manifest dir not found: %s", d) 

935 continue 

936 for path in sorted(d.glob("**/*.yaml")): 

937 docs = _parse_manifest_file(path) 

938 manifests.extend(docs) 

939 if docs: 

940 log.debug("Loaded %d manifest(s) from %s", len(docs), path) 

941 return manifests 

942 

943 

944# --------------------------------------------------------------------------- 

945# ManifestRegistry 

946# --------------------------------------------------------------------------- 

947 

948ResourceKey = tuple[str, str] # (kind, name) 

949 

950 

951class ManifestRegistry: 

952 """Indeksoi ladatut manifestit kind+name avaimella.""" 

953 

954 def __init__(self, manifests: list[dict]) -> None: 

955 self._store: dict[ResourceKey, dict] = {} 

956 for doc in manifests: 

957 kind = doc.get("kind", "") 

958 name = (doc.get("metadata") or {}).get("name", "") 

959 if kind and name: 

960 key: ResourceKey = (kind, name) 

961 if key in self._store: 

962 log.warning("Duplicate manifest %s/%s — last one wins", kind, name) 

963 self._store[key] = doc 

964 

965 def get(self, kind: str, name: str) -> dict | None: 

966 return self._store.get((kind, name)) 

967 

968 def all_of_kind(self, kind: str) -> list[dict]: 

969 return [v for (k, _), v in self._store.items() if k == kind] 

970 

971 

972# --------------------------------------------------------------------------- 

973# AgentResolver 

974# --------------------------------------------------------------------------- 

975 

976def _deep_merge(base: dict, override: dict) -> dict: 

977 """Syvä merge: override voittaa konflikteissa.""" 

978 result = dict(base) 

979 for k, v in override.items(): 

980 if isinstance(v, dict) and isinstance(result.get(k), dict): 

981 result[k] = _deep_merge(result[k], v) 

982 else: 

983 result[k] = v 

984 return result 

985 

986 

987def _tool_has_description(tool: Any) -> bool: 

988 """Report whether a resolved tool exposes a non-empty model description.""" 

989 description = (tool.definition().get("function") or {}).get("description") 

990 return isinstance(description, str) and bool(description) 

991 

992 

993class AgentResolver: 

994 """ 

995 Palauttaa fully-resolved agent spec:n instanssille. 

996 Jos instanssilla on based_on, mergee Agent-base + AgentInstance-overridet. 

997 """ 

998 

999 def __init__(self, registry: ManifestRegistry) -> None: 

1000 self._registry = registry 

1001 self._cache: dict[str, dict] = {} 

1002 

1003 def resolve(self, instance_name: str) -> dict: 

1004 if instance_name in self._cache: 

1005 return self._cache[instance_name] 

1006 

1007 instance = self._registry.get("AgentInstance", instance_name) 

1008 if instance is None: 

1009 agent = self._registry.get("Agent", instance_name) 

1010 if agent is None: 

1011 raise RunHardFailure( 

1012 "VALIDATION_ERROR", 

1013 f"No Agent or AgentInstance named '{instance_name}'", 

1014 {"agent": instance_name}, 

1015 ) 

1016 resolved = agent 

1017 else: 

1018 based_on = (instance.get("spec") or {}).get("based_on") 

1019 if based_on: 

1020 base_agent = self._registry.get("Agent", based_on) 

1021 if base_agent is None: 

1022 raise RunHardFailure( 

1023 "VALIDATION_ERROR", 

1024 f"AgentInstance '{instance_name}' references unknown " 

1025 f"Agent '{based_on}'", 

1026 {"agent": instance_name, "based_on": based_on}, 

1027 ) 

1028 base_spec = base_agent.get("spec") or {} 

1029 inst_spec = {k: v for k, v in (instance.get("spec") or {}).items() 

1030 if k != "based_on"} 

1031 merged_spec = _deep_merge(base_spec, inst_spec) 

1032 else: 

1033 merged_spec = {k: v for k, v in (instance.get("spec") or {}).items() 

1034 if k != "based_on"} 

1035 

1036 resolved = { 

1037 "apiVersion": "agent-platform/v1", 

1038 "kind": "Agent", 

1039 "metadata": {"name": instance_name}, 

1040 "spec": merged_spec, 

1041 } 

1042 

1043 self._cache[instance_name] = resolved 

1044 return resolved 

1045 

1046 

1047# --------------------------------------------------------------------------- 

1048# State type 

1049# --------------------------------------------------------------------------- 

1050 

1051PipelineState = dict[str, Any] 

1052 

1053# Engine-reserved channel key: the existing flat Core-managed JSON state lives 

1054# under this key when a pipeline declares spec.channels. 

1055_CORE = CORE_KEY 

1056 

1057# Core-owned per-step map-reduce accumulator. A concurrent top-level `parallel` 

1058# step fans out through native `Send`; each worker writes `{index: item_result}` 

1059# under its step id and the join node consumes it. In the channels path the 

1060# accumulator is a top-level state channel; in the no-channels path it is a 

1061# reserved flat key folded by `_merge_state`. 

1062_MAP_REDUCE_ACCUMULATOR_KEY = "__map_reduce_accum__" 

1063 

1064# Reserved key carrying a worker's source item index through a `Send` payload. 

1065_MAP_REDUCE_ITEM_INDEX = "__map_reduce_item_index__" 

1066 

1067# Flat-state keys the engine owns; channel names may not shadow them. 

1068_CORE_STATE_KEYS = frozenset({ 

1069 "errors", "stats", "run_status", "project_root", "run_id", 

1070 "_stats", "step_context_metrics", "step_tool_meta", 

1071 _MAP_REDUCE_ACCUMULATOR_KEY, 

1072}) 

1073 

1074# Engine-local iteration keys the loop machinery injects into the read state. 

1075# They are engine plumbing, not user state, so a colliding channel key must 

1076# never shadow them. 

1077_ENGINE_LOCAL_ITERATION_KEYS = ("item", "_item", "_foreach_item", "loop") 

1078 

1079 

1080def _iteration_discriminator(state: dict) -> str | None: 

1081 """The engine-local iteration token for *state*, or None when not iterating. 

1082 

1083 Concurrent foreach/parallel items share a step id but differ in their 

1084 iteration key, so the synthesized call id uses this to stay distinct on the 

1085 in-cluster sidecar params file. The item is digested so the id stays a 

1086 bounded, filename-safe length. 

1087 """ 

1088 for key in _ENGINE_LOCAL_ITERATION_KEYS: 

1089 if key in state: 

1090 digest = hashlib.sha1(repr(state[key]).encode("utf-8")).hexdigest()[:12] 

1091 return f"{key}-{digest}" 

1092 return None 

1093 

1094 

1095 

1096def _merge_state(left: dict, right: dict) -> dict: 

1097 if not left: 

1098 return dict(right) 

1099 side_channel_keys = ("step_context_metrics", "step_tool_meta") 

1100 merged = dict(left) 

1101 for key, value in right.items(): 

1102 if key == _MAP_REDUCE_ACCUMULATOR_KEY: 

1103 folded = accumulate_map_reduce(merged.get(key), value) 

1104 if folded: 

1105 merged[key] = folded 

1106 else: 

1107 merged.pop(key, None) 

1108 continue 

1109 existing = merged.get(key) 

1110 if ( 

1111 key in side_channel_keys 

1112 and isinstance(existing, dict) 

1113 and isinstance(value, dict) 

1114 ): 

1115 merged[key] = _merge_side_channel(existing, value) 

1116 else: 

1117 merged[key] = value 

1118 return merged 

1119 

1120 

1121def _build_state_schema() -> Any: 

1122 """Build the channel-path TypedDict: the Core namespace plus the accumulator. 

1123 

1124 Channel context is Core-owned, so there are no per-channel native LangGraph 

1125 fields; the flat committed snapshot (core keys + channel keys) travels 

1126 node-to-node under ``__core__``. The map-reduce accumulator is a separate 

1127 top-level channel so native ``Send`` workers reduce into it; LangGraph stays 

1128 the graph executor. 

1129 """ 

1130 from typing import TypedDict 

1131 

1132 return TypedDict( 

1133 "PipelineState", 

1134 { 

1135 _CORE: Annotated[dict[str, Any], _merge_state], 

1136 _MAP_REDUCE_ACCUMULATOR_KEY: Annotated[ 

1137 dict[str, Any], accumulate_map_reduce 

1138 ], 

1139 }, 

1140 ) 

1141 

1142 

1143# Sentinel for optional kwargs distinguishing "not provided" from None 

1144_UNSET: Any = object() 

1145 

1146# --------------------------------------------------------------------------- 

1147# BuiltinExecutor 

1148# --------------------------------------------------------------------------- 

1149 

1150class BuiltinExecutor: 

1151 """ 

1152 Ajaa builtin-komponentit. 

1153 Uusia komponentteja voi rekisteröidä register()-metodilla. 

1154 Nimet normalisoidaan: viiva → alaviiva (esim. 'list-dir-entries' == 'list_dir_entries'). 

1155 """ 

1156 

1157 # Lazy modules are imported on first use (they need httpx / heavier deps). 

1158 # Profile-specific builtins live here: the PR-profile github-pr-comment 

1159 # and the TICKET-profile ticket-publish (per-run local feature branch; push/PR disabled). 

1160 _LAZY_MODULES: tuple[str, ...] = ("_builtins_pr", "_builtins_ticket") 

1161 

1162 def __init__(self) -> None: 

1163 self._handlers: dict[str, Any] = { 

1164 "passthrough": self._passthrough, 

1165 # Engine-internal call-expansion scaffolding (ADR-0037 D4): these are 

1166 # synthesized by the build-time call composer, never authored in a 

1167 # manifest. A call step becomes a compiled subgraph; these two op 

1168 # steps run inside it, binding params and emitting the call result. 

1169 "__call_bind": self._call_bind, 

1170 "__call_emit": self._call_emit, 

1171 } 

1172 # Pipeline's LLM invoker (spec, step, state) → state, bound by 

1173 # GraphBuilder. Profile builtins that make LLM decisions 

1174 # (ticket-dor / ticket-review) read it via their register_all 

1175 # wrapper — the executor must have it set before the builtin 

1176 # executes (binding happens at build_workflow time). 

1177 self.invoke_agent_fn: Any | None = None 

1178 self._lazy_loaded = False 

1179 

1180 def register(self, name: str, fn: Any) -> None: 

1181 self._handlers[name] = fn 

1182 # Also register with normalised name (hyphen → underscore) 

1183 normalised = name.replace("-", "_") 

1184 if normalised != name: 

1185 self._handlers[normalised] = fn 

1186 

1187 def _load_lazy_modules(self) -> None: 

1188 if self._lazy_loaded: 

1189 return 

1190 self._lazy_loaded = True 

1191 import importlib 

1192 for module_name in self._LAZY_MODULES: 

1193 try: 

1194 mod = importlib.import_module(f".{module_name}", package="langgraph") 

1195 except ImportError: 

1196 continue 

1197 mod.register_all(self) 

1198 

1199 def has(self, name: str) -> bool: 

1200 """True when *name* resolves in the builtin register (lazy modules included).""" 

1201 self._load_lazy_modules() 

1202 return name in self._handlers or name.replace("-", "_") in self._handlers 

1203 

1204 def get(self, name: str) -> Any: 

1205 fn = self._handlers.get(name) or self._handlers.get(name.replace("-", "_")) 

1206 if fn is not None: 

1207 return fn 

1208 self._load_lazy_modules() 

1209 fn = self._handlers.get(name) or self._handlers.get(name.replace("-", "_")) 

1210 if fn is None: 

1211 raise RunHardFailure( 

1212 "VALIDATION_ERROR", 

1213 f"Unknown builtin component '{name}'", 

1214 {"component": name}, 

1215 ) 

1216 return fn 

1217 

1218 def execute(self, component: str, step: dict, state: PipelineState) -> PipelineState: 

1219 fn = self.get(component) 

1220 try: 

1221 return fn(step, state) 

1222 except RunHardFailure as exc: 

1223 log.error("[hard-failure] %s: %s", exc.code, exc.message) 

1224 raise 

1225 

1226 # --- default builtins --- 

1227 

1228 @staticmethod 

1229 def _passthrough(step: dict, state: PipelineState) -> PipelineState: 

1230 return state 

1231 

1232 @staticmethod 

1233 def _call_bind(step: dict, state: PipelineState) -> PipelineState: 

1234 """Call-scope entry (synthesized): bind the function call's params. 

1235 

1236 The caller's function params are resolved against the current state and 

1237 stored at ``state[<call_id>] = {"params": {...}}`` so the (rewritten) 

1238 function-body templates ``{{ <call_id>.params.<name> }}`` resolve. The 

1239 call's reserved ``__call_id`` key is stripped from the bound params. 

1240 """ 

1241 call_id = (step.get("params") or {}).get("__call_id") 

1242 if not call_id: 

1243 raise ValueError("__call_bind missing __call_id (not a synthesized step)") 

1244 bound = { 

1245 k: v for k, v in (step.get("params") or {}).items() if k != "__call_id" 

1246 } 

1247 return {**state, call_id: {"params": bound}} 

1248 

1249 @staticmethod 

1250 def _call_emit(step: dict, state: PipelineState) -> PipelineState: 

1251 """Call-scope exit (synthesized): surface the function's outputs. 

1252 

1253 Per the call step-result contract (00-process-contract r.70) the caller 

1254 sees only ``state[<call_id>] = {outputs: {<name>: <source value>}}`` — 

1255 the function body step results are invisible to the caller. Each output 

1256 name resolves to the manifest interface ``outputs[].source`` dot-path. 

1257 

1258 Optionality (ADR-0031): an output flagged ``optional: true`` is skipped 

1259 when its source-path is unresolved; an output that is NOT optional must 

1260 resolve or a RunHardFailure is raised (no silent None — ADR-0012). 

1261 """ 

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

1263 call_id = params.get("__call_id") 

1264 if not call_id: 

1265 raise ValueError("__call_emit missing __call_id (not a synthesized step)") 

1266 outputs_spec = params.get("__outputs") or [] 

1267 outputs: dict[str, Any] = {} 

1268 for spec in outputs_spec: 

1269 if not isinstance(spec, dict): 

1270 continue 

1271 name = spec.get("name") 

1272 source = spec.get("source") 

1273 if name is None: 

1274 continue 

1275 optional = bool(spec.get("optional", False)) 

1276 if source is None: 

1277 # No declared source-path: treat as unresolved against the 

1278 # optionality gate (ADR-0012 — no silent None for required). 

1279 if optional: 

1280 continue 

1281 raise RunHardFailure( 

1282 "UNRESOLVED_OUTPUT", 

1283 f"call '{call_id}' output '{name}' has no source " 

1284 "(required output — no silent None, ADR-0012)", 

1285 {"call_id": call_id, "name": name, "source": source}, 

1286 ) 

1287 try: 

1288 value = _dot_get_strict(state, source) 

1289 except ValueError: 

1290 # Unresolved source-path: required → hard error; optional → skip. 

1291 if optional: 

1292 continue 

1293 raise RunHardFailure( 

1294 "UNRESOLVED_OUTPUT", 

1295 f"call '{call_id}' output '{name}' unresolved: source " 

1296 f"'{source}' not found in state (required output — " 

1297 "no silent None, ADR-0012)", 

1298 {"call_id": call_id, "name": name, "source": source}, 

1299 ) from None 

1300 outputs[name] = value 

1301 return {**state, call_id: {"outputs": outputs}} 

1302 

1303 

1304_DEFAULT_BUILTINS = BuiltinExecutor() 

1305 

1306 

1307def _register_domain_builtins(executor: BuiltinExecutor) -> None: 

1308 """Rekisteröi v1-domain-builtinit (list-dir-entries, ticket-publisher, ...). 

1309 

1310 Erotettu omaan moduliin (`_builtins.py`) — runner.py pysyy generic-engine, 

1311 builtinit ovat generiisiä mekanismeja (ADR-0016/0017). 

1312 """ 

1313 try: 

1314 from langgraph import _builtins # type: ignore 

1315 except ImportError: 

1316 # Kun runner.py ajetaan ilman pakettirakennetta (esim. /app/langgraph) 

1317 import importlib.util 

1318 spec = importlib.util.spec_from_file_location( 

1319 "_builtins", Path(__file__).parent / "_builtins.py" 

1320 ) 

1321 if spec and spec.loader: 

1322 _builtins = importlib.util.module_from_spec(spec) 

1323 spec.loader.exec_module(_builtins) # type: ignore[union-attr] 

1324 else: 

1325 log.warning("Cannot load _builtins.py — domain builtins disabled") 

1326 return 

1327 _builtins.register_all(executor) 

1328 _register_function_builtins(executor) 

1329 

1330 

1331def _register_function_builtins(executor: BuiltinExecutor) -> None: 

1332 """Rekisteröi function-engine builtinit (question-block-form). 

1333 

1334 Erotettu omaan moduliin (`_builtins_functions.py`) — runner.py pysyy 

1335 generic-engine, function-builtinit pysyvät puhtaina ja erillisinä. 

1336 """ 

1337 try: 

1338 from langgraph import _builtins_functions # type: ignore 

1339 except ImportError: 

1340 import importlib.util 

1341 spec = importlib.util.spec_from_file_location( 

1342 "_builtins_functions", Path(__file__).parent / "_builtins_functions.py" 

1343 ) 

1344 if spec and spec.loader: 

1345 _builtins_functions = importlib.util.module_from_spec(spec) 

1346 spec.loader.exec_module(_builtins_functions) # type: ignore[union-attr] 

1347 else: 

1348 log.warning("Cannot load _builtins_functions.py — function builtins disabled") 

1349 return 

1350 _builtins_functions.register_all(executor) 

1351 

1352 

1353_register_domain_builtins(_DEFAULT_BUILTINS) 

1354 

1355# --------------------------------------------------------------------------- 

1356# StepRunner 

1357# --------------------------------------------------------------------------- 

1358 

1359class StepRunner: 

1360 """ 

1361 Ajaa yksittäisen step:n sen typin mukaan. 

1362 

1363 run_context: dict joka lisätään template-kontekstiin. Esim: 

1364 {"budget": {"max_parallel": 16, ...}, "config": {...}} 

1365 """ 

1366 

1367 def __init__( 

1368 self, 

1369 resolver: AgentResolver, 

1370 builtins: BuiltinExecutor | None = None, 

1371 invoke_agent_fn: Any | None = None, 

1372 run_context: dict | None = None, 

1373 *, 

1374 step_functions: StepFunctionExecutor | None = None, 

1375 tracer: Any | None = None, 

1376 tool_resolver: Any | None = None, 

1377 ) -> None: 

1378 self._resolver = resolver 

1379 self._builtins = builtins or _DEFAULT_BUILTINS 

1380 self._invoke_agent = self._wrap_hard_failure_gate( 

1381 invoke_agent_fn or self._dummy_invoke 

1382 ) 

1383 self._run_context: dict = run_context or {} 

1384 self._step_functions = step_functions 

1385 self._tracer = tracer 

1386 self._tool_resolver = tool_resolver 

1387 self._channel_names: frozenset[str] = frozenset() 

1388 

1389 def set_channel_routing(self, channel_names: frozenset[str]) -> None: 

1390 """Attach the build-time routing context nested body steps route with.""" 

1391 self._channel_names = channel_names 

1392 

1393 @staticmethod 

1394 def _should_skip_llm(step: dict, ctx: dict) -> bool: 

1395 """Truthiness of a step's `skip_llm_if` expression (ENG-12, Q4). 

1396 

1397 Lenient (non-strict) evaluation: a missing/undefined field resolves to 

1398 falsy, so the step behaves normally (LLM invoked). Only an explicit 

1399 truthy expression skips the LLM. 

1400 """ 

1401 raw = step.get("skip_llm_if") 

1402 if raw is None: 

1403 return False 

1404 resolved = _resolve_template(raw, ctx) 

1405 if isinstance(resolved, bool): 

1406 return resolved 

1407 if isinstance(resolved, str): 

1408 return resolved.lower() not in ('false', '0', '', 'none', 'null') 

1409 return bool(resolved) 

1410 

1411 @staticmethod 

1412 def _skip_llm_passthrough( 

1413 step: dict, 

1414 state: PipelineState, 

1415 ctx: dict, 

1416 read_state: PipelineState, 

1417 ) -> PipelineState: 

1418 """Deterministic passthrough of a step's resolved input (no LLM). 

1419 

1420 `state[step_id]` becomes the resolved `input` value (the parallel 

1421 fan-out envelope `{results: [...]}` when the input resolves to a list). 

1422 ``read_state`` is the working-set overlay the input resolves against; 

1423 the returned state is still built from the raw ``state``. 

1424 """ 

1425 step_id = step.get("id", "?") 

1426 input_spec = step.get("input") 

1427 resolved = _resolve_input(input_spec, read_state, ctx) if input_spec is not None else None 

1428 if isinstance(resolved, list): 

1429 log.debug("[step] %s skip_llm_if passthrough (fanout, no LLM)", step_id) 

1430 return {**state, step_id: {"results": resolved}} 

1431 log.debug("[step] %s skip_llm_if passthrough (no LLM)", step_id) 

1432 return {**state, step_id: resolved} 

1433 

1434 @staticmethod 

1435 def _declared_item_source(step: dict) -> tuple[str, Any] | None: 

1436 """A fan-out step's declared item source as ``(kind, spec)``, else None. 

1437 

1438 ``input`` takes precedence over ``over``. ``over`` addresses a flat 

1439 state key; ``input`` supports templates, bracket literals, and dot-paths 

1440 (so a runtime envelope set is ``input: "<step_id>.iteration"``). A step 

1441 declaring neither has no item source. 

1442 """ 

1443 if step.get("input") is not None: 

1444 return ("input", step.get("input")) 

1445 if step.get("over"): 

1446 return ("over", step.get("over")) 

1447 return None 

1448 

1449 @staticmethod 

1450 def _resolve_item_source( 

1451 source: tuple[str, Any], state: PipelineState, ctx: dict 

1452 ) -> Any: 

1453 """Resolve a declared item source to its raw value (no validation).""" 

1454 kind, spec = source 

1455 if kind == "input": 

1456 return _resolve_input(spec, state, ctx) 

1457 return state.get(spec) 

1458 

1459 @staticmethod 

1460 def _unresolvable_item_source_failure( 

1461 step_id: str, source: tuple[str, Any] 

1462 ) -> "RunHardFailure": 

1463 kind, spec = source 

1464 return RunHardFailure( 

1465 "VALIDATION_ERROR", 

1466 f"step '{step_id}' item source '{kind}: {spec}' is unresolvable " 

1467 "(no silent fallback — ADR-0012)", 

1468 {"step_id": step_id, "source_kind": kind, "source": spec}, 

1469 ) 

1470 

1471 @staticmethod 

1472 def _empty_item_source_failure( 

1473 step_id: str, source: tuple[str, Any] 

1474 ) -> "RunHardFailure": 

1475 kind, spec = source 

1476 return RunHardFailure( 

1477 "VALIDATION_ERROR", 

1478 f"step '{step_id}' item source '{kind}: {spec}' resolved to an " 

1479 "empty list (no silent fallback — ADR-0012)", 

1480 {"step_id": step_id, "source_kind": kind, "source": spec}, 

1481 ) 

1482 

1483 @staticmethod 

1484 def _parallel_fanout_items( 

1485 step: dict, state: PipelineState, ctx: dict 

1486 ) -> list | None: 

1487 """The resolved fan-out item list for a parallel step, else None. 

1488 

1489 A parallel step whose resolved item source is a list invokes the agent 

1490 once per item and stores a ``{"results": [...]}`` envelope instead of a 

1491 single agent output. A parallel step declaring no item source keeps the 

1492 single-agent fallback; a declared source that is unresolvable or empty 

1493 is a ``VALIDATION_ERROR`` (ADR-0012). 

1494 """ 

1495 if step.get("type") != "parallel": 

1496 return None 

1497 source = StepRunner._declared_item_source(step) 

1498 if source is None: 

1499 return None 

1500 items = StepRunner._resolve_item_source(source, state, ctx) 

1501 if items is None: 

1502 raise StepRunner._unresolvable_item_source_failure( 

1503 step.get("id", "?"), source 

1504 ) 

1505 if not isinstance(items, list): 

1506 return None 

1507 if not items: 

1508 raise StepRunner._empty_item_source_failure(step.get("id", "?"), source) 

1509 return items 

1510 

1511 @staticmethod 

1512 def _parallel_fanout_item_list( 

1513 step: dict, state: PipelineState, ctx: dict 

1514 ) -> list | None: 

1515 """The fan-out item list with the ``max_items`` cap applied.""" 

1516 items = StepRunner._parallel_fanout_items(step, state, ctx) 

1517 if items is None: 

1518 return None 

1519 max_items_raw = step.get("max_items") 

1520 if max_items_raw is not None: 

1521 items = items[: _resolve_int(max_items_raw, ctx, len(items))] 

1522 return items 

1523 

1524 @staticmethod 

1525 def _foreach_item_list( 

1526 step: dict, state: PipelineState, ctx: dict 

1527 ) -> list: 

1528 """The foreach item list: resolved, wrapped, empty-checked, capped. 

1529 

1530 A missing source, an unresolvable source, and an empty list are hard 

1531 ``VALIDATION_ERROR`` failures (ADR-0012). ``max_iterations`` caps the 

1532 list. 

1533 """ 

1534 step_id = step.get("id", "?") 

1535 source = StepRunner._declared_item_source(step) 

1536 if source is None: 

1537 raise RunHardFailure( 

1538 "VALIDATION_ERROR", 

1539 f"foreach step '{step_id}' declares no item source " 

1540 "('input' or 'over') — no silent [None] fallback (ADR-0012)", 

1541 {"step_id": step_id}, 

1542 ) 

1543 resolved_items = StepRunner._resolve_item_source(source, state, ctx) 

1544 if resolved_items is None: 

1545 raise StepRunner._unresolvable_item_source_failure(step_id, source) 

1546 items: list = ( 

1547 resolved_items 

1548 if isinstance(resolved_items, list) 

1549 else [resolved_items] 

1550 ) 

1551 if not items: 

1552 raise StepRunner._empty_item_source_failure(step_id, source) 

1553 max_iter_raw = step.get("max_iterations") 

1554 if max_iter_raw is not None: 

1555 items = items[: _resolve_int(max_iter_raw, ctx, len(items))] 

1556 return items 

1557 

1558 def ran_agent(self, step: dict, state: PipelineState) -> bool: 

1559 """Whether a step's run invokes its agent. 

1560 

1561 False for agent-less steps, a ``skip_llm_if`` passthrough, and a 

1562 parallel fan-out (per-item agent calls storing a result envelope). 

1563 Channel routing is restricted to actual agent outputs, so these paths 

1564 are excluded from inverse projection. 

1565 """ 

1566 if self.resolve_agent(step) is None: 

1567 return False 

1568 ctx = self._make_ctx(state) 

1569 if self._should_skip_llm(step, ctx): 

1570 return False 

1571 return self._parallel_fanout_items(step, state, ctx) is None 

1572 

1573 def _make_ctx(self, state: dict, **extras: Any) -> dict: 

1574 """Rakentaa template-kontekstin: state + run_context + extras.""" 

1575 ctx: dict = {} 

1576 ctx.update(state) 

1577 ctx.update(self._run_context) 

1578 ctx.update(extras) 

1579 return ctx 

1580 

1581 @staticmethod 

1582 def _read_state( 

1583 state: PipelineState, working_set: ChannelWorkingSet | None 

1584 ) -> PipelineState: 

1585 """Overlay the invocation working set's read view onto the local state. 

1586 

1587 The working set carries the committed snapshot plus this invocation's 

1588 pending writes; the local state contributes keys the snapshot does not 

1589 hold (item context, body step results). With no working set the raw 

1590 state is the read view, preserving legacy behaviour exactly. The 

1591 engine-local iteration keys are re-asserted after the overlay so a 

1592 declared channel with the same name cannot shadow them. 

1593 """ 

1594 if working_set is None: 

1595 return state 

1596 resolved = {**state, **working_set.read_view()} 

1597 for key in _ENGINE_LOCAL_ITERATION_KEYS: 

1598 if key in state: 

1599 resolved[key] = state[key] 

1600 return resolved 

1601 

1602 def _run_body_step( 

1603 self, 

1604 step: dict, 

1605 state: PipelineState, 

1606 working_set: ChannelWorkingSet | None, 

1607 ) -> PipelineState: 

1608 """Run one nested body step and stage its declared channel writes. 

1609 

1610 The two-argument call preserves the legacy recursion signature for the 

1611 no-channels path (and for callers that monkey-patch ``run_step``). 

1612 """ 

1613 if working_set is None: 

1614 return self.run_step(step, state) 

1615 result = self.run_step(step, state, working_set) 

1616 self._stage_body_channel_writes(step, state, result, working_set) 

1617 return result 

1618 

1619 def _stage_body_channel_writes( 

1620 self, 

1621 step: dict, 

1622 pre_state: PipelineState, 

1623 result_state: PipelineState, 

1624 working_set: ChannelWorkingSet, 

1625 ) -> None: 

1626 """Stage a body step's declared channel writes into the working set. 

1627 

1628 The router early-outs for agent-less steps (builtins, call bind/emit); 

1629 a step carrying an agent routes its declared writes. 

1630 """ 

1631 if not self._channel_names: 

1632 return 

1633 writes = _route_declared_channels( 

1634 step, 

1635 self._read_state(pre_state, working_set), 

1636 result_state, 

1637 self, 

1638 self._channel_names, 

1639 ) 

1640 for name, value in writes.items(): 

1641 working_set.stage(name, value) 

1642 

1643 def _resolve_step_runtime( 

1644 self, 

1645 step: dict, 

1646 state: dict, 

1647 ctx: dict, 

1648 *, 

1649 input_override: Any = _UNSET, 

1650 extra_params: dict | None = None, 

1651 ) -> dict: 

1652 """ 

1653 Palauta kopio step:stä jossa `input` ja `params` on resolvoitu 

1654 templaattien osalta state + run_context -kontekstia vasten. 

1655 

1656 OpenAIInvoker olettaa että step.input ja step.params on JO resolvoitu 

1657 (vrt. _llm.py:n __call__ docstring) — tämä helper hoitaa sen. 

1658 """ 

1659 resolved: dict = dict(step) 

1660 

1661 if input_override is not _UNSET: 

1662 resolved["input"] = input_override 

1663 else: 

1664 input_spec = step.get("input") 

1665 if input_spec is not None: 

1666 resolved["input"] = _resolve_input(input_spec, state, ctx) 

1667 

1668 params_spec = step.get("params") 

1669 if params_spec or extra_params: 

1670 base = _resolve_template(params_spec, ctx) if params_spec else {} 

1671 if not isinstance(base, dict): 

1672 base = {} 

1673 if extra_params: 

1674 base = {**base, **extra_params} 

1675 resolved["params"] = base 

1676 

1677 return resolved 

1678 

1679 def _attach_communication_context( 

1680 self, resolved_step: dict, read_state: PipelineState 

1681 ) -> dict: 

1682 """Attach a step's projected COMMUNICATION context when declared. 

1683 

1684 The projection is resolved from the invocation read overlay (the 

1685 committed snapshot plus this invocation's pending writes) and carries 

1686 only the step's declared input channels — never whole state. A step 

1687 without a read declaration is returned unchanged. 

1688 """ 

1689 communication = resolve_communication_context(resolved_step, read_state) 

1690 if not communication: 

1691 return resolved_step 

1692 return {**resolved_step, COMMUNICATION_CONTEXT_KEY: communication} 

1693 

1694 def _attach_resolved_tools( 

1695 self, resolved_step: dict, resolved_agent: dict 

1696 ) -> dict: 

1697 """Attach the agent's declared `tools` as resolved `Tool` instances. 

1698 

1699 Resolves the agent's declared tool names through `ToolResolver` into 

1700 ordered `Tool` objects under `RESOLVED_TOOLS_KEY`. An agent without 

1701 tools, or a runner without a resolver, is returned unchanged. 

1702 """ 

1703 tools = (resolved_agent.get("spec") or {}).get("tools") or [] 

1704 if not tools or self._tool_resolver is None: 

1705 return resolved_step 

1706 return { 

1707 **resolved_step, 

1708 RESOLVED_TOOLS_KEY: self._resolve_agent_tools( 

1709 resolved_agent, list(tools) 

1710 ), 

1711 } 

1712 

1713 def _resolve_agent_tools( 

1714 self, resolved_agent: dict, tools: list[str] 

1715 ) -> list: 

1716 """Resolve an agent's declared tools, enforcing the instruction guard. 

1717 

1718 The low-level resolver stays permissive; the policy lives here 

1719 (ADR-0016). A declared name the resolver cannot resolve, or a 

1720 resolved tool that exposes no non-empty description, is translated 

1721 into the engine's build-time `AGENT_INSTRUCTION_MISSING` hard failure. 

1722 A dual-source name keeps its own `BUILTIN_NAME_COLLISION` failure. 

1723 """ 

1724 return [self._resolve_agent_tool(resolved_agent, name) for name in tools] 

1725 

1726 def _resolve_agent_tool(self, resolved_agent: dict, name: str): 

1727 try: 

1728 tool = self._tool_resolver.resolve([name])[0] 

1729 except ToolCollisionError as exc: 

1730 raise RunHardFailure( 

1731 "BUILTIN_NAME_COLLISION", 

1732 f"tool {name!r} exists in both the platform registry and the " 

1733 "StepFunction register", 

1734 {"tool": name}, 

1735 ) from exc 

1736 except ToolRegistryError as exc: 

1737 raise self._instruction_missing_failure( 

1738 resolved_agent, name, str(exc) 

1739 ) from exc 

1740 if not _tool_has_description(tool): 

1741 raise self._instruction_missing_failure( 

1742 resolved_agent, name, "the resolved tool exposes no description" 

1743 ) 

1744 return tool 

1745 

1746 @staticmethod 

1747 def _instruction_missing_failure( 

1748 resolved_agent: dict, tool_name: str, detail: str 

1749 ) -> RunHardFailure: 

1750 agent_name = (resolved_agent.get("metadata") or {}).get("name") 

1751 return RunHardFailure( 

1752 AGENT_INSTRUCTION_MISSING, 

1753 f"Agent {agent_name!r} declares tool {tool_name!r} with no usage " 

1754 f"instruction: {detail}", 

1755 {"agent": agent_name, "tool": tool_name}, 

1756 ) 

1757 

1758 @staticmethod 

1759 def _dummy_invoke( 

1760 resolved_spec: dict, step: dict, state: PipelineState 

1761 ) -> PipelineState: 

1762 """Placeholder — ei kutsu oikeaa LLM:ää.""" 

1763 step_id = step.get("id", "?") 

1764 log.debug("[dummy] Step '%s' agent call skipped (no invoke_agent_fn set)", step_id) 

1765 return {**state, step_id: {"status": "skipped", "agent": resolved_spec["metadata"]["name"]}} 

1766 

1767 @staticmethod 

1768 def _merge_errors(base: list, additions: list) -> list: 

1769 """Return a new list containing all items from *base* plus any items from 

1770 *additions* that are not already present. Order is preserved and no 

1771 in-place mutation occurs — safe to call from any context.""" 

1772 if not additions: 

1773 return base 

1774 merged = list(base) 

1775 for err in additions: 

1776 if err not in merged: 

1777 merged.append(err) 

1778 return merged 

1779 

1780 @staticmethod 

1781 def _wrap_hard_failure_gate(invoke_fn: Any) -> Any: 

1782 """Kietoo invokerin: jokaiseen agent-step-tulokseen sovelletaan 

1783 kova-virhe-portti (RunHardFailure details.error_code-perusteella). 

1784 Suppein funnel joka näkee KAIKKI agent-tulokset: sequential, 

1785 parallel (single + fanout), foreach (body + agent-polku) ja while.""" 

1786 def invoke_with_gate( 

1787 resolved_spec: dict, step: dict, state: PipelineState 

1788 ) -> PipelineState: 

1789 result = invoke_fn(resolved_spec, step, state) 

1790 step_id = step.get("id", "?") 

1791 StepRunner._raise_on_hard_error(result.get(step_id), step_id) 

1792 return result 

1793 return invoke_with_gate 

1794 

1795 @staticmethod 

1796 def _is_hard_fail(details: dict) -> bool: 

1797 """Kova virhe vain kun: error_code on HARD_ERROR_CODESissa 

1798 (TRUNCATION_ERROR, TOOL_FAILURE, AGENT_ERROR) tai VALIDATION_ERROR 

1799 kantaa kontekstiylityksen signatuurin (assembled_tokens + 

1800 context_budget — _llm._context_overflow_result). 

1801 Generic-VALIDATION_ERROR ei kaada ajoa. status:error-envelope kaatuu 

1802 _raise_on_error_envelope-polulla ennen tätä tarkistusta.""" 

1803 code = details.get("error_code") 

1804 if code in RunHardFailure.HARD_ERROR_CODES: 

1805 return True 

1806 if code == "VALIDATION_ERROR": 

1807 return {"assembled_tokens", "context_budget"} <= details.keys() 

1808 return False 

1809 

1810 @staticmethod 

1811 def _raise_on_hard_error(step_result: Any, step_id: str) -> None: 

1812 """Nosta RunHardFailure jos step-tulos kantaa kovan virheen 

1813 (_is_hard_fail) tai on status:error-envelope; stepin details 

1814 kulkeutuu poikkeuksen mukana.""" 

1815 if not isinstance(step_result, dict): 

1816 return 

1817 if is_error_envelope(step_result): 

1818 StepRunner._raise_on_error_envelope(step_result, step_id) 

1819 return 

1820 details = step_result.get("details") 

1821 if not isinstance(details, dict): 

1822 return 

1823 if not StepRunner._is_hard_fail(details): 

1824 return 

1825 code = details.get("error_code") 

1826 err = step_result.get("error", "") 

1827 message = err if isinstance(err, str) and err else f"{code}: step '{step_id}' hard failure" 

1828 log.error("[hard-failure] %s: %s", code, message) 

1829 raise RunHardFailure(code, message, details) 

1830 

1831 @staticmethod 

1832 def _raise_on_error_envelope(step_result: dict, step_id: str) -> None: 

1833 """Nosta RunHardFailure status:error-envelopesta; envelopen error 

1834 (dict) kulkeutuu details-attribuuttina, muuten minimi details.""" 

1835 envelope_error = step_result.get("error") 

1836 if isinstance(envelope_error, dict): 

1837 details = envelope_error 

1838 message = envelope_error.get("message") 

1839 else: 

1840 details = {"error_code": ENVELOPE_ERROR_CODE, "step_id": step_id} 

1841 message = None 

1842 if not isinstance(message, str) or not message: 

1843 message = f"{ENVELOPE_ERROR_CODE}: step '{step_id}' envelope error" 

1844 raise RunHardFailure(ENVELOPE_ERROR_CODE, message, details) 

1845 

1846 @staticmethod 

1847 def _propagate_step_errors(result: PipelineState, step_id: str) -> PipelineState: 

1848 """ 

1849 Jos _invoke_agent palautti virheen (mikä tahansa ei-tyhjä 'error'-kenttä), 

1850 lisätään se state['errors']-listaan jotta run_finalizer raportoi ajon 

1851 epäonnistuneeksi eikä hiljennä virhettä. 

1852 

1853 Kaikki virhetyypit propagoidaan: 

1854 - budget:max_llm_calls / budget:run_timeout → budjettiloki 

1855 - parse_error:*, timeout:*, ja muut → error-loki 

1856 """ 

1857 step_result = result.get(step_id) 

1858 if not isinstance(step_result, dict): 

1859 return result 

1860 err = step_result.get("error", "") 

1861 if not isinstance(err, str) or not err: 

1862 return result 

1863 errors = list(result.get("errors") or []) 

1864 message = f"{step_id}: {err}" 

1865 if message not in errors: 

1866 errors.append(message) 

1867 if err.startswith("budget:"): 

1868 log.warning("[budget] %s", message) 

1869 else: 

1870 log.error("[step-error] %s", message) 

1871 return {**result, "errors": errors} 

1872 

1873 def resolve_agent(self, step: dict) -> dict | None: 

1874 """Resolve the agent/instance a step runs, or None for agent-less steps.""" 

1875 agent_name = step.get("agent") 

1876 if not agent_name: 

1877 return None 

1878 return self._resolver.resolve(agent_name) 

1879 

1880 def run_step( 

1881 self, 

1882 step: dict, 

1883 state: PipelineState, 

1884 working_set: ChannelWorkingSet | None = None, 

1885 ) -> PipelineState: 

1886 step_id = step.get("id", "?") 

1887 read_state = self._read_state(state, working_set) 

1888 ctx = self._make_ctx(read_state) 

1889 step_type = step.get("type", "sequential") 

1890 

1891 # Evaluate condition — if false, skip step. A `while` never reaches 

1892 # this point as a raw step: build-time composition rewrites it into a 

1893 # native cycle placeholder whose head owns the entry-condition skip. 

1894 cond = step.get("condition") 

1895 if cond is not None and not _eval_condition(cond, ctx): 

1896 log.debug("[step] %s skipped (condition false)", step_id) 

1897 return state 

1898 

1899 if step_type == "builtin": 

1900 component = step.get("component", "passthrough") 

1901 log.debug("[step] %s builtin:%s", step_id, component) 

1902 # Builtins lukevat raw step.params suoraan; resolvoidaan kuitenkin 

1903 # template-arvot (esim. "{{ budget.max_parallel }}") 

1904 resolved_step = self._resolve_step_runtime(step, read_state, ctx) 

1905 # ADR-0037 D2: resolve the component against the platform builtin 

1906 # register FIRST, then the kind: StepFunction registry. A name in 

1907 # both is a hard error (fail-fast). 

1908 is_builtin = self._builtins.has(component) 

1909 is_step_function = ( 

1910 self._step_functions is not None 

1911 and self._step_functions.has(component) 

1912 ) 

1913 if is_builtin and is_step_function: 

1914 raise RunHardFailure( 

1915 "BUILTIN_NAME_COLLISION", 

1916 f"component '{component}' is present in both the builtin " 

1917 "register and the StepFunction registry (ADR-0037 — " 

1918 "fail-fast)", 

1919 {"component": component}, 

1920 ) 

1921 if is_step_function: 

1922 return self._step_functions.execute(component, resolved_step, state) 

1923 builtin_state = self._builtins.execute(component, resolved_step, state) 

1924 self._raise_on_hard_error(builtin_state.get(step_id), step_id) 

1925 return builtin_state 

1926 

1927 # skip_llm_if (ENG-12, Q4): when the step's skip_llm_if evaluates truthy, 

1928 # do NOT invoke the LLM (no inference budget consumed) and set the step 

1929 # result to a deterministic passthrough of the step's resolved input. 

1930 if ( 

1931 step_type in ("sequential", "parallel") 

1932 and self._should_skip_llm(step, ctx) 

1933 ): 

1934 return self._skip_llm_passthrough(step, state, ctx, read_state) 

1935 

1936 if step_type == "parallel": 

1937 # Fan-out: if input resolves to a list, call agent once per item 

1938 items = self._parallel_fanout_items(step, read_state, ctx) 

1939 if items is not None: 

1940 return self._run_parallel_fanout( 

1941 step, state, items, ctx, working_set 

1942 ) 

1943 # Fallback: single agent call 

1944 agent_name = step.get("agent") 

1945 if not agent_name: 

1946 log.warning("[step] %s has no agent — skipping", step_id) 

1947 return state 

1948 resolved = self._resolver.resolve(agent_name) 

1949 log.debug("[step] %s parallel (single) → agent:%s", step_id, agent_name) 

1950 resolved_step = self._resolve_step_runtime(step, read_state, ctx) 

1951 resolved_step = self._attach_communication_context( 

1952 resolved_step, read_state 

1953 ) 

1954 resolved_step = self._attach_resolved_tools(resolved_step, resolved) 

1955 result = self._invoke_agent(resolved, resolved_step, state) 

1956 return self._propagate_step_errors(result, step_id) 

1957 

1958 if step_type == "sequential": 

1959 agent_name = step.get("agent") 

1960 if not agent_name: 

1961 log.warning("[step] %s has no agent — skipping", step_id) 

1962 return state 

1963 resolved = self._resolver.resolve(agent_name) 

1964 log.debug("[step] %s sequential → agent:%s", step_id, agent_name) 

1965 resolved_step = self._resolve_step_runtime(step, read_state, ctx) 

1966 resolved_step = self._attach_communication_context( 

1967 resolved_step, read_state 

1968 ) 

1969 resolved_step = self._attach_resolved_tools(resolved_step, resolved) 

1970 result = self._invoke_agent(resolved, resolved_step, state) 

1971 return self._propagate_step_errors(result, step_id) 

1972 

1973 if step_type == "foreach": 

1974 return self._run_foreach(step, state, working_set) 

1975 

1976 if step_type == "call": 

1977 # Build-time composition (ADR-0037 D4) rewrites every call step into 

1978 # a native subgraph node, so a call reaching the runtime means the 

1979 # builder skipped a composition path it was responsible for. 

1980 raise RunHardFailure( 

1981 "VALIDATION_ERROR", 

1982 f"call step '{step_id}' reached runtime without build-time " 

1983 "expansion (ADR-0037 D4) — not yet supported", 

1984 {"step_id": step_id}, 

1985 ) 

1986 

1987 if step_type == _SUBGRAPH_STEP_TYPE: 

1988 # Build-time composition (ADR-0037 D4) embeds a compiled function 

1989 # subgraph as a native node. At the top level ``_assemble_graph`` 

1990 # wires it through ``_make_subgraph_node``; when the call sits in an 

1991 # imperative foreach/while body the placeholder reaches ``run_step`` 

1992 # instead, so invoke the embedded graph here. The top-of-method 

1993 # condition check already handled a false condition (returned the 

1994 # input state unchanged), matching the node wrapper's skip. 

1995 compiled = step.get(_SUBGRAPH_GRAPH_KEY) 

1996 if compiled is None: 

1997 raise RunHardFailure( 

1998 "VALIDATION_ERROR", 

1999 f"subgraph step '{step_id}' has no compiled graph", 

2000 {"step_id": step_id}, 

2001 ) 

2002 config = _while_invoke_config(step) 

2003 if working_set is not None: 

2004 # Channel path: the compiled subgraph shares the parent's 

2005 # Core-namespaced schema, while a loop body carries the flat 

2006 # committed snapshot. Re-wrap on entry, flatten on exit. No 

2007 # channel reducer is re-applied here — the subgraph's own 

2008 # commits already produced the authoritative values. 

2009 sub_result = compiled.invoke({_CORE: read_state}, config) 

2010 committed = _flatten_channel_state(sub_result) 

2011 return {**state, **committed} 

2012 return compiled.invoke(state, config) 

2013 

2014 if step_type == "spawn": 

2015 return self._run_spawn(step, state, working_set) 

2016 

2017 raise RunHardFailure( 

2018 "VALIDATION_ERROR", 

2019 f"Unknown step type '{step_type}' in step '{step_id}'", 

2020 {"step_id": step_id, "step_type": step_type}, 

2021 ) 

2022 

2023 def _run_parallel_fanout( 

2024 self, 

2025 step: dict, 

2026 state: PipelineState, 

2027 items: list, 

2028 ctx: dict, 

2029 working_set: ChannelWorkingSet | None = None, 

2030 ) -> PipelineState: 

2031 """ 

2032 Fan-out: call agent once per item, concurrently. 

2033 Results stored as state[step_id] = {"results": [item_result, ...]}. 

2034 Each item is available as state["item"] inside the invoke call. 

2035 """ 

2036 step_id = step.get("id", "?") 

2037 agent_name = step.get("agent") 

2038 if not agent_name: 

2039 log.warning("[step] %s parallel fanout: no agent", step_id) 

2040 return {**state, step_id: {"results": []}} 

2041 

2042 resolved = self._resolver.resolve(agent_name) 

2043 

2044 # Apply max_items cap 

2045 max_items_raw = step.get("max_items") 

2046 if max_items_raw is not None: 

2047 max_items = _resolve_int(max_items_raw, ctx, len(items)) 

2048 items = items[:max_items] 

2049 

2050 # Concurrency limit 

2051 max_conc_raw = step.get("max_concurrent") 

2052 if max_conc_raw is not None: 

2053 max_conc = _resolve_int(max_conc_raw, ctx, len(items)) 

2054 else: 

2055 max_conc = len(items) or 1 

2056 max_conc = max(1, min(max_conc, len(items) or 1)) 

2057 

2058 log.debug( 

2059 "[step] %s parallel fanout: %d items, %d concurrent", 

2060 step_id, len(items), max_conc, 

2061 ) 

2062 

2063 read_state = self._read_state(state, working_set) 

2064 

2065 def call_one(item: Any) -> tuple[Any, dict | None, dict | None, list]: 

2066 item_state = {**read_state, "_item": item, "item": item} 

2067 item_ctx = self._make_ctx(item_state) 

2068 return self._invoke_parallel_item( 

2069 step, resolved, item, item_state, item_ctx 

2070 ) 

2071 

2072 # Pre-allocate results to preserve input order regardless of completion 

2073 # order (ENG-07), matching the concurrent foreach body-polku. 

2074 results: list = [None] * len(items) 

2075 item_metrics: dict = {} 

2076 item_tool_meta: dict = {} 

2077 with ThreadPoolExecutor(max_workers=max_conc) as pool: 

2078 futures = {pool.submit(call_one, item): i for i, item in enumerate(items)} 

2079 for future in as_completed(futures): 

2080 idx = futures[future] 

2081 try: 

2082 item_result, item_metrics_map, item_tool_meta_map, *_ = ( 

2083 future.result() 

2084 ) 

2085 results[idx] = item_result 

2086 if isinstance(item_metrics_map, dict): 

2087 item_metrics.update(item_metrics_map) 

2088 if isinstance(item_tool_meta_map, dict): 

2089 item_tool_meta.update(item_tool_meta_map) 

2090 except SkillLoadError: 

2091 raise 

2092 except RunHardFailure: 

2093 pool.shutdown(wait=False, cancel_futures=True) 

2094 raise 

2095 except Exception as exc: # noqa: BLE001 

2096 log.error("[step] %s item failed: %s", step_id, exc) 

2097 results[idx] = {"error": str(exc)} 

2098 

2099 self._stage_fanout_channel_writes(step, results, working_set) 

2100 

2101 final: PipelineState = {**state, step_id: {"results": results}} 

2102 if item_metrics: 

2103 final = { 

2104 **final, 

2105 "step_context_metrics": _merge_side_channel( 

2106 state.get("step_context_metrics"), item_metrics 

2107 ), 

2108 } 

2109 if item_tool_meta: 

2110 final = { 

2111 **final, 

2112 "step_tool_meta": _merge_side_channel( 

2113 state.get("step_tool_meta"), item_tool_meta 

2114 ), 

2115 } 

2116 return final 

2117 

2118 def _invoke_parallel_item( 

2119 self, 

2120 step: dict, 

2121 resolved: dict, 

2122 item: Any, 

2123 item_state: PipelineState, 

2124 item_ctx: dict, 

2125 ) -> tuple[Any, dict | None, dict | None, list]: 

2126 """Run one parallel fan-out item's agent call. 

2127 

2128 Shared by the in-node thread-pool fan-out and the native ``Send`` 

2129 worker, so both paths resolve input, communication context and tools 

2130 identically. Returns ``(step_result, metrics, tool_meta, errors)``. 

2131 """ 

2132 step_id = step.get("id", "?") 

2133 resolved_step = self._resolve_step_runtime( 

2134 step, item_state, item_ctx, 

2135 input_override=item, 

2136 ) 

2137 resolved_step = self._attach_communication_context( 

2138 resolved_step, item_state 

2139 ) 

2140 resolved_step = self._attach_resolved_tools(resolved_step, resolved) 

2141 result_state = self._invoke_agent(resolved, resolved_step, item_state) 

2142 return ( 

2143 result_state.get(step_id), 

2144 result_state.get("step_context_metrics"), 

2145 result_state.get("step_tool_meta"), 

2146 list(result_state.get("errors") or []), 

2147 ) 

2148 

2149 def _stage_fanout_channel_writes( 

2150 self, 

2151 step: dict, 

2152 item_outputs: list, 

2153 working_set: ChannelWorkingSet | None, 

2154 ) -> None: 

2155 """Stage each fan-out item's declared channel writes in item order. 

2156 

2157 Runs on the main thread after the item pool completes, so the shared 

2158 working set is never mutated from a worker thread. The fan-out 

2159 envelope itself is never routed; only each item's own agent output is. 

2160 """ 

2161 if working_set is None or not self._channel_names: 

2162 return 

2163 for output in item_outputs: 

2164 writes = _declared_output_channel_writes( 

2165 step, output, self._channel_names 

2166 ) 

2167 for name, value in writes.items(): 

2168 working_set.stage(name, value) 

2169 

2170 def _run_foreach( 

2171 self, 

2172 step: dict, 

2173 state: PipelineState, 

2174 working_set: ChannelWorkingSet | None = None, 

2175 ) -> PipelineState: 

2176 """ 

2177 Foreach: iterate over input list, run body steps per item. 

2178 Input from step.input (dot-path or template) or step.over (state key). 

2179 

2180 When max_concurrent > 1, body iterations run concurrently via 

2181 ThreadPoolExecutor — each item gets its own worker thread. The 

2182 max_concurrent value sets the thread pool size (mirrors parallel 

2183 fan-out semantics). Results list preserves original item order. 

2184 

2185 max_output budget in concurrent mode: a stop flag prevents new workers 

2186 from starting after the budget is exhausted. Workers already executing 

2187 when the flag is set will complete naturally — at most max_concurrent 

2188 extra items may run past the limit. This is accepted behaviour; the 

2189 sequential early-exit guarantee cannot be reproduced without sacrificing 

2190 true concurrency. 

2191 

2192 The invocation working set is threaded into every body step so reads 

2193 resolve over the committed snapshot plus this invocation's pending 

2194 writes; the returned state is still built from the raw ``state``. 

2195 """ 

2196 step_id = step.get("id", "?") 

2197 read_state = self._read_state(state, working_set) 

2198 ctx = self._make_ctx(read_state) 

2199 

2200 items = self._foreach_item_list(step, read_state, ctx) 

2201 

2202 # max_output cap — stop once N iterations have produced a successful 

2203 # publish (iter_state["publish"]["path"]). 0/absent = unlimited. 

2204 max_output_raw = step.get("max_output") 

2205 max_output = _resolve_int(max_output_raw, ctx, 0) if max_output_raw is not None else 0 

2206 

2207 # max_concurrent — when > 1, enables parallel body execution 

2208 max_conc_raw = step.get("max_concurrent") 

2209 if max_conc_raw is not None: 

2210 max_conc = _resolve_int(max_conc_raw, ctx, len(items)) 

2211 max_conc = max(1, min(max_conc, len(items) or 1)) 

2212 else: 

2213 max_conc = 1 

2214 

2215 body = step.get("body") or [] 

2216 agent_name = step.get("agent") 

2217 

2218 if max_conc <= 1: 

2219 raise RunHardFailure( 

2220 "VALIDATION_ERROR", 

2221 f"foreach step '{step_id}' reached runtime without build-time " 

2222 "composition (a sequential foreach is compiled into a native " 

2223 "cycle) — internal invariant violated", 

2224 {"step_id": step_id}, 

2225 ) 

2226 return self._run_foreach_concurrent( 

2227 step_id, step, state, items, body, agent_name, max_output, max_conc, 

2228 working_set, 

2229 ) 

2230 

2231 @staticmethod 

2232 def _body_item_result(iter_state: PipelineState, declared_output: str | None) -> Any: 

2233 """body-polku per-iteration result (r.67): the declared body-output 

2234 step's result; else the iteration's `publish` result; else None.""" 

2235 if declared_output: 

2236 return iter_state.get(declared_output) 

2237 return iter_state.get("publish") 

2238 

2239 @staticmethod 

2240 def _body_item_passed(item: Any) -> bool: 

2241 """Derive a body-polku item verdict from its key: verdict PASS/FAIL or a 

2242 `passed` bool; an item lacking such a key is not counted as failed.""" 

2243 if isinstance(item, dict): 

2244 verdict = item.get("verdict") 

2245 if verdict == "FAIL": 

2246 return False 

2247 if verdict == "PASS": 

2248 return True 

2249 passed = item.get("passed") 

2250 if isinstance(passed, bool): 

2251 return passed 

2252 return True 

2253 

2254 @staticmethod 

2255 def _aggregate_body_results(item_results: list) -> dict: 

2256 """body-polku aggregate (r.67): {results, passed, failing, failed}.""" 

2257 failing = [r for r in item_results if not StepRunner._body_item_passed(r)] 

2258 return { 

2259 "results": item_results, 

2260 "passed": not failing, 

2261 "failing": failing, 

2262 "failed": bool(failing), 

2263 } 

2264 

2265 @staticmethod 

2266 def _merge_item_working_sets( 

2267 working_set: ChannelWorkingSet | None, 

2268 item_working_sets: list[ChannelWorkingSet | None], 

2269 ) -> None: 

2270 """Stage each concurrent item's writes onto the shared set in item order.""" 

2271 if working_set is None: 

2272 return 

2273 for item_working_set in item_working_sets: 

2274 if item_working_set is None: 

2275 continue 

2276 for name, value in item_working_set.pending_writes(): 

2277 working_set.stage(name, value) 

2278 

2279 def _run_foreach_concurrent( 

2280 self, 

2281 step_id: str, 

2282 step: dict, 

2283 state: PipelineState, 

2284 items: list, 

2285 body: list, 

2286 agent_name: str | None, 

2287 max_output: int, 

2288 max_conc: int, 

2289 working_set: ChannelWorkingSet | None = None, 

2290 ) -> PipelineState: 

2291 """Concurrent foreach — runs body iterations in parallel, throttled by max_conc.""" 

2292 log.info( 

2293 "[foreach] %s concurrent: %d items, max_concurrent=%d", 

2294 step_id, len(items), max_conc, 

2295 ) 

2296 

2297 # Pre-allocate results list to preserve item order across threads. 

2298 # _SKIPPED is a sentinel that marks slots skipped via the stop_flag; 

2299 # legitimate None results (body produced no output) are kept. 

2300 body_polku = bool(body) 

2301 declared_output = step.get("output") 

2302 _SKIPPED = object() 

2303 results: list = [_SKIPPED] * len(items) 

2304 item_working_sets: list[ChannelWorkingSet | None] = [None] * len(items) 

2305 lock = threading.Lock() 

2306 produced_outputs = [0] # mutable counter shared across threads 

2307 stop_flag = threading.Event() # set when max_output budget is exhausted 

2308 item_errors: list = [] # budget errors collected from worker threads 

2309 accumulated_metrics: dict = {} # per-item envelopes from worker threads 

2310 accumulated_tool_meta: dict = {} # per-item tool metadata from worker threads 

2311 

2312 def run_item(idx: int, item: Any) -> None: 

2313 nonlocal item_errors 

2314 # Honour stop flag — skip if budget already exhausted before we start 

2315 if max_output and stop_flag.is_set(): 

2316 return 

2317 

2318 item_working_set = ( 

2319 working_set.fork() if working_set is not None else None 

2320 ) 

2321 item_working_sets[idx] = item_working_set 

2322 iter_state = {**state, "_item": item, "item": item, "_foreach_item": item} 

2323 

2324 if body_polku: 

2325 for sub_step in body: 

2326 iter_state = self._run_body_step( 

2327 sub_step, iter_state, item_working_set 

2328 ) 

2329 # Collect budget errors from this iteration (thread-safe merge) 

2330 new_errs = iter_state.get("errors") or [] 

2331 if new_errs: 

2332 with lock: 

2333 item_errors = self._merge_errors(item_errors, new_errs) 

2334 item_metrics_map = iter_state.get("step_context_metrics") 

2335 if isinstance(item_metrics_map, dict): 

2336 with lock: 

2337 accumulated_metrics.update(item_metrics_map) 

2338 item_tool_meta_map = iter_state.get("step_tool_meta") 

2339 if isinstance(item_tool_meta_map, dict): 

2340 with lock: 

2341 accumulated_tool_meta.update(item_tool_meta_map) 

2342 publish_out = iter_state.get("publish") 

2343 results[idx] = self._body_item_result(iter_state, declared_output) 

2344 if isinstance(publish_out, dict) and publish_out.get("path"): 

2345 with lock: 

2346 produced_outputs[0] += 1 

2347 if max_output and produced_outputs[0] >= max_output: 

2348 stop_flag.set() 

2349 elif agent_name: 

2350 resolved = self._resolver.resolve(agent_name) 

2351 item_read_state = self._read_state(iter_state, item_working_set) 

2352 item_ctx = self._make_ctx(item_read_state) 

2353 resolved_step = self._resolve_step_runtime( 

2354 step, item_read_state, item_ctx 

2355 ) 

2356 resolved_step = self._attach_communication_context( 

2357 resolved_step, item_read_state 

2358 ) 

2359 resolved_step = self._attach_resolved_tools(resolved_step, resolved) 

2360 iter_state = self._invoke_agent(resolved, resolved_step, iter_state) 

2361 results[idx] = iter_state.get(step_id) 

2362 item_metrics_map = iter_state.get("step_context_metrics") 

2363 if isinstance(item_metrics_map, dict): 

2364 with lock: 

2365 accumulated_metrics.update(item_metrics_map) 

2366 item_tool_meta_map = iter_state.get("step_tool_meta") 

2367 if isinstance(item_tool_meta_map, dict): 

2368 with lock: 

2369 accumulated_tool_meta.update(item_tool_meta_map) 

2370 

2371 with ThreadPoolExecutor(max_workers=max_conc) as pool: 

2372 futures = {pool.submit(run_item, i, item): i for i, item in enumerate(items)} 

2373 for future in as_completed(futures): 

2374 try: 

2375 future.result() 

2376 except SkillLoadError: 

2377 raise 

2378 except RunHardFailure: 

2379 pool.shutdown(wait=False, cancel_futures=True) 

2380 raise 

2381 except Exception as exc: # noqa: BLE001 

2382 idx = futures[future] 

2383 log.error("[foreach] %s item[%d] failed: %s", step_id, idx, exc) 

2384 results[idx] = {"error": str(exc)} 

2385 

2386 self._merge_item_working_sets(working_set, item_working_sets) 

2387 

2388 # Strip only sentinel slots (items skipped via stop_flag); keep legitimate None results 

2389 ordered = [r for r in results if r is not _SKIPPED] 

2390 step_result: Any = self._aggregate_body_results(ordered) if body_polku else ordered 

2391 final: PipelineState = {**state, step_id: step_result} 

2392 if accumulated_metrics: 

2393 final = { 

2394 **final, 

2395 "step_context_metrics": _merge_side_channel( 

2396 state.get("step_context_metrics"), accumulated_metrics 

2397 ), 

2398 } 

2399 if accumulated_tool_meta: 

2400 final = { 

2401 **final, 

2402 "step_tool_meta": _merge_side_channel( 

2403 state.get("step_tool_meta"), accumulated_tool_meta 

2404 ), 

2405 } 

2406 if item_errors: 

2407 final = {**final, "errors": self._merge_errors(list(state.get("errors") or []), item_errors)} 

2408 return final 

2409 

2410 def _run_spawn( 

2411 self, 

2412 step: dict, 

2413 state: PipelineState, 

2414 working_set: ChannelWorkingSet | None = None, 

2415 ) -> PipelineState: 

2416 """Run a spawn step as a native dynamic ``Send`` fan-out subgraph. 

2417 

2418 The ``agent`` runs once as director at the tree root and may emit a 

2419 child plan at ``envelope.content.children``; a shared child-worker node 

2420 re-runs the ``child`` role (default = ``agent``) for each child and 

2421 re-``Send``s itself for deeper levels. Depth and fan are bounded by the 

2422 spawn_policy, and the whole tree draws from one shared SpawnBudget. 

2423 """ 

2424 step_id = step.get("id", "?") 

2425 director = step.get("agent") 

2426 if not director: 

2427 log.warning("[step] %s spawn has no agent — skipping", step_id) 

2428 return state 

2429 read_state = self._read_state(state, working_set) 

2430 ctx = self._make_ctx(read_state) 

2431 policy = step.get("spawn_policy") or {} 

2432 max_depth = _resolve_int( 

2433 policy.get("max_depth"), ctx, _SPAWN_DEFAULT_MAX_DEPTH 

2434 ) 

2435 max_spawn_per_node = _resolve_int( 

2436 policy.get("max_spawn_per_node"), ctx, _SPAWN_DEFAULT_MAX_SPAWN_PER_NODE 

2437 ) 

2438 max_calls_raw = policy.get("max_agent_calls") 

2439 budget = SpawnBudget( 

2440 _SPAWN_DEFAULT_MAX_AGENT_CALLS 

2441 if max_calls_raw is None 

2442 else _resolve_int(max_calls_raw, ctx, _SPAWN_DEFAULT_MAX_AGENT_CALLS) 

2443 ) 

2444 run_max_parallel = int( 

2445 (self._run_context.get("budget") or {}).get( 

2446 "max_parallel", DEFAULT_MAX_PARALLEL 

2447 ) 

2448 ) 

2449 max_concurrent_raw = policy.get("max_concurrent_children") 

2450 max_concurrent_children = ( 

2451 run_max_parallel 

2452 if max_concurrent_raw is None 

2453 else _resolve_int(max_concurrent_raw, ctx, run_max_parallel) 

2454 ) 

2455 child_role = step.get("child") or director 

2456 

2457 outcome = self._run_native_spawn( 

2458 step=step, 

2459 step_id=step_id, 

2460 director=director, 

2461 child_role=child_role, 

2462 base_state=read_state, 

2463 base_ctx=ctx, 

2464 budget=budget, 

2465 max_depth=max_depth, 

2466 max_spawn_per_node=max_spawn_per_node, 

2467 max_concurrent_children=max_concurrent_children, 

2468 ) 

2469 envelope = outcome["envelope"] 

2470 final: PipelineState = {**state, step_id: envelope} 

2471 if outcome["metrics"]: 

2472 final = { 

2473 **final, 

2474 "step_context_metrics": _merge_side_channel( 

2475 state.get("step_context_metrics"), outcome["metrics"] 

2476 ), 

2477 } 

2478 if outcome["tool_meta"]: 

2479 final = { 

2480 **final, 

2481 "step_tool_meta": _merge_side_channel( 

2482 state.get("step_tool_meta"), outcome["tool_meta"] 

2483 ), 

2484 } 

2485 self._stage_spawn_channel_writes(step, envelope["result"], working_set) 

2486 return final 

2487 

2488 def _run_native_spawn( 

2489 self, 

2490 *, 

2491 step: dict, 

2492 step_id: str, 

2493 director: str, 

2494 child_role: str, 

2495 base_state: PipelineState, 

2496 base_ctx: dict, 

2497 budget: SpawnBudget, 

2498 max_depth: int, 

2499 max_spawn_per_node: int | None, 

2500 max_concurrent_children: int, 

2501 ) -> dict: 

2502 """Compile and invoke the native spawn tree, returning its outcome. 

2503 

2504 The graph is a director node that seeds one ``Send`` per child to a 

2505 shared worker, whose worker re-``Send``s itself for deeper levels. An 

2506 outstanding-descendants barrier (``outstanding`` counter plus a ``check`` 

2507 node) makes the ``join`` run exactly once for uneven-depth trees; the 

2508 join assembles the result envelope by path key in plan order. 

2509 """ 

2510 from contextlib import nullcontext 

2511 from typing import TypedDict 

2512 

2513 from langgraph.graph import END, START, StateGraph 

2514 from langgraph.types import Command, Send 

2515 

2516 tracer = self._tracer 

2517 parent_obs = ( 

2518 tracer.open_parent_observation( 

2519 name=f"spawn:{step_id}", 

2520 input={"node_id": step_id, "role": director, "depth": 0}, 

2521 ) 

2522 if tracer is not None 

2523 else None 

2524 ) 

2525 root_obs_id = getattr(parent_obs, "id", None) 

2526 try: 

2527 child_gates: dict[str, threading.Semaphore] = {} 

2528 child_gates_lock = threading.Lock() 

2529 

2530 def child_gate(parent_id: str) -> threading.Semaphore: 

2531 """The concurrency gate for one spawning node's direct children. 

2532 

2533 ``max_concurrent_children`` bounds a single spawn node's fan-out: 

2534 each spawning node owns its own gate, so a shallow node's cap 

2535 never throttles a deeper sibling's subtree. 

2536 """ 

2537 with child_gates_lock: 

2538 gate = child_gates.get(parent_id) 

2539 if gate is None: 

2540 gate = threading.Semaphore(max(1, max_concurrent_children)) 

2541 child_gates[parent_id] = gate 

2542 return gate 

2543 

2544 def run_node( 

2545 *, 

2546 role: str, 

2547 node_step: dict, 

2548 input_override: Any, 

2549 node_id: str, 

2550 depth: int, 

2551 parent_id: str | None = None, 

2552 ) -> dict: 

2553 resolved_role = self._resolver.resolve(role) 

2554 resolved_step = self._resolve_step_runtime( 

2555 node_step, base_state, base_ctx, input_override=input_override 

2556 ) 

2557 resolved_step = self._attach_communication_context( 

2558 resolved_step, base_state 

2559 ) 

2560 resolved_step = self._attach_resolved_tools( 

2561 resolved_step, resolved_role 

2562 ) 

2563 if not budget.consume(): 

2564 raise RunHardFailure( 

2565 "SPAWN_BUDGET_EXHAUSTED", 

2566 f"spawn tree budget exhausted at node '{node_id}'", 

2567 {"node_id": node_id, "depth": depth}, 

2568 ) 

2569 gate = child_gate(parent_id) if parent_id is not None else nullcontext() 

2570 with gate: 

2571 return self._invoke_agent( 

2572 resolved_role, resolved_step, base_state 

2573 ) 

2574 

2575 def build_record( 

2576 *, 

2577 node_id: str, 

2578 path: tuple, 

2579 parent_path: tuple | None, 

2580 depth: int, 

2581 this_result: Any, 

2582 result_state: dict, 

2583 children: list, 

2584 ) -> tuple[dict, list]: 

2585 exhausted = False 

2586 plan: list = [] 

2587 if children: 

2588 if depth + 1 > max_depth: 

2589 exhausted = True 

2590 else: 

2591 plan = [ 

2592 { 

2593 "path": path + (index,), 

2594 "depth": depth + 1, 

2595 "child": child, 

2596 "parent_id": node_id, 

2597 "index": index, 

2598 } 

2599 for index, child in enumerate(children) 

2600 ] 

2601 record = { 

2602 "path": path, 

2603 "parent_path": parent_path, 

2604 "child_id": node_id, 

2605 "result": this_result, 

2606 "self_passed": not self._spawn_is_soft_fail(this_result), 

2607 "exhausted": exhausted, 

2608 "metrics": dict(result_state.get("step_context_metrics") or {}), 

2609 "tool_meta": dict(result_state.get("step_tool_meta") or {}), 

2610 } 

2611 return record, plan 

2612 

2613 def child_plan(node_id: str, this_result: Any, depth: int) -> list: 

2614 children = self._spawn_children_from_result(this_result) 

2615 if children and max_spawn_per_node is not None and len(children) > max_spawn_per_node: 

2616 raise RunHardFailure( 

2617 "SPAWN_OVER_FLIGHT", 

2618 f"spawn node '{node_id}' emitted {len(children)} children, " 

2619 f"exceeds max_spawn_per_node={max_spawn_per_node}", 

2620 { 

2621 "node_id": node_id, 

2622 "depth": depth, 

2623 "children": len(children), 

2624 "max_spawn_per_node": max_spawn_per_node, 

2625 }, 

2626 ) 

2627 return children 

2628 

2629 def director_node(_state: dict) -> Command: 

2630 result_state = run_node( 

2631 role=director, 

2632 node_step=step, 

2633 input_override=_UNSET, 

2634 node_id=step_id, 

2635 depth=0, 

2636 ) 

2637 this_result = result_state.get(step_id) 

2638 children = child_plan(step_id, this_result, 0) 

2639 record, plan = build_record( 

2640 node_id=step_id, 

2641 path=(), 

2642 parent_path=None, 

2643 depth=0, 

2644 this_result=this_result, 

2645 result_state=result_state, 

2646 children=children, 

2647 ) 

2648 update = { 

2649 "accum": {step_id: {(): record}}, 

2650 "outstanding": len(plan) - 1, 

2651 } 

2652 if not plan: 

2653 return Command(update=update, goto="join") 

2654 return Command( 

2655 update=update, 

2656 goto=[ 

2657 Send("worker", {**payload, "parent_obs_id": root_obs_id}) 

2658 for payload in plan 

2659 ], 

2660 ) 

2661 

2662 def worker_node(payload: dict) -> Command: 

2663 path = tuple(payload["path"]) 

2664 depth = payload["depth"] 

2665 parent_id = payload["parent_id"] 

2666 index = payload["index"] 

2667 child = payload["child"] 

2668 child_id = ( 

2669 child["id"] 

2670 if isinstance(child, dict) and child.get("id") 

2671 else f"{parent_id}.{index}" 

2672 ) 

2673 node_step = self._child_node_step( 

2674 node_id=child_id, 

2675 child=child, 

2676 parent_id=parent_id, 

2677 index=index, 

2678 base_state=base_state, 

2679 base_ctx=base_ctx, 

2680 ) 

2681 # The worker's own graph context does not carry the spawning 

2682 # node's observation (each Send target runs in a fresh context), 

2683 # so the parent chain is re-established from the payload before 

2684 # opening this node's observation (ADR-0035 D5). 

2685 parent_obs_id = payload.get("parent_obs_id") 

2686 stack_token = ( 

2687 _PARENT_STACK.set((parent_obs_id,)) 

2688 if tracer is not None and parent_obs_id is not None 

2689 else None 

2690 ) 

2691 child_obs = ( 

2692 tracer.open_parent_observation( 

2693 name=f"spawn:{child_id}", 

2694 input={"node_id": child_id, "role": child_role, "depth": depth}, 

2695 ) 

2696 if tracer is not None 

2697 else None 

2698 ) 

2699 try: 

2700 result_state = run_node( 

2701 role=child_role, 

2702 node_step=node_step, 

2703 input_override=node_step["input"], 

2704 node_id=child_id, 

2705 depth=depth, 

2706 parent_id=parent_id, 

2707 ) 

2708 finally: 

2709 if tracer is not None and child_obs is not None: 

2710 tracer.close_parent_observation(child_obs) 

2711 if stack_token is not None: 

2712 _PARENT_STACK.reset(stack_token) 

2713 this_result = result_state.get(child_id) 

2714 children = child_plan(child_id, this_result, depth) 

2715 record, plan = build_record( 

2716 node_id=child_id, 

2717 path=path, 

2718 parent_path=path[:-1], 

2719 depth=depth, 

2720 this_result=this_result, 

2721 result_state=result_state, 

2722 children=children, 

2723 ) 

2724 update = { 

2725 "accum": {step_id: {path: record}}, 

2726 "outstanding": len(plan) - 1, 

2727 } 

2728 if plan: 

2729 child_obs_id = getattr(child_obs, "id", None) 

2730 return Command( 

2731 update=update, 

2732 goto=[ 

2733 Send( 

2734 "worker", 

2735 {**payload, "parent_obs_id": child_obs_id}, 

2736 ) 

2737 for payload in plan 

2738 ], 

2739 ) 

2740 return Command(update=update, goto="check") 

2741 

2742 def check_node(state: dict) -> Command: 

2743 if state.get("outstanding") == 0: 

2744 return Command(goto="join") 

2745 return Command(goto=END) 

2746 

2747 def join_node(state: dict) -> dict: 

2748 records = (state.get("accum") or {}).get(step_id) or {} 

2749 envelope, metrics, tool_meta = _assemble_spawn_outcome(records) 

2750 return { 

2751 "spawn_outcome": { 

2752 "envelope": envelope, 

2753 "metrics": metrics, 

2754 "tool_meta": tool_meta, 

2755 }, 

2756 "accum": {step_id: None}, 

2757 } 

2758 

2759 schema = TypedDict( 

2760 "_SpawnState", 

2761 { 

2762 "accum": Annotated[dict, accumulate_map_reduce], 

2763 "outstanding": Annotated[int, operator.add], 

2764 "spawn_outcome": dict, 

2765 }, 

2766 ) 

2767 graph = StateGraph(schema) 

2768 graph.add_node("director", director_node) 

2769 graph.add_node("worker", worker_node) 

2770 graph.add_node("check", check_node) 

2771 graph.add_node("join", join_node) 

2772 graph.add_edge(START, "director") 

2773 graph.add_edge("join", END) 

2774 app = graph.compile() 

2775 out = app.invoke( 

2776 {"accum": {}, "outstanding": 1}, 

2777 config={"recursion_limit": _SPAWN_RECURSION_LIMIT}, 

2778 ) 

2779 return out["spawn_outcome"] 

2780 finally: 

2781 if tracer is not None and parent_obs is not None: 

2782 tracer.close_parent_observation(parent_obs) 

2783 

2784 def _child_node_step( 

2785 self, 

2786 *, 

2787 node_id: str, 

2788 child: Any, 

2789 parent_id: str, 

2790 index: int, 

2791 base_state: PipelineState, 

2792 base_ctx: dict, 

2793 ) -> dict: 

2794 """Resolve one child plan item into the worker's runtime step. 

2795 

2796 A child is a runtime-generated step: it carries only its id and its 

2797 resolved input, never the spawn step's declared channel bindings, so a 

2798 child's output can never route a channel write. 

2799 """ 

2800 if not self._spawn_has_input(child): 

2801 raise RunHardFailure( 

2802 "INVALID_CHILD_PLAN", 

2803 f"spawn child #{index} of node '{parent_id}' must be a dict " 

2804 "with a non-empty 'input'", 

2805 {"node_id": parent_id, "child_index": index, "child": repr(child)[:200]}, 

2806 ) 

2807 return {"id": node_id, "input": _resolve_input(child["input"], base_state, base_ctx)} 

2808 

2809 def _stage_spawn_channel_writes( 

2810 self, 

2811 step: dict, 

2812 result: Any, 

2813 working_set: ChannelWorkingSet | None, 

2814 ) -> None: 

2815 """Stage a spawn node's declared writes from its own agent result.""" 

2816 if working_set is None: 

2817 return 

2818 writes = _declared_output_channel_writes(step, result, self._channel_names) 

2819 for name, value in writes.items(): 

2820 working_set.stage(name, value) 

2821 

2822 @staticmethod 

2823 def _spawn_children_from_result(result: Any) -> list: 

2824 """The node's child plan at ``content.children``; [] for a valid leaf. 

2825 

2826 The director's parsed output envelope carries the plan at 

2827 ``content.children``; a result that is not a dict, or whose ``content`` 

2828 is not an object, or carries no ``children``, is a valid leaf. A 

2829 ``children`` value that is not a list is a hard ``INVALID_CHILD_PLAN``. 

2830 """ 

2831 content = _envelope_content(result) 

2832 if not isinstance(content, dict): 

2833 return [] 

2834 raw = content.get("children") 

2835 if raw is None: 

2836 return [] 

2837 if not isinstance(raw, list): 

2838 raise RunHardFailure( 

2839 "INVALID_CHILD_PLAN", 

2840 "spawn agent content 'children' must be a list", 

2841 {"children": repr(raw)[:200]}, 

2842 ) 

2843 return raw 

2844 

2845 @staticmethod 

2846 def _spawn_has_input(child: Any) -> bool: 

2847 """A valid child plan item: a dict carrying a non-empty 'input'.""" 

2848 if not isinstance(child, dict): 

2849 return False 

2850 input_val = child.get("input") 

2851 if input_val is None: 

2852 return False 

2853 if isinstance(input_val, str) and not input_val.strip(): 

2854 return False 

2855 if isinstance(input_val, (list, dict)) and not input_val: 

2856 return False 

2857 return True 

2858 

2859 @staticmethod 

2860 def _spawn_is_soft_fail(result: Any) -> bool: 

2861 """A soft/gap agent outcome (escalate, parse_error, error, budget:*) — not a hard error. 

2862 

2863 An envelope-shaped result is never soft: ``status:error`` is a hard 

2864 failure (the gate raises before this), and ``ok``/``partial``/ 

2865 ``no_success`` are valid non-fatal results. 

2866 """ 

2867 if not isinstance(result, dict): 

2868 return False 

2869 if result.get("status") in ENVELOPE_STATUSES: 

2870 return False 

2871 if _SPAWN_SOFT_FAIL_KEYS & result.keys(): 

2872 return True 

2873 return any(key.startswith("budget:") for key in result) 

2874 

2875 

2876# --------------------------------------------------------------------------- 

2877# GraphBuilder 

2878# --------------------------------------------------------------------------- 

2879 

2880def _topological_order(steps: list[dict]) -> list[dict]: 

2881 """ 

2882 Palauttaa stepit topologisessa järjestyksessä depends_on-kenttien perusteella. 

2883 """ 

2884 id_to_step = {s["id"]: s for s in steps} 

2885 visited: set[str] = set() 

2886 order: list[dict] = [] 

2887 

2888 def visit(step_id: str) -> None: 

2889 if step_id in visited: 

2890 return 

2891 visited.add(step_id) 

2892 for dep in (id_to_step[step_id].get("depends_on") or []): 

2893 if dep in id_to_step: 

2894 visit(dep) 

2895 order.append(id_to_step[step_id]) 

2896 

2897 for s in steps: 

2898 visit(s["id"]) 

2899 return order 

2900 

2901 

2902def _group_dependencies( 

2903 groups: list[list[dict]], 

2904) -> tuple[list[set[int]], list[set[int]]]: 

2905 """Group-index adjacency (predecessors, successors) from each step's depends_on.""" 

2906 step_to_group = { 

2907 step["id"]: index 

2908 for index, group in enumerate(groups) 

2909 for step in group 

2910 } 

2911 predecessors: list[set[int]] = [set() for _ in groups] 

2912 successors: list[set[int]] = [set() for _ in groups] 

2913 for index, group in enumerate(groups): 

2914 for step in group: 

2915 for dep in step.get("depends_on") or []: 

2916 dep_index = step_to_group.get(dep) 

2917 if dep_index is None or dep_index == index: 

2918 continue 

2919 predecessors[index].add(dep_index) 

2920 successors[dep_index].add(index) 

2921 return predecessors, successors 

2922 

2923 

2924@dataclass(frozen=True) 

2925class _RoutePlan: 

2926 """Compiled `routes` topology: route tables plus the static-edge suppression sets. 

2927 

2928 `routes` owns a routing step's outgoing control flow (ADR-0025). A route 

2929 target reached via a static `depends_on` edge is therefore suppressed and 

2930 replaced by the native conditional edge; the plan records both the targets 

2931 (so they are never made entry points) and the suppressed `(source, target)` 

2932 pairs. 

2933 """ 

2934 

2935 step_to_node: dict[str, str] 

2936 routes_by_step: dict[str, list[dict]] 

2937 target_node_ids: frozenset[str] 

2938 routing_node_ids: frozenset[str] 

2939 suppressed_edges: frozenset[tuple[str, str]] 

2940 

2941 

2942def _reject_nested_routes(step: dict) -> None: 

2943 """Reject `routes` on a nested `body` step (top-level-only rule, ADR-0012). 

2944 

2945 The engine wires `routes` only for top-level steps, so a nested declaration 

2946 would be silently ignored; nested routing is deferred. 

2947 """ 

2948 body = step.get("body") 

2949 if not isinstance(body, list): 

2950 return 

2951 for nested in _iter_steps(body): 

2952 if nested.get("routes"): 

2953 raise RunHardFailure( 

2954 "VALIDATION_ERROR", 

2955 f"Step '{nested.get('id')}' declares `routes` inside a nested " 

2956 "body; `routes` is only allowed on top-level steps", 

2957 ) 

2958 

2959 

2960def _build_route_plan(groups: list[list[dict]], node_ids: list[str]) -> _RoutePlan: 

2961 """Resolve every top-level step's `routes` into a node-level routing plan.""" 

2962 step_to_node = { 

2963 step["id"]: node_ids[index] 

2964 for index, group in enumerate(groups) 

2965 for step in group 

2966 } 

2967 step_to_deps = { 

2968 step["id"]: set(step.get("depends_on") or []) 

2969 for group in groups 

2970 for step in group 

2971 } 

2972 routes_by_step: dict[str, list[dict]] = {} 

2973 target_node_ids: set[str] = set() 

2974 routing_node_ids: set[str] = set() 

2975 suppressed_edges: set[tuple[str, str]] = set() 

2976 for group in groups: 

2977 for step in group: 

2978 _reject_nested_routes(step) 

2979 routes = step.get("routes") 

2980 if not routes: 

2981 continue 

2982 source = step["id"] 

2983 source_node = step_to_node[source] 

2984 routing_node_ids.add(source_node) 

2985 routes_by_step[source] = routes 

2986 for route in routes: 

2987 target = route.get("target") 

2988 target_node = step_to_node.get(target) 

2989 if target_node is None: 

2990 raise RunHardFailure( 

2991 "VALIDATION_ERROR", 

2992 f"Step '{source}' route target '{target}' is not a " 

2993 "top-level step in this pipeline", 

2994 ) 

2995 target_node_ids.add(target_node) 

2996 if source in step_to_deps.get(target, set()): 

2997 suppressed_edges.add((source_node, target_node)) 

2998 log.warning( 

2999 "[routes] suppressing static edge %s -> %s; route " 

3000 "targets are wired via `routes`", 

3001 source, 

3002 target, 

3003 ) 

3004 return _RoutePlan( 

3005 step_to_node=step_to_node, 

3006 routes_by_step=routes_by_step, 

3007 target_node_ids=frozenset(target_node_ids), 

3008 routing_node_ids=frozenset(routing_node_ids), 

3009 suppressed_edges=frozenset(suppressed_edges), 

3010 ) 

3011 

3012 

3013def _make_route_router(routes: list[dict], step_to_node: dict[str, str], end) -> Any: 

3014 """First-match-wins router over a routing step's declared routes. 

3015 

3016 Evaluates `when` in declaration order and returns the selected target node; 

3017 a route without `when` is the default branch. With no match and no default 

3018 it returns the graph terminal. 

3019 """ 

3020 def router(state: PipelineState) -> str: 

3021 ctx = _flatten_channel_state(state) if _CORE in state else state 

3022 for route in routes: 

3023 if _eval_condition(route.get("when"), ctx): 

3024 return step_to_node[route["target"]] 

3025 return end 

3026 

3027 return router 

3028 

3029 

3030def _wire_route_edges(graph, route_plan: _RoutePlan, end) -> None: 

3031 """Add one native conditional edge per routing step, with an explicit path_map.""" 

3032 for source, routes in route_plan.routes_by_step.items(): 

3033 target_nodes = [route_plan.step_to_node[r["target"]] for r in routes] 

3034 path_map = {node: node for node in target_nodes} 

3035 path_map[end] = end 

3036 graph.add_conditional_edges( 

3037 route_plan.step_to_node[source], 

3038 _make_route_router(routes, route_plan.step_to_node, end), 

3039 path_map, 

3040 ) 

3041 

3042 

3043def _wire_dependency_edges( 

3044 graph, 

3045 groups: list[list[dict]], 

3046 node_ids: list[str], 

3047 end, 

3048 route_plan: _RoutePlan, 

3049 entry_ids: list[str] | None = None, 

3050 exit_ids: list[str] | None = None, 

3051) -> None: 

3052 """Wire native branches and fan-in joins from group-level depends_on edges. 

3053 

3054 Static edges originating from a routing step to one of its route targets are 

3055 suppressed — `routes` supplies that control flow. A route target with no 

3056 other predecessor is reached by its conditional edge, never an entry point. 

3057 A group split into a Send fan-out enters through its dispatch node and exits 

3058 through its join node, so ``entry_ids``/``exit_ids`` name those boundaries. 

3059 """ 

3060 entry_ids = entry_ids if entry_ids is not None else node_ids 

3061 exit_ids = exit_ids if exit_ids is not None else node_ids 

3062 predecessors, successors = _group_dependencies(groups) 

3063 for index, node_id in enumerate(node_ids): 

3064 entry = entry_ids[index] 

3065 pred_nodes = [exit_ids[p] for p in sorted(predecessors[index])] 

3066 if not pred_nodes: 

3067 if node_id not in route_plan.target_node_ids: 

3068 graph.set_entry_point(entry) 

3069 else: 

3070 live_preds = [ 

3071 pred 

3072 for pred in pred_nodes 

3073 if (pred, entry) not in route_plan.suppressed_edges 

3074 ] 

3075 if len(live_preds) == 1: 

3076 graph.add_edge(live_preds[0], entry) 

3077 elif live_preds: 

3078 graph.add_edge(live_preds, entry) 

3079 if not successors[index] and node_id not in route_plan.routing_node_ids: 

3080 graph.add_edge(exit_ids[index], end) 

3081 

3082 

3083# --------------------------------------------------------------------------- 

3084# Call-step build-time expansion (ADR-0037 D3/D4) 

3085# --------------------------------------------------------------------------- 

3086# 

3087# A `type: call` step is composed at build time: the function's body steps plus 

3088# two synthesized op steps (`__call_bind` / `__call_emit`) are assembled into a 

3089# compiled subgraph added as a single parent-graph node keyed by the call id. The 

3090# runtime executes only native steps; LangGraph never sees a call step. The caller 

3091# reads the function outputs via the call result shape 

3092# `state[<call_id>] = {outputs: {<name>: value}}` (00-process-contract r.70). 

3093# The function body's `{{ params.<name> }}` references are rewritten to 

3094# `{{ <call_id>.params.<name> }}` so the bind step's params namespace resolves. A 

3095# call nested in a foreach/while body is flat-inlined instead, because loop bodies 

3096# execute imperatively and cannot host a compiled subgraph node. 

3097# 

3098# Isolation (ADR-0037 D5 / 00-functions § Isolation) and recursion (ADR-0037 D7): a 

3099# function body step must not reference a step-id outside its own body (a 

3100# one-way dependency check — outward references to caller step-ids are build 

3101# errors); a cycle in the function call graph (self or mutual) is a build 

3102# error. Bounded while loops are runtime constructs and are not cycle-flagged. 

3103 

3104# Call step result namespace keys (internal scaffolding). 

3105def _scope_params_value(value: Any, call_id: str) -> Any: 

3106 """Rewrite `params.` template references to the call-scoped namespace. 

3107 

3108 Operates on the function body's string values (templates). Dict keys are 

3109 untouched — only templated string VALUES are scoped, so a function body's 

3110 `{{ params.<name> }}` becomes `{{ <call_id>.params.<name> }}`. 

3111 """ 

3112 if isinstance(value, str): 

3113 return re.sub(r"\bparams\.", f"{call_id}.params.", value) 

3114 if isinstance(value, dict): 

3115 return {k: _scope_params_value(v, call_id) for k, v in value.items()} 

3116 if isinstance(value, list): 

3117 return [_scope_params_value(v, call_id) for v in value] 

3118 return value 

3119 

3120 

3121def _scope_params(step: dict, call_id: str) -> dict: 

3122 """Deep-scope a function body step's params references for a call id.""" 

3123 scoped: dict = dict(step) 

3124 for field in ("input", "params", "condition", "exit_condition", "agent", "function"): 

3125 if field in scoped and isinstance(scoped[field], (str, dict, list)): 

3126 scoped[field] = _scope_params_value(scoped[field], call_id) 

3127 if "body" in scoped and isinstance(scoped["body"], list): 

3128 scoped["body"] = [_scope_params(b, call_id) for b in scoped["body"]] 

3129 return scoped 

3130 

3131 

3132def _collect_step_ids(steps: list[dict]) -> set[str]: 

3133 """All step ids declared in *steps*, including nested body step ids.""" 

3134 ids: set[str] = set() 

3135 for step in steps: 

3136 step_id = step.get("id") 

3137 if isinstance(step_id, str): 

3138 ids.add(step_id) 

3139 body = step.get("body") 

3140 if isinstance(body, list): 

3141 ids |= _collect_step_ids(body) 

3142 return ids 

3143 

3144 

3145def _iter_steps(steps: list[dict]): 

3146 """Yield every step in *steps*, recursing into nested bodies.""" 

3147 for step in steps: 

3148 yield step 

3149 body = step.get("body") 

3150 if isinstance(body, list): 

3151 yield from _iter_steps(body) 

3152 

3153 

3154def _collect_composed_step_ids(steps: list[dict]) -> set[str]: 

3155 """All declared step ids, including ids composed into native subgraphs. 

3156 

3157 A ``call`` step becomes a ``__subgraph__`` placeholder whose inner step ids 

3158 live only inside the compiled subgraph. The placeholder records them under 

3159 ``_SUBGRAPH_STEP_IDS_KEY`` so name-collision validation sees the full set 

3160 even after body steps stop being textually inlined. 

3161 """ 

3162 ids = _collect_step_ids(steps) 

3163 for step in _iter_steps(steps): 

3164 composed_ids = step.get(_SUBGRAPH_STEP_IDS_KEY) 

3165 if isinstance(composed_ids, (set, frozenset)): 

3166 ids |= composed_ids 

3167 return ids 

3168 

3169 

3170_TEMPLATE_REF_RE = re.compile(r"\{\{\s*([^}]*)\}\}") 

3171_STRING_LITERAL_RE = re.compile(r"'[^']*'|\"[^\"]*\"") 

3172_IDENTIFIER_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") 

3173 

3174_REFERENCE_FIELDS = ("input", "params", "condition", "exit_condition") 

3175 

3176# Engine-reserved template namespaces — never step-id references, even if a 

3177# caller happens to declare a step with one of these names. 

3178_RESERVED_NAMESPACES = frozenset({"params", "loop", "item", "_item", "budget", "config"}) 

3179 

3180 

3181def _referenced_names(value: Any) -> set[str]: 

3182 """Root identifiers read by templated strings. 

3183 

3184 Only the leading identifier of each reference (before the first ``.`` or 

3185 ``[``) can name a step-id; nested field segments are ignored. Quoted 

3186 literals are ignored. 

3187 """ 

3188 names: set[str] = set() 

3189 if isinstance(value, str): 

3190 for expr in _TEMPLATE_REF_RE.findall(value): 

3191 bare = _STRING_LITERAL_RE.sub("", expr) 

3192 for match in _IDENTIFIER_RE.finditer(bare): 

3193 if match.start() == 0 or bare[match.start() - 1] != ".": 

3194 names.add(match.group()) 

3195 elif isinstance(value, dict): 

3196 for v in value.values(): 

3197 names |= _referenced_names(v) 

3198 elif isinstance(value, list): 

3199 for v in value: 

3200 names |= _referenced_names(v) 

3201 return names 

3202 

3203 

3204def _validate_function_purity( 

3205 fn_name: str, body: list[dict], visible_ids: set[str] 

3206) -> None: 

3207 """One-way isolation: a function body must not reference a caller step-id. 

3208 

3209 A body step may read its own body step-ids and the params namespace; a 

3210 reference to a step-id that exists outside the body (``visible_ids`` but 

3211 not declared in the body) is an outward dependency and a hard build error. 

3212 """ 

3213 local_ids = _collect_step_ids(body) 

3214 for step in _iter_steps(body): 

3215 step_id = step.get("id", "?") 

3216 for dep in step.get("depends_on") or []: 

3217 if dep in visible_ids and dep not in local_ids: 

3218 raise RunHardFailure( 

3219 "VALIDATION_ERROR", 

3220 f"function '{fn_name}' step '{step_id}': depends_on " 

3221 f"'{dep}' is outside the function body (isolation — " 

3222 "function bodies see only their own steps and params)", 

3223 ) 

3224 for field in _REFERENCE_FIELDS: 

3225 for ref in _referenced_names(step.get(field)): 

3226 if ref in _RESERVED_NAMESPACES: 

3227 continue 

3228 if ref in visible_ids and ref not in local_ids: 

3229 raise RunHardFailure( 

3230 "VALIDATION_ERROR", 

3231 f"function '{fn_name}' step '{step_id}': '{field}' " 

3232 f"references '{ref}', a step-id outside the function " 

3233 "body (isolation — function bodies see only their own " 

3234 "steps and params)", 

3235 ) 

3236 

3237 

3238def _reject_function_body_routes(fn_name: str, body: list[dict]) -> None: 

3239 """Reject `routes` anywhere in a function body (top-level-only rule). 

3240 

3241 A function body is inlined to the top level, so a body step declaring 

3242 `routes` would escape the nested-`body` guard and be silently rewired; 

3243 reject it at build time instead. 

3244 """ 

3245 for step in _iter_steps(body): 

3246 if step.get("routes"): 

3247 raise RunHardFailure( 

3248 "VALIDATION_ERROR", 

3249 f"function '{fn_name}' step '{step.get('id')}' declares `routes`; " 

3250 "`routes` is only allowed on top-level steps", 

3251 ) 

3252 

3253 

3254def _rewrite_call_deps(steps: list[dict], call_map: dict[str, str]) -> None: 

3255 """Rewrite `depends_on` references to call ids → the corresponding emit id. 

3256 

3257 After a call is expanded away, a downstream step that depends_on the call id 

3258 must instead wait on the emit step (which writes state[<call_id>]). This 

3259 guarantees the caller's `{{ <call_id>.outputs.<name> }}` reads resolve after 

3260 the function body ran. 

3261 """ 

3262 for step in steps: 

3263 deps = step.get("depends_on") or [] 

3264 if deps: 

3265 step["depends_on"] = [call_map.get(d, d) for d in deps] 

3266 if "body" in step and isinstance(step["body"], list): 

3267 _rewrite_call_deps(step["body"], call_map) 

3268 

3269 

3270def expand_calls(steps: list[dict], registry: ManifestRegistry) -> list[dict]: 

3271 """Expand every ``call`` step through the native subgraph composer. 

3272 

3273 Test-facing seam over the single call-expansion implementation: the same 

3274 ``_CallComposer`` that ``GraphBuilder`` uses builds the steps, so no second 

3275 (flat) expansion path exists. The embedded subgraph value is the un-compiled 

3276 sub-step list; a runnable graph only needs the compiled form, which 

3277 production obtains through ``GraphBuilder._compose_calls``. 

3278 """ 

3279 composer = _CallComposer( 

3280 registry, 

3281 assemble_subgraph=lambda sub_steps: sub_steps, 

3282 channels=[], 

3283 channel_names=frozenset(), 

3284 ) 

3285 return composer.compose(steps) 

3286 

3287 

3288_SUBGRAPH_STEP_TYPE = "__subgraph__" 

3289_SUBGRAPH_GRAPH_KEY = "__subgraph_graph__" 

3290_SUBGRAPH_STEP_IDS_KEY = "__subgraph_step_ids__" 

3291 

3292# A `while` step is composed into a `__subgraph__` placeholder whose embedded 

3293# graph is a native LangGraph cycle (head -> body -> cond -> head/exit). The 

3294# marker flags the placeholder so the parent uses the while node wrapper (which 

3295# keeps the loop-internal state out of the parent result) rather than the plain 

3296# call-subgraph node, and carries the build-time recursion bound. 

3297_SUBGRAPH_WHILE_KEY = "__subgraph_while__" 

3298_WHILE_RECURSION_LIMIT_KEY = "__while_recursion_limit__" 

3299 

3300# Engine-local cycle state: the active while's `{current, iteration, passed, 

3301# skipped, max_iter}` marker. Nested whiles live in isolated cycle invocations, 

3302# so a shared key cannot collide across them. 

3303_WHILE_STATE_KEY = "__while_state__" 

3304 

3305# State keys the while wrapper propagates from the isolated cycle back to the 

3306# parent (the loop result itself is carried separately under the step id). 

3307_WHILE_SIDE_CHANNEL_KEYS = ("errors", "step_context_metrics", "step_tool_meta") 

3308 

3309# A sequential `foreach` (max_concurrent absent or resolving to <= 1) is 

3310# composed into a `__subgraph__` placeholder whose embedded graph is a native 

3311# LangGraph cycle (head -> body/agent -> cond -> head/exit). The marker flags 

3312# the placeholder so the parent uses the foreach node wrapper, and carries the 

3313# build-time recursion bound. 

3314_SUBGRAPH_FOREACH_KEY = "__subgraph_foreach__" 

3315_FOREACH_RECURSION_LIMIT_KEY = "__foreach_recursion_limit__" 

3316 

3317# Engine-local cycle state: the active foreach's `{items, index, results, 

3318# produced_outputs, max_output}` marker. Nested foreachs live in isolated cycle 

3319# invocations, so a shared key cannot collide across them. 

3320_FOREACH_STATE_KEY = "__foreach_state__" 

3321 

3322# State keys the foreach wrapper propagates from the isolated cycle back to the 

3323# parent (the aggregate result itself is carried separately under the step id). 

3324_FOREACH_SIDE_CHANNEL_KEYS = ("errors", "step_context_metrics", "step_tool_meta") 

3325 

3326# Build-time recursion bound fallback for a foreach with no declared 

3327# `max_iterations`: the item list is uncapped, so reserve a generous bound. 

3328_FOREACH_DEFAULT_MAX_ITER = 1000 

3329 

3330 

3331class _CallComposer: 

3332 """Compose `call` steps into compiled native subgraph nodes (ADR-0037 D4). 

3333 

3334 A function body becomes its own compiled StateGraph added to the parent as a 

3335 single node keyed by the call id; the ``bind``/``emit`` scaffolding and the 

3336 body steps live inside that subgraph and share the parent state. Nested 

3337 function calls compose recursively. A `call` nested in a foreach/while body 

3338 is composed the same way; the loop body executes the embedded compiled graph 

3339 imperatively via ``run_step``'s ``__subgraph__`` branch. 

3340 """ 

3341 

3342 def __init__( 

3343 self, 

3344 registry: ManifestRegistry, 

3345 assemble_subgraph: Any, 

3346 channels: list[dict], 

3347 channel_names: frozenset[str], 

3348 *, 

3349 assemble_while: Any | None = None, 

3350 compose_whiles: bool = False, 

3351 assemble_foreach: Any | None = None, 

3352 compose_foreachs: bool = False, 

3353 build_ctx: dict | None = None, 

3354 ) -> None: 

3355 self._registry = registry 

3356 self._assemble_subgraph = assemble_subgraph 

3357 self._channels = channels 

3358 self._channel_names = channel_names 

3359 self._assemble_while = assemble_while 

3360 self._compose_whiles = compose_whiles 

3361 self._assemble_foreach = assemble_foreach 

3362 self._compose_foreachs = compose_foreachs 

3363 self._build_ctx = build_ctx or {} 

3364 self._stack: list[str] = [] 

3365 self._expanded_functions: dict[str, str] = {} 

3366 self._call_map: dict[str, str] = {} 

3367 

3368 def compose(self, steps: list[dict]) -> list[dict]: 

3369 visible_ids = _collect_step_ids(steps) 

3370 composed: list[dict] = [] 

3371 for step in steps: 

3372 composed.extend(self._compose_step(step, visible_ids=visible_ids)) 

3373 return composed 

3374 

3375 def _compose_step(self, step: dict, *, visible_ids: set[str]) -> list[dict]: 

3376 step_type = step.get("type") 

3377 if step_type == "call": 

3378 return [self._compose_subgraph_call(step, visible_ids)] 

3379 if step_type == "while" and self._compose_whiles: 

3380 return [self._compose_while(step, visible_ids)] 

3381 if step_type == "foreach" and self._should_compose_foreach(step): 

3382 return [self._compose_foreach(step, visible_ids)] 

3383 body = step.get("body") 

3384 if not isinstance(body, list): 

3385 return [step] 

3386 nested: list[dict] = [] 

3387 for sub in body: 

3388 nested.extend(self._compose_step(sub, visible_ids=visible_ids)) 

3389 _rewrite_call_deps(nested, self._call_map) 

3390 return [{**step, "body": nested}] 

3391 

3392 def _compose_while(self, step: dict, visible_ids: set[str]) -> dict: 

3393 """Compose a `while` step into a native LangGraph cycle subgraph. 

3394 

3395 The embedded graph owns the loop topology: a head that resolves the 

3396 entry condition / initial ``current``, the composed body step nodes, a 

3397 conditional back-edge that computes the feed + exit condition and routes 

3398 continue-vs-exit, and an exit node that writes the documented while 

3399 result. ``expand_calls`` leaves the step uncomposed (``compose_whiles`` 

3400 false) so the call-expansion seam still exposes the loop body. 

3401 """ 

3402 _reject_nested_routes(step) 

3403 body = step.get("body") or [] 

3404 nested: list[dict] = [] 

3405 for sub in body: 

3406 nested.extend(self._compose_step(sub, visible_ids=visible_ids)) 

3407 _validate_step_channel_bindings(nested, self._channel_names) 

3408 compiled, recursion_limit = self._assemble_while(step, nested) 

3409 placeholder: dict = { 

3410 "id": step["id"], 

3411 "type": _SUBGRAPH_STEP_TYPE, 

3412 _SUBGRAPH_GRAPH_KEY: compiled, 

3413 _SUBGRAPH_STEP_IDS_KEY: _collect_composed_step_ids(nested), 

3414 _SUBGRAPH_WHILE_KEY: True, 

3415 _WHILE_RECURSION_LIMIT_KEY: recursion_limit, 

3416 "depends_on": list(step.get("depends_on") or []), 

3417 } 

3418 if step.get("routes"): 

3419 placeholder["routes"] = step["routes"] 

3420 return placeholder 

3421 

3422 def _should_compose_foreach(self, step: dict) -> bool: 

3423 """Whether a `foreach` is the sequential form composed into a cycle. 

3424 

3425 Only a `max_concurrent` absent or resolving to ``<= 1`` is composed; a 

3426 declared ``max_concurrent > 1`` keeps the native ``Send`` / in-node 

3427 concurrent behaviour. 

3428 """ 

3429 if not self._compose_foreachs: 

3430 return False 

3431 return _resolved_send_concurrency(step, self._build_ctx) <= 1 

3432 

3433 def _compose_foreach(self, step: dict, visible_ids: set[str]) -> dict: 

3434 """Compose a sequential `foreach` into a native LangGraph cycle subgraph. 

3435 

3436 Mirrors ``_compose_while``: the embedded graph owns the loop topology 

3437 (a head that resolves the item list and injects the current item, the 

3438 composed body step nodes or the per-item agent node, a conditional 

3439 back-edge routing next-item-vs-exit, and an exit node that writes the 

3440 documented aggregate). ``expand_calls`` leaves the step uncomposed so 

3441 the call-expansion seam still exposes the loop body. 

3442 """ 

3443 _reject_nested_routes(step) 

3444 body = step.get("body") or [] 

3445 nested: list[dict] = [] 

3446 for sub in body: 

3447 nested.extend(self._compose_step(sub, visible_ids=visible_ids)) 

3448 _validate_step_channel_bindings(nested, self._channel_names) 

3449 compiled, recursion_limit = self._assemble_foreach(step, nested) 

3450 placeholder: dict = { 

3451 "id": step["id"], 

3452 "type": _SUBGRAPH_STEP_TYPE, 

3453 _SUBGRAPH_GRAPH_KEY: compiled, 

3454 _SUBGRAPH_STEP_IDS_KEY: _collect_composed_step_ids(nested), 

3455 _SUBGRAPH_FOREACH_KEY: True, 

3456 _FOREACH_RECURSION_LIMIT_KEY: recursion_limit, 

3457 "depends_on": list(step.get("depends_on") or []), 

3458 } 

3459 if step.get("routes"): 

3460 placeholder["routes"] = step["routes"] 

3461 return placeholder 

3462 

3463 def _compose_subgraph_call(self, step: dict, visible_ids: set[str]) -> dict: 

3464 call_id = step["id"] 

3465 function, body = self._check_call_guards(step, visible_ids) 

3466 

3467 nested_visible = visible_ids | _collect_step_ids(body) 

3468 outputs_spec = ( 

3469 ((function.get("spec") or {}).get("interface") or {}).get("outputs") or [] 

3470 ) 

3471 bind_id = f"_call__{call_id}__bind" 

3472 emit_id = f"_call__{call_id}__emit" 

3473 bind_step: dict = { 

3474 "id": bind_id, 

3475 "type": "builtin", 

3476 "component": "__call_bind", 

3477 "params": {**(step.get("params") or {}), "__call_id": call_id}, 

3478 "depends_on": [], 

3479 } 

3480 

3481 self._stack.append(step["function"]) 

3482 try: 

3483 expanded_body = self._compose_body( 

3484 body, call_id, bind_id, nested_visible 

3485 ) 

3486 finally: 

3487 self._stack.pop() 

3488 

3489 emit_step: dict = { 

3490 "id": emit_id, 

3491 "type": "builtin", 

3492 "component": "__call_emit", 

3493 "params": {"__call_id": call_id, "__outputs": outputs_spec}, 

3494 "depends_on": [bind_id] + [s["id"] for s in expanded_body], 

3495 } 

3496 sub_steps = [bind_step, *expanded_body, emit_step] 

3497 if self._channels: 

3498 _validate_channel_inputs(self._channels, sub_steps) 

3499 _validate_step_channel_bindings(sub_steps, self._channel_names) 

3500 placeholder: dict = { 

3501 "id": call_id, 

3502 "type": _SUBGRAPH_STEP_TYPE, 

3503 _SUBGRAPH_GRAPH_KEY: self._assemble_subgraph( 

3504 _topological_order(sub_steps) 

3505 ), 

3506 _SUBGRAPH_STEP_IDS_KEY: _collect_composed_step_ids(sub_steps), 

3507 "depends_on": list(step.get("depends_on") or []), 

3508 } 

3509 call_condition = step.get("condition") 

3510 if call_condition is not None: 

3511 placeholder["condition"] = call_condition 

3512 return placeholder 

3513 

3514 def _compose_body( 

3515 self, body: list[dict], call_id: str, bind_id: str, visible_ids: set[str] 

3516 ) -> list[dict]: 

3517 expanded: list[dict] = [] 

3518 for body_step in body: 

3519 scoped = _scope_params(body_step, call_id) 

3520 for sub in self._compose_step(scoped, visible_ids=visible_ids): 

3521 deps = list(sub.get("depends_on") or []) 

3522 if bind_id in deps: 

3523 expanded.append(sub) 

3524 else: 

3525 expanded.append({**sub, "depends_on": [bind_id, *deps]}) 

3526 return expanded 

3527 

3528 def _check_call_guards( 

3529 self, step: dict, visible_ids: set[str] 

3530 ) -> tuple[dict, list[dict]]: 

3531 """Single owner of every build-time ``call`` guard (ADR-0037 D3/D5/D7). 

3532 

3533 Rejects routes on the call step, function cycles, duplicate expansion, 

3534 unknown functions, function-body isolation (purity) violations, and 

3535 body-declared routes; returns the resolved function and its body. 

3536 """ 

3537 fn_name = step["function"] 

3538 call_id = step["id"] 

3539 

3540 if step.get("routes"): 

3541 raise RunHardFailure( 

3542 "VALIDATION_ERROR", 

3543 f"call step '{call_id}' declares `routes`; `routes` is not supported " 

3544 "on call steps", 

3545 ) 

3546 if fn_name in self._stack: 

3547 chain = " -> ".join(self._stack + [fn_name]) 

3548 raise RunHardFailure( 

3549 "VALIDATION_ERROR", 

3550 f"cycle detected in function expansion: {chain} " 

3551 "(ADR-0037 D7 — recursion must be a bounded while loop or " 

3552 "idempotent re-run)", 

3553 ) 

3554 if fn_name in self._expanded_functions: 

3555 raise RunHardFailure( 

3556 "VALIDATION_ERROR", 

3557 f"duplicate call expansion: function '{fn_name}' called at both " 

3558 f"'{self._expanded_functions[fn_name]}' and '{call_id}' in one " 

3559 "pipeline (ADR-0037 D7 — repeated calls to the same function are " 

3560 "not yet supported)", 

3561 ) 

3562 self._expanded_functions[fn_name] = call_id 

3563 

3564 function = self._registry.get("Function", fn_name) 

3565 if function is None: 

3566 raise RunHardFailure( 

3567 "VALIDATION_ERROR", 

3568 f"call step '{call_id}' references unknown function '{fn_name}' " 

3569 "(no kind: Function manifest in the workflows tree)", 

3570 ) 

3571 body = (function.get("spec") or {}).get("steps") or [] 

3572 _validate_function_purity(fn_name, body, visible_ids) 

3573 _reject_function_body_routes(fn_name, body) 

3574 return function, body 

3575 

3576 

3577_CHANNEL_TYPES = frozenset({"LastValue", "Topic", "BinaryOperatorAggregate"}) 

3578 

3579 

3580def _validate_channel_entry(index: int, channel: Any) -> None: 

3581 """Validate one spec.channels entry against the frozen ChannelSpec shape. 

3582 

3583 The engine build path does not run manifest-schema validation, so a 

3584 malformed entry is rejected here — before any ``["name"]`` indexing — with 

3585 an error that identifies the offending entry. 

3586 """ 

3587 if not isinstance(channel, dict): 

3588 raise RunHardFailure( 

3589 "CHANNEL_BINDING_ERROR", 

3590 f"Pipeline spec.channels[{index}] must be a mapping; got " 

3591 f"{type(channel).__name__}", 

3592 ) 

3593 name = channel.get("name") 

3594 if not isinstance(name, str) or not name: 

3595 raise RunHardFailure( 

3596 "CHANNEL_BINDING_ERROR", 

3597 f"Pipeline spec.channels[{index}] is missing a valid 'name'; each " 

3598 "channel requires a non-empty string 'name'", 

3599 ) 

3600 if "type" in channel and channel["type"] not in _CHANNEL_TYPES: 

3601 raise RunHardFailure( 

3602 "CHANNEL_BINDING_ERROR", 

3603 f"Pipeline spec.channels[{index}] ('{name}') has invalid type " 

3604 f"'{channel['type']}'; expected one of {sorted(_CHANNEL_TYPES)}", 

3605 ) 

3606 

3607 

3608def _validated_channels(raw_channels: Any) -> list[dict]: 

3609 """Return spec.channels as a validated list, or [] when absent. 

3610 

3611 ``None`` (absent) and an empty list are both valid no-channel pipelines. 

3612 """ 

3613 if raw_channels is None: 

3614 return [] 

3615 if not isinstance(raw_channels, list): 

3616 raise RunHardFailure( 

3617 "CHANNEL_BINDING_ERROR", 

3618 f"Pipeline spec.channels must be a list; got " 

3619 f"{type(raw_channels).__name__}", 

3620 ) 

3621 for index, channel in enumerate(raw_channels): 

3622 _validate_channel_entry(index, channel) 

3623 return raw_channels 

3624 

3625 

3626def _validate_step_channel_bindings( 

3627 steps: list[dict], channel_names: frozenset[str] 

3628) -> None: 

3629 """Reject malformed or undeclared step-level channel bindings at build time. 

3630 

3631 Every step's declared ``output_channel`` must be a non-empty declared 

3632 channel name, and every declared ``input_channels`` entry must be a 

3633 non-empty declared channel name. This runs unconditionally, so a binding 

3634 to a channel absent from ``spec.channels`` fails the build even when no 

3635 channels are declared. 

3636 """ 

3637 for step in _iter_steps(steps): 

3638 _validate_step_output_channel(step, channel_names) 

3639 _validate_step_input_channels(step, channel_names) 

3640 

3641 

3642def _validate_step_output_channel(step: dict, channel_names: frozenset[str]) -> None: 

3643 channel = step.get("output_channel") 

3644 if channel is None: 

3645 return 

3646 if not isinstance(channel, str) or not channel: 

3647 raise RunHardFailure( 

3648 "CHANNEL_BINDING_ERROR", 

3649 f"Step '{step.get('id', '?')}' output_channel must be a non-empty " 

3650 f"string; got {channel!r}", 

3651 ) 

3652 if channel not in channel_names: 

3653 raise RunHardFailure( 

3654 "CHANNEL_BINDING_ERROR", 

3655 f"Step '{step.get('id', '?')}' declares output_channel '{channel}' " 

3656 "which is not declared in spec.channels", 

3657 ) 

3658 

3659 

3660def _validate_step_input_channels(step: dict, channel_names: frozenset[str]) -> None: 

3661 channels = step.get("input_channels") 

3662 if channels is None: 

3663 return 

3664 if not isinstance(channels, list): 

3665 raise RunHardFailure( 

3666 "CHANNEL_BINDING_ERROR", 

3667 f"Step '{step.get('id', '?')}' input_channels must be a list of " 

3668 f"channel names; got {type(channels).__name__}", 

3669 ) 

3670 for channel in channels: 

3671 if not isinstance(channel, str) or not channel: 

3672 raise RunHardFailure( 

3673 "CHANNEL_BINDING_ERROR", 

3674 f"Step '{step.get('id', '?')}' input_channels has an invalid " 

3675 f"channel name '{channel}'; channel names must be non-empty " 

3676 "strings", 

3677 ) 

3678 if channel not in channel_names: 

3679 raise RunHardFailure( 

3680 "CHANNEL_BINDING_ERROR", 

3681 f"Step '{step.get('id', '?')}' reads undeclared channel " 

3682 f"'{channel}'; the channel must be declared in spec.channels", 

3683 ) 

3684 

3685 

3686def _validate_channel_names(channels: list[dict], steps: list[dict]) -> None: 

3687 """Reject duplicate and engine/step-reserved channel names.""" 

3688 forbidden = ( 

3689 {_CORE} 

3690 | set(_RESERVED_NAMESPACES) 

3691 | _CORE_STATE_KEYS 

3692 | _collect_composed_step_ids(steps) 

3693 ) 

3694 seen: set[str] = set() 

3695 for channel in channels: 

3696 name = channel.get("name") 

3697 if name in seen: 

3698 raise RunHardFailure( 

3699 "CHANNEL_BINDING_ERROR", 

3700 f"Pipeline declares channel '{name}' more than once; channel " 

3701 "names must be unique", 

3702 ) 

3703 seen.add(name) 

3704 if name in forbidden: 

3705 raise RunHardFailure( 

3706 "CHANNEL_BINDING_ERROR", 

3707 f"Pipeline channel '{name}' collides with an engine-reserved " 

3708 "name (a step id, engine state key, reserved namespace, or the " 

3709 "core state channel); channel names must be unique and " 

3710 "non-reserved", 

3711 ) 

3712 

3713 

3714def _input_path_root(value: str) -> str | None: 

3715 """Leading state key of a bare dot-path input string, or None. 

3716 

3717 Template strings, bracket-prefixed verbatim literals, and blank strings 

3718 are not state reads and yield None. 

3719 """ 

3720 if "{{" in value: 

3721 return None 

3722 stripped = value.lstrip() 

3723 if not stripped or stripped.startswith("["): 

3724 return None 

3725 return re.split(r"[.\[]", stripped, maxsplit=1)[0] 

3726 

3727 

3728def _iter_input_roots(input_spec: Any): 

3729 """Yield every bare dot-path root in an input spec, recursing into containers.""" 

3730 if isinstance(input_spec, str): 

3731 root = _input_path_root(input_spec) 

3732 if root: 

3733 yield root 

3734 elif isinstance(input_spec, dict): 

3735 for value in input_spec.values(): 

3736 yield from _iter_input_roots(value) 

3737 elif isinstance(input_spec, list): 

3738 for value in input_spec: 

3739 yield from _iter_input_roots(value) 

3740 

3741 

3742def _validate_channel_inputs(channels: list[dict], steps: list[dict]) -> None: 

3743 """Reject channel-pipeline input roots that name nothing the state exposes. 

3744 

3745 The channel state is the declared channels plus the engine state, so a bare 

3746 dot-path input root must be a declared channel, a known step id, a reserved 

3747 namespace, or an engine state key. Anything else is a hard build error — 

3748 there is no silent fallback to a missing key. 

3749 """ 

3750 allowed = ( 

3751 {ch["name"] for ch in channels} 

3752 | _collect_step_ids(steps) 

3753 | set(_RESERVED_NAMESPACES) 

3754 | _CORE_STATE_KEYS 

3755 ) 

3756 for step in _iter_steps(steps): 

3757 for root in _iter_input_roots(step.get("input")): 

3758 if root not in allowed: 

3759 raise RunHardFailure( 

3760 "CHANNEL_BINDING_ERROR", 

3761 f"Pipeline channel step '{step.get('id', '?')}' input " 

3762 f"references unknown root '{root}'; in a channel pipeline " 

3763 "an input root must be a declared channel, a step id, a " 

3764 "reserved namespace, or an engine state key", 

3765 ) 

3766 

3767 

3768def _flatten_channel_state(state: dict) -> dict: 

3769 """Read the flat committed snapshot nested under the Core namespace.""" 

3770 return dict(state.get(_CORE) or {}) 

3771 

3772 

3773def _strip_channel_keys(flat: dict, channel_names: frozenset[str]) -> dict: 

3774 """Drop channel-named keys so a node emits only the core state.""" 

3775 return {k: v for k, v in flat.items() if k not in channel_names} 

3776 

3777 

3778_FAILURE_ENVELOPE_KEYS = frozenset({"error", "parse_error"}) 

3779 

3780_NON_ERROR_ENVELOPE_STATUSES: frozenset[str] = ENVELOPE_STATUSES - {"error"} 

3781 

3782 

3783def _envelope_content(result: Any) -> Any: 

3784 """Return an envelope's optional ``content`` field (ADR-0023 D3). 

3785 

3786 ``content`` is opaque and optional: a non-mapping result, an absent field, 

3787 and an explicit ``null`` all read as ``None``. Callers apply their own 

3788 fallback and shape checks (e.g. ``[]`` for a spawn child plan). 

3789 """ 

3790 if isinstance(result, dict): 

3791 return result.get("content") 

3792 return None 

3793 

3794 

3795def _is_failure_envelope(value: Any) -> bool: 

3796 """True when a step output is not a successfully parsed dict. 

3797 

3798 Missing output, non-dict values, and the ``error``/``parse_error`` 

3799 envelopes written by the LLM failure paths are not routed. A valid 

3800 non-error envelope (``ok``/``partial``/``no_success``) is routed even when 

3801 it carries an explicit ``"error": null``. 

3802 """ 

3803 if not isinstance(value, dict): 

3804 return True 

3805 if value.get("status") in _NON_ERROR_ENVELOPE_STATUSES: 

3806 return False 

3807 return bool(_FAILURE_ENVELOPE_KEYS & value.keys()) 

3808 

3809 

3810def _declared_output_channel_writes( 

3811 step: dict, output: Any, channel_names: frozenset[str] 

3812) -> dict[str, Any]: 

3813 """Route one step output's content to its declared output channel. 

3814 

3815 Returns ``{channel_name: output["content"]}`` for the caller to stage into 

3816 the working set. Failure envelopes and steps without an ``output_channel`` 

3817 are not routed; an undeclared target channel is a hard error. 

3818 """ 

3819 if _is_failure_envelope(output): 

3820 return {} 

3821 channel = step.get("output_channel") 

3822 if not channel: 

3823 return {} 

3824 if channel not in channel_names: 

3825 raise RunHardFailure( 

3826 "CHANNEL_BINDING_ERROR", 

3827 f"Step '{step.get('id', '?')}' declares output_channel '{channel}' " 

3828 "which is not declared in spec.channels", 

3829 ) 

3830 return {channel: _envelope_content(output)} 

3831 

3832 

3833def _route_declared_channels( 

3834 step: dict, 

3835 pre_state: dict, 

3836 core: dict, 

3837 step_runner: Any, 

3838 channel_names: frozenset[str], 

3839) -> dict[str, Any]: 

3840 """Route a step's declared output channel write to its channel. 

3841 

3842 Returns ``{channel_name: value}`` for LangGraph to merge through the 

3843 channel's reducer, where ``channel_name`` is the step's single declared 

3844 ``output_channel`` and ``value`` is the agent envelope's ``content``. Only 

3845 an actual agent invocation is routed: a ``skip_llm_if`` passthrough and a 

3846 parallel fan-out envelope are excluded. A step without an ``output_channel`` 

3847 routes nothing (its output stays in ``core`` untouched), and failure 

3848 envelopes are not routed. A spawn step is routed at the spawn seam from its 

3849 node result, never here from its envelope. 

3850 """ 

3851 step_id = step.get("id", "?") 

3852 if step.get("type") == "spawn": 

3853 return {} 

3854 if step_id not in core: 

3855 return {} 

3856 agent_spec = step_runner.resolve_agent(step) 

3857 if agent_spec is None: 

3858 return {} 

3859 if not step_runner.ran_agent(step, pre_state): 

3860 return {} 

3861 return _declared_output_channel_writes(step, core[step_id], channel_names) 

3862 

3863 

3864def _make_step_node( 

3865 step: dict, 

3866 step_runner: Any, 

3867 channel_names: frozenset[str], 

3868 channel_types: dict[str, str], 

3869) -> Any: 

3870 if not channel_names: 

3871 def node_fn(state: PipelineState) -> PipelineState: 

3872 return step_runner.run_step(step, state) 

3873 return node_fn 

3874 

3875 def node_fn(state: PipelineState) -> PipelineState: 

3876 committed = _flatten_channel_state(state) 

3877 working_set = ChannelWorkingSet(committed, channel_types) 

3878 core = _strip_channel_keys( 

3879 step_runner.run_step(step, committed, working_set), channel_names 

3880 ) 

3881 writes = _route_declared_channels( 

3882 step, committed, core, step_runner, channel_names 

3883 ) 

3884 for name, value in writes.items(): 

3885 working_set.stage(name, value) 

3886 written_names = {name for name, _ in working_set.pending_writes()} 

3887 channels = working_set.commit() 

3888 emitted = {name: channels[name] for name in written_names} 

3889 return {_CORE: {**core, **emitted}} 

3890 return node_fn 

3891 

3892 

3893def _make_subgraph_node( 

3894 compiled: Any, 

3895 condition: Any, 

3896 step_runner: Any, 

3897 channel_names: frozenset[str], 

3898) -> Any: 

3899 """Wrap a compiled call subgraph with the call step's entry condition. 

3900 

3901 A false condition returns no state updates, so the subgraph never runs and 

3902 ``state[<call_id>]`` is not written — matching the soft-skip semantics of a 

3903 normal step condition. The ctx is derived exactly like ``run_step``'s, 

3904 including channel-mode flattening. With no condition the compiled subgraph 

3905 is the node, preserving the existing wiring. 

3906 """ 

3907 if condition is None: 

3908 return compiled 

3909 

3910 if not channel_names: 

3911 def node_fn(state: PipelineState) -> PipelineState: 

3912 if not _eval_condition(condition, step_runner._make_ctx(state)): 

3913 return {} 

3914 return compiled.invoke(state) 

3915 return node_fn 

3916 

3917 def node_fn(state: PipelineState) -> PipelineState: 

3918 committed = _flatten_channel_state(state) 

3919 if not _eval_condition(condition, step_runner._make_ctx(committed)): 

3920 return {} 

3921 return compiled.invoke(state) 

3922 return node_fn 

3923 

3924 

3925# --------------------------------------------------------------------------- 

3926# Native `while` cycle nodes (ADR-0025 D1/D2) 

3927# --------------------------------------------------------------------------- 

3928# 

3929# A `while` step is a compiled cycle: `head -> body... -> cond` with a 

3930# conditional back-edge from `cond` to `head` (continue) or `exit`. The head 

3931# owns entry-condition handling and the initial `current`; the cond node owns 

3932# the feed, the iteration increment and the exit-condition evaluation; the exit 

3933# node writes the documented result and enforces `fail_if_exhausted`. The 

3934# nodes read/write the shared graph state, using the `_CORE` container when the 

3935# pipeline declares channels. 

3936 

3937 

3938def _while_marker_key(step_id: str) -> str: 

3939 """Per-while engine marker key (nested whiles live in isolated cycles).""" 

3940 return f"{_WHILE_STATE_KEY}__{step_id}" 

3941 

3942 

3943def _while_flat(state: PipelineState, channel_names: frozenset[str]) -> dict: 

3944 return _flatten_channel_state(state) if channel_names else state 

3945 

3946 

3947def _while_update(update: dict, channel_names: frozenset[str]) -> dict: 

3948 return {_CORE: update} if channel_names else update 

3949 

3950 

3951def _while_loop_context(step_id: str, marker: dict) -> dict: 

3952 """The body-visible `loop` namespace and the intermediate step result. 

3953 

3954 Written in this order so a step literally named ``loop`` shadows the 

3955 injected namespace — the legacy in-node loop had the same collision. 

3956 """ 

3957 current = marker["current"] 

3958 iteration = marker["iteration"] 

3959 update = {"loop": {"current": current, "iteration": iteration}} 

3960 update[step_id] = {"current": current, "passed": False} 

3961 return update 

3962 

3963 

3964def _while_invoke_config(step: dict) -> dict | None: 

3965 """Recursion bound for a while/foreach cycle placeholder, else None.""" 

3966 if step.get(_SUBGRAPH_WHILE_KEY): 

3967 return {"recursion_limit": step.get(_WHILE_RECURSION_LIMIT_KEY, 25)} 

3968 if step.get(_SUBGRAPH_FOREACH_KEY): 

3969 return {"recursion_limit": step.get(_FOREACH_RECURSION_LIMIT_KEY, 25)} 

3970 return None 

3971 

3972 

3973def _make_while_body_node( 

3974 sub: dict, 

3975 step_runner: Any, 

3976 channel_names: frozenset[str], 

3977 channel_types: dict[str, str], 

3978) -> Any: 

3979 """A while body step as a node: a nested subgraph or a normal step node.""" 

3980 if sub.get("type") == _SUBGRAPH_STEP_TYPE: 

3981 if sub.get(_SUBGRAPH_WHILE_KEY): 

3982 return _make_while_subgraph_node(sub, step_runner, channel_names) 

3983 if sub.get(_SUBGRAPH_FOREACH_KEY): 

3984 return _make_foreach_subgraph_node(sub, step_runner, channel_names) 

3985 return _make_subgraph_node( 

3986 sub[_SUBGRAPH_GRAPH_KEY], sub.get("condition"), step_runner, channel_names 

3987 ) 

3988 return _make_step_node(sub, step_runner, channel_names, channel_types) 

3989 

3990 

3991def _make_while_head_node( 

3992 step: dict, 

3993 step_runner: Any, 

3994 channel_names: frozenset[str], 

3995 max_iter: int, 

3996) -> Any: 

3997 """Entry node: handle the entry condition and seed the loop marker.""" 

3998 step_id = step.get("id", "?") 

3999 marker_key = _while_marker_key(step_id) 

4000 

4001 def node_fn(state: PipelineState) -> PipelineState: 

4002 flat = _while_flat(state, channel_names) 

4003 marker = flat.get(marker_key) 

4004 if marker is not None: 

4005 return _while_update( 

4006 _while_loop_context(step_id, marker), channel_names 

4007 ) 

4008 ctx = step_runner._make_ctx(flat) 

4009 entry_cond = step.get("condition") 

4010 if entry_cond is not None and not _eval_condition(entry_cond, ctx): 

4011 skip_input = _resolved_while_input(step, flat, ctx) 

4012 skip_marker = { 

4013 "current": None, 

4014 "iteration": 0, 

4015 "passed": True, 

4016 "skipped": True, 

4017 "max_iter": max_iter, 

4018 } 

4019 return _while_update( 

4020 { 

4021 step_id: { 

4022 "passed": True, 

4023 "iterations": 0, 

4024 "output": skip_input, 

4025 "exhausted": False, 

4026 "skipped": True, 

4027 }, 

4028 marker_key: skip_marker, 

4029 }, 

4030 channel_names, 

4031 ) 

4032 marker = { 

4033 "current": _resolved_while_input(step, flat, ctx), 

4034 "iteration": 0, 

4035 "passed": False, 

4036 "skipped": False, 

4037 "max_iter": max_iter, 

4038 } 

4039 update = _while_loop_context(step_id, marker) 

4040 update[marker_key] = marker 

4041 return _while_update(update, channel_names) 

4042 

4043 return node_fn 

4044 

4045 

4046def _resolved_while_input(step: dict, flat: dict, ctx: dict) -> Any: 

4047 input_spec = step.get("input") 

4048 if input_spec is None: 

4049 return None 

4050 return _resolve_input(input_spec, flat, ctx) 

4051 

4052 

4053def _make_while_head_router( 

4054 step: dict, 

4055 channel_names: frozenset[str], 

4056 first_id: str, 

4057 exit_id: str, 

4058 end: Any, 

4059) -> Any: 

4060 """Route from the head: skipped → end, bound already reached → exit, else body.""" 

4061 marker_key = _while_marker_key(step.get("id", "?")) 

4062 

4063 def router(state: PipelineState) -> str: 

4064 marker = _while_flat(state, channel_names).get(marker_key) or {} 

4065 if marker.get("skipped"): 

4066 return end 

4067 if marker.get("iteration", 0) >= marker.get("max_iter", 0): 

4068 return exit_id 

4069 return first_id 

4070 

4071 return router 

4072 

4073 

4074def _make_while_cond_node( 

4075 step: dict, 

4076 step_runner: Any, 

4077 channel_names: frozenset[str], 

4078) -> Any: 

4079 """Feed the declared output, increment the iteration, evaluate the exit.""" 

4080 step_id = step.get("id", "?") 

4081 marker_key = _while_marker_key(step_id) 

4082 declared_output = step.get("output") 

4083 exit_condition = step.get("exit_condition") 

4084 

4085 def node_fn(state: PipelineState) -> PipelineState: 

4086 flat = _while_flat(state, channel_names) 

4087 marker = dict(flat.get(marker_key) or {}) 

4088 current = marker.get("current") 

4089 if declared_output: 

4090 feed_value = flat.get(declared_output) 

4091 if isinstance(feed_value, dict) and "output" in feed_value: 

4092 current = feed_value["output"] 

4093 elif isinstance(feed_value, dict) and isinstance( 

4094 feed_value.get("results"), list 

4095 ): 

4096 current = feed_value["results"] 

4097 else: 

4098 current = feed_value 

4099 passed = False 

4100 if exit_condition: 

4101 if _eval_condition( 

4102 exit_condition, step_runner._make_ctx(flat) 

4103 ): 

4104 passed = True 

4105 else: 

4106 passed = True 

4107 marker.update( 

4108 { 

4109 "current": current, 

4110 "iteration": marker.get("iteration", 0) + 1, 

4111 "passed": passed, 

4112 } 

4113 ) 

4114 return _while_update({marker_key: marker}, channel_names) 

4115 

4116 return node_fn 

4117 

4118 

4119def _make_while_cond_router( 

4120 step: dict, 

4121 channel_names: frozenset[str], 

4122 head_id: str, 

4123 exit_id: str, 

4124) -> Any: 

4125 """Route from the cond: exit on pass or when the bound is reached, else head.""" 

4126 marker_key = _while_marker_key(step.get("id", "?")) 

4127 

4128 def router(state: PipelineState) -> str: 

4129 marker = _while_flat(state, channel_names).get(marker_key) or {} 

4130 if marker.get("passed"): 

4131 return exit_id 

4132 if marker.get("iteration", 0) >= marker.get("max_iter", 0): 

4133 return exit_id 

4134 return head_id 

4135 

4136 return router 

4137 

4138 

4139def _make_while_exit_node( 

4140 step: dict, 

4141 step_runner: Any, 

4142 channel_names: frozenset[str], 

4143) -> Any: 

4144 """Write the documented while result and enforce `fail_if_exhausted`.""" 

4145 step_id = step.get("id", "?") 

4146 marker_key = _while_marker_key(step_id) 

4147 fail_if_exhausted = step.get("fail_if_exhausted") 

4148 

4149 def node_fn(state: PipelineState) -> PipelineState: 

4150 flat = _while_flat(state, channel_names) 

4151 marker = flat.get(marker_key) or {} 

4152 passed = bool(marker.get("passed", False)) 

4153 exhausted = not passed 

4154 if ( 

4155 fail_if_exhausted is not None 

4156 and exhausted 

4157 and _eval_condition( 

4158 fail_if_exhausted, step_runner._make_ctx(flat) 

4159 ) 

4160 ): 

4161 raise RunHardFailure( 

4162 "EXHAUSTED", 

4163 f"while step '{step_id}' exhausted " 

4164 f"max_iterations={marker.get('max_iter')} without " 

4165 "exit_condition passing (fail_if_exhausted)", 

4166 {"step_id": step_id, "exhausted": True}, 

4167 ) 

4168 return _while_update( 

4169 { 

4170 step_id: { 

4171 "passed": passed, 

4172 "iterations": marker.get("iteration", 0), 

4173 "output": marker.get("current"), 

4174 "exhausted": exhausted, 

4175 "skipped": False, 

4176 } 

4177 }, 

4178 channel_names, 

4179 ) 

4180 

4181 return node_fn 

4182 

4183 

4184def _make_while_subgraph_node( 

4185 placeholder: dict, 

4186 step_runner: Any, 

4187 channel_names: frozenset[str], 

4188) -> Any: 

4189 """Parent node for a compiled `while` cycle: isolate and surface its result. 

4190 

4191 The cycle shares the graph state schema, so invoking it directly would leak 

4192 the body step results and loop plumbing into the parent. This wrapper runs 

4193 the cycle on a copy and returns only the documented result plus the side 

4194 channels, preserving the in-node loop's state contract exactly. 

4195 """ 

4196 compiled = placeholder[_SUBGRAPH_GRAPH_KEY] 

4197 step_id = placeholder["id"] 

4198 config = {"recursion_limit": placeholder.get(_WHILE_RECURSION_LIMIT_KEY, 25)} 

4199 

4200 if not channel_names: 

4201 def node_fn(state: PipelineState) -> PipelineState: 

4202 sub = compiled.invoke(state, config) 

4203 result = {step_id: sub.get(step_id)} 

4204 for key in _WHILE_SIDE_CHANNEL_KEYS: 

4205 if key in sub and sub.get(key) != state.get(key): 

4206 result[key] = sub.get(key) 

4207 return result 

4208 return node_fn 

4209 

4210 def node_fn(state: PipelineState) -> PipelineState: 

4211 sub = compiled.invoke(state, config) 

4212 flat = _flatten_channel_state(sub) 

4213 parent_flat = _flatten_channel_state(state) 

4214 result = {step_id: flat.get(step_id)} 

4215 for key in _WHILE_SIDE_CHANNEL_KEYS: 

4216 if key in flat and flat.get(key) != parent_flat.get(key): 

4217 result[key] = flat.get(key) 

4218 return {_CORE: _strip_channel_keys(result, channel_names)} 

4219 

4220 return node_fn 

4221 

4222 

4223# --------------------------------------------------------------------------- 

4224# Native sequential `foreach` cycle nodes 

4225# --------------------------------------------------------------------------- 

4226# 

4227# A sequential `foreach` is a compiled cycle: `head -> (body... | agent) -> 

4228# cond` with a conditional back-edge from `cond` to `head` (next item) or 

4229# `exit` (`max_output` reached or the item list exhausted). The head resolves 

4230# the item list once and injects the current item (`item` / `_item` / 

4231# `_foreach_item`); the cond collects the item result; the exit node writes the 

4232# documented aggregate (`{"results", "passed", "failing", "failed"}` for the 

4233# body path, the plain list for the agent path). Nodes read/write the shared 

4234# graph state, using the `_CORE` container when the pipeline declares channels. 

4235 

4236 

4237def _foreach_marker_key(step_id: str) -> str: 

4238 """Per-foreach engine marker key (nested foreachs live in isolated cycles).""" 

4239 return f"{_FOREACH_STATE_KEY}__{step_id}" 

4240 

4241 

4242def _foreach_flat(state: PipelineState, channel_names: frozenset[str]) -> dict: 

4243 return _flatten_channel_state(state) if channel_names else state 

4244 

4245 

4246def _foreach_update(update: dict, channel_names: frozenset[str]) -> dict: 

4247 return {_CORE: update} if channel_names else update 

4248 

4249 

4250def _make_foreach_head_node( 

4251 step: dict, 

4252 step_runner: Any, 

4253 channel_names: frozenset[str], 

4254) -> Any: 

4255 """Entry node: evaluate the entry condition, resolve items, inject the item.""" 

4256 step_id = step.get("id", "?") 

4257 marker_key = _foreach_marker_key(step_id) 

4258 

4259 def node_fn(state: PipelineState) -> PipelineState: 

4260 flat = _foreach_flat(state, channel_names) 

4261 marker = flat.get(marker_key) 

4262 if marker is None: 

4263 ctx = step_runner._make_ctx(flat) 

4264 entry_cond = step.get("condition") 

4265 if entry_cond is not None and not _eval_condition(entry_cond, ctx): 

4266 return _foreach_update({}, channel_names) 

4267 max_output_raw = step.get("max_output") 

4268 marker = { 

4269 "items": step_runner._foreach_item_list(step, flat, ctx), 

4270 "index": 0, 

4271 "results": [], 

4272 "produced_outputs": 0, 

4273 "max_output": ( 

4274 _resolve_int(max_output_raw, ctx, 0) 

4275 if max_output_raw is not None 

4276 else 0 

4277 ), 

4278 } 

4279 items = marker.get("items") or [] 

4280 index = marker.get("index", 0) 

4281 if index < len(items): 

4282 update = { 

4283 "_item": items[index], 

4284 "item": items[index], 

4285 "_foreach_item": items[index], 

4286 } 

4287 update[marker_key] = marker 

4288 return _foreach_update(update, channel_names) 

4289 return _foreach_update({marker_key: marker}, channel_names) 

4290 

4291 return node_fn 

4292 

4293 

4294def _make_foreach_head_router( 

4295 step: dict, 

4296 channel_names: frozenset[str], 

4297 first_id: str, 

4298 exit_id: str, 

4299 end: Any, 

4300) -> Any: 

4301 """Route from the head: condition-skipped → end, exhausted/budget → exit, else body.""" 

4302 marker_key = _foreach_marker_key(step.get("id", "?")) 

4303 

4304 def router(state: PipelineState) -> str: 

4305 marker = _foreach_flat(state, channel_names).get(marker_key) 

4306 if marker is None: 

4307 return end 

4308 items = marker.get("items") or [] 

4309 if marker.get("index", 0) >= len(items): 

4310 return exit_id 

4311 max_output = marker.get("max_output", 0) 

4312 if max_output and marker.get("produced_outputs", 0) >= max_output: 

4313 return exit_id 

4314 return first_id 

4315 

4316 return router 

4317 

4318 

4319def _make_foreach_body_node( 

4320 sub: dict, 

4321 step_runner: Any, 

4322 channel_names: frozenset[str], 

4323 channel_types: dict[str, str], 

4324) -> Any: 

4325 """A foreach body step as a node: a nested loop subgraph or a step node.""" 

4326 if sub.get("type") == _SUBGRAPH_STEP_TYPE: 

4327 if sub.get(_SUBGRAPH_WHILE_KEY): 

4328 return _make_while_subgraph_node(sub, step_runner, channel_names) 

4329 if sub.get(_SUBGRAPH_FOREACH_KEY): 

4330 return _make_foreach_subgraph_node(sub, step_runner, channel_names) 

4331 return _make_subgraph_node( 

4332 sub[_SUBGRAPH_GRAPH_KEY], sub.get("condition"), step_runner, channel_names 

4333 ) 

4334 return _make_step_node(sub, step_runner, channel_names, channel_types) 

4335 

4336 

4337def _make_foreach_agent_node( 

4338 step: dict, 

4339 step_runner: Any, 

4340 channel_names: frozenset[str], 

4341 channel_types: dict[str, str], 

4342) -> Any: 

4343 """Agent-path node: invoke the foreach step's agent for the current item.""" 

4344 step_id = step.get("id", "?") 

4345 

4346 def node_fn(state: PipelineState) -> PipelineState: 

4347 flat = _foreach_flat(state, channel_names) 

4348 resolved = step_runner.resolve_agent(step) 

4349 ctx = step_runner._make_ctx(flat) 

4350 resolved_step = step_runner._resolve_step_runtime(step, flat, ctx) 

4351 resolved_step = step_runner._attach_communication_context(resolved_step, flat) 

4352 resolved_step = step_runner._attach_resolved_tools(resolved_step, resolved) 

4353 result_state = step_runner._invoke_agent(resolved, resolved_step, flat) 

4354 if not channel_names: 

4355 return result_state 

4356 return {_CORE: _strip_channel_keys(result_state, channel_names)} 

4357 

4358 return node_fn 

4359 

4360 

4361def _make_foreach_cond_node( 

4362 step: dict, 

4363 step_runner: Any, 

4364 channel_names: frozenset[str], 

4365) -> Any: 

4366 """Collect the current item's result and advance the item index.""" 

4367 step_id = step.get("id", "?") 

4368 marker_key = _foreach_marker_key(step_id) 

4369 declared_output = step.get("output") 

4370 runs_body = bool(step.get("body")) 

4371 

4372 def node_fn(state: PipelineState) -> PipelineState: 

4373 flat = _foreach_flat(state, channel_names) 

4374 marker = dict(flat.get(marker_key) or {}) 

4375 results = list(marker.get("results") or []) 

4376 produced = marker.get("produced_outputs", 0) 

4377 if runs_body: 

4378 results.append(StepRunner._body_item_result(flat, declared_output)) 

4379 publish_out = flat.get("publish") 

4380 if isinstance(publish_out, dict) and publish_out.get("path"): 

4381 produced += 1 

4382 else: 

4383 results.append(flat.get(step_id)) 

4384 marker.update( 

4385 { 

4386 "results": results, 

4387 "index": marker.get("index", 0) + 1, 

4388 "produced_outputs": produced, 

4389 } 

4390 ) 

4391 return _foreach_update({marker_key: marker}, channel_names) 

4392 

4393 return node_fn 

4394 

4395 

4396def _make_foreach_cond_router( 

4397 step: dict, 

4398 channel_names: frozenset[str], 

4399 head_id: str, 

4400 exit_id: str, 

4401) -> Any: 

4402 """Route from the cond: exit on exhaustion or budget, else back to the head.""" 

4403 marker_key = _foreach_marker_key(step.get("id", "?")) 

4404 

4405 def router(state: PipelineState) -> str: 

4406 marker = _foreach_flat(state, channel_names).get(marker_key) or {} 

4407 items = marker.get("items") or [] 

4408 if marker.get("index", 0) >= len(items): 

4409 return exit_id 

4410 max_output = marker.get("max_output", 0) 

4411 if max_output and marker.get("produced_outputs", 0) >= max_output: 

4412 return exit_id 

4413 return head_id 

4414 

4415 return router 

4416 

4417 

4418def _make_foreach_exit_node( 

4419 step: dict, 

4420 step_runner: Any, 

4421 channel_names: frozenset[str], 

4422) -> Any: 

4423 """Write the documented foreach aggregate result.""" 

4424 step_id = step.get("id", "?") 

4425 marker_key = _foreach_marker_key(step_id) 

4426 runs_body = bool(step.get("body")) 

4427 

4428 def node_fn(state: PipelineState) -> PipelineState: 

4429 flat = _foreach_flat(state, channel_names) 

4430 marker = flat.get(marker_key) or {} 

4431 results = list(marker.get("results") or []) 

4432 step_result = ( 

4433 StepRunner._aggregate_body_results(results) if runs_body else results 

4434 ) 

4435 return _foreach_update({step_id: step_result}, channel_names) 

4436 

4437 return node_fn 

4438 

4439 

4440def _make_foreach_subgraph_node( 

4441 placeholder: dict, 

4442 step_runner: Any, 

4443 channel_names: frozenset[str], 

4444) -> Any: 

4445 """Parent node for a compiled sequential `foreach` cycle: isolate and surface. 

4446 

4447 The cycle shares the graph state schema, so invoking it directly would leak 

4448 body step results and loop plumbing into the parent. This wrapper runs the 

4449 cycle on a copy and returns only the documented aggregate plus the side 

4450 channels. A condition-skipped entry writes no step result (matching the 

4451 in-node soft-skip), so the step id is only surfaced when the cycle wrote it. 

4452 """ 

4453 compiled = placeholder[_SUBGRAPH_GRAPH_KEY] 

4454 step_id = placeholder["id"] 

4455 config = {"recursion_limit": placeholder.get(_FOREACH_RECURSION_LIMIT_KEY, 25)} 

4456 

4457 if not channel_names: 

4458 def node_fn(state: PipelineState) -> PipelineState: 

4459 sub = compiled.invoke(state, config) 

4460 if step_id not in sub: 

4461 return {} 

4462 result = {step_id: sub.get(step_id)} 

4463 for key in _FOREACH_SIDE_CHANNEL_KEYS: 

4464 if key in sub and sub.get(key) != state.get(key): 

4465 result[key] = sub.get(key) 

4466 return result 

4467 return node_fn 

4468 

4469 def node_fn(state: PipelineState) -> PipelineState: 

4470 sub = compiled.invoke(state, config) 

4471 flat = _flatten_channel_state(sub) 

4472 parent_flat = _flatten_channel_state(state) 

4473 if step_id not in flat: 

4474 return {} 

4475 result = {step_id: flat.get(step_id)} 

4476 for key in _FOREACH_SIDE_CHANNEL_KEYS: 

4477 if key in flat and flat.get(key) != parent_flat.get(key): 

4478 result[key] = flat.get(key) 

4479 return {_CORE: _strip_channel_keys(result, channel_names)} 

4480 

4481 return node_fn 

4482 

4483 

4484def _collect_parallel_channel_writes( 

4485 steps: list[dict], 

4486 pre_state: dict, 

4487 core: dict, 

4488 step_runner: Any, 

4489 channel_names: frozenset[str], 

4490 branch_working_sets: dict[str, ChannelWorkingSet] | None = None, 

4491) -> list[tuple[str, Any]]: 

4492 """Collect one parallel node's declared writes in branch order. 

4493 

4494 Every branch write is emitted, so fan-in feeds the channel-type reducer 

4495 every write in deterministic declaration/branch order: Topic appends each 

4496 write, LastValue keeps the last, and BinaryOperatorAggregate combines them 

4497 per its accumulated shape (list concat, collection-map union, dict merge, 

4498 scalar last). A branch's isolated body writes are emitted before its own 

4499 declared write, mirroring the top-level node's route-after-run order. 

4500 """ 

4501 writes: list[tuple[str, Any]] = [] 

4502 for step in steps: 

4503 if branch_working_sets is not None: 

4504 branch_working_set = branch_working_sets.get(step["id"]) 

4505 if branch_working_set is not None: 

4506 writes.extend(branch_working_set.pending_writes()) 

4507 branch = _route_declared_channels( 

4508 step, pre_state, core, step_runner, channel_names 

4509 ) 

4510 for channel_name, value in branch.items(): 

4511 writes.append((channel_name, value)) 

4512 return writes 

4513 

4514 

4515def _make_parallel_node( 

4516 group_steps: list[dict], 

4517 step_runner: Any, 

4518 channel_names: frozenset[str], 

4519 channel_types: dict[str, str], 

4520) -> Any: 

4521 def node_fn(state: PipelineState) -> PipelineState: 

4522 base = ( 

4523 _flatten_channel_state(state) 

4524 if channel_names else state 

4525 ) 

4526 working_set = ( 

4527 ChannelWorkingSet(base, channel_types) if channel_names else None 

4528 ) 

4529 # Each branch writes into its own fork; the shared set is advanced only 

4530 # at fan-in, so branch-internal body writes never race. 

4531 branch_working_sets = ( 

4532 {s["id"]: working_set.fork() for s in group_steps} 

4533 if working_set is not None else None 

4534 ) 

4535 

4536 def submit_branch(pool: Any, step: dict) -> Any: 

4537 if branch_working_sets is None: 

4538 return pool.submit(step_runner.run_step, step, base) 

4539 return pool.submit( 

4540 step_runner.run_step, step, base, branch_working_sets[step["id"]] 

4541 ) 

4542 

4543 results: dict[str, Any] = {} 

4544 branch_metrics: dict = {} 

4545 branch_tool_meta: dict = {} 

4546 with ThreadPoolExecutor(max_workers=len(group_steps)) as pool: 

4547 futures = {submit_branch(pool, s): s["id"] for s in group_steps} 

4548 for future in as_completed(futures): 

4549 sid = futures[future] 

4550 try: 

4551 out = future.result() 

4552 results[sid] = out.get(sid) 

4553 branch_metrics.update(out.get("step_context_metrics") or {}) 

4554 branch_tool_meta.update(out.get("step_tool_meta") or {}) 

4555 except SkillLoadError: 

4556 raise 

4557 except RunHardFailure: 

4558 pool.shutdown(wait=False, cancel_futures=True) 

4559 raise 

4560 except Exception as exc: # noqa: BLE001 

4561 log.error("Step '%s' failed: %s", sid, exc) 

4562 results[sid] = {"error": str(exc)} 

4563 if channel_names: 

4564 merged = _strip_channel_keys({**base, **results}, channel_names) 

4565 if branch_metrics: 

4566 merged["step_context_metrics"] = _merge_side_channel( 

4567 merged.get("step_context_metrics"), branch_metrics 

4568 ) 

4569 if branch_tool_meta: 

4570 merged["step_tool_meta"] = _merge_side_channel( 

4571 merged.get("step_tool_meta"), branch_tool_meta 

4572 ) 

4573 writes = _collect_parallel_channel_writes( 

4574 group_steps, base, merged, step_runner, channel_names, 

4575 branch_working_sets, 

4576 ) 

4577 written_names = {name for name, _ in writes} 

4578 for name, value in writes: 

4579 working_set.stage(name, value) 

4580 channels = working_set.commit() 

4581 emitted = {name: channels[name] for name in written_names} 

4582 return {_CORE: {**merged, **emitted}} 

4583 final: PipelineState = {**state, **results} 

4584 if branch_metrics: 

4585 final = { 

4586 **final, 

4587 "step_context_metrics": _merge_side_channel( 

4588 state.get("step_context_metrics"), branch_metrics 

4589 ), 

4590 } 

4591 if branch_tool_meta: 

4592 final = { 

4593 **final, 

4594 "step_tool_meta": _merge_side_channel( 

4595 state.get("step_tool_meta"), branch_tool_meta 

4596 ), 

4597 } 

4598 return final 

4599 return node_fn 

4600 

4601 

4602@dataclass(frozen=True) 

4603class _MapReduceParts: 

4604 """The nodes and edges of one concurrent step's Send fan-out. 

4605 

4606 ``dispatch_id`` reuses the step id so the step's incoming dependency edges are 

4607 unchanged; ``join_id`` carries the step's outgoing edges. 

4608 """ 

4609 

4610 dispatch_id: str 

4611 worker_id: str 

4612 fallback_id: str 

4613 join_id: str 

4614 dispatch_fn: Any 

4615 route_fn: Any 

4616 worker_fn: Any 

4617 join_fn: Any 

4618 

4619 

4620@dataclass(frozen=True) 

4621class _MapReduceItemOutcome: 

4622 """One worker's per-item result plus its errors, side channels and writes. 

4623 

4624 ``errors`` carries the item's soft errors (and the inherited soft errors it 

4625 read), so the join can fold them in item order exactly like the in-node 

4626 concurrent path. ``channel_writes`` is empty for the parallel strategy (its 

4627 channel writes are derived from the item result at the join) and carries the 

4628 foreach body's pending writes so the join can stage them in item order. 

4629 """ 

4630 

4631 result: Any 

4632 metrics: dict | None 

4633 tool_meta: dict | None 

4634 channel_writes: tuple[tuple[str, Any], ...] 

4635 errors: tuple[str, ...] = () 

4636 

4637 

4638@dataclass(frozen=True) 

4639class _MapReducePlan: 

4640 """The per-step behaviour injected into the shared Send machinery. 

4641 

4642 The parallel strategy runs a single agent call per item; the foreach 

4643 strategy runs the body (or agent) per item. ``item_lister`` resolves the 

4644 fan-out list, ``item_runner`` executes one item, ``result_assembler`` 

4645 shapes the step result, and ``channel_write_stager`` stages per-item 

4646 channel writes at the join. 

4647 """ 

4648 

4649 item_lister: Any 

4650 item_runner: Any 

4651 result_assembler: Any 

4652 channel_write_stager: Any 

4653 

4654 

4655def _map_reduce_worker_payload(flat: dict, item: Any, index: int) -> dict: 

4656 """Build a worker's ``Send`` payload from the resolved source state.""" 

4657 return {**flat, "item": item, "_item": item, _MAP_REDUCE_ITEM_INDEX: index} 

4658 

4659 

4660def _map_reduce_worker_output( 

4661 step_id: str, 

4662 index: int, 

4663 outcome: _MapReduceItemOutcome, 

4664) -> PipelineState: 

4665 """The accumulator record carrying every per-item fold input. 

4666 

4667 The slot is ``{index: {"result", "writes", "errors", "metrics", 

4668 "tool_meta"}}`` so the join can assemble the step result, stage channel 

4669 writes, and fold soft errors plus side channels in item order — the same 

4670 observable result as the in-node concurrent path. 

4671 """ 

4672 record = { 

4673 "result": outcome.result, 

4674 "writes": list(outcome.channel_writes), 

4675 "errors": list(outcome.errors), 

4676 "metrics": outcome.metrics if isinstance(outcome.metrics, dict) else None, 

4677 "tool_meta": ( 

4678 outcome.tool_meta if isinstance(outcome.tool_meta, dict) else None 

4679 ), 

4680 } 

4681 return {_MAP_REDUCE_ACCUMULATOR_KEY: {step_id: {index: record}}} 

4682 

4683 

4684def _make_map_reduce_route_fn( 

4685 plan: _MapReducePlan, 

4686 step_runner: Any, 

4687 channel_names: frozenset[str], 

4688 worker_id: str, 

4689 fallback_id: str, 

4690 join_id: str, 

4691) -> Any: 

4692 def route(state: PipelineState) -> list: 

4693 flat = _flatten_channel_state(state) if channel_names else state 

4694 ctx = step_runner._make_ctx(flat) 

4695 items = plan.item_lister(flat, ctx) 

4696 if items is None: 

4697 return [Send(fallback_id, state)] 

4698 if not items: 

4699 return [Send(join_id, state)] 

4700 return [ 

4701 Send(worker_id, _map_reduce_worker_payload(flat, item, index)) 

4702 for index, item in enumerate(items) 

4703 ] 

4704 

4705 return route 

4706 

4707 

4708def _make_map_reduce_worker_fn( 

4709 step: dict, 

4710 plan: _MapReducePlan, 

4711 step_runner: Any, 

4712 gate: threading.Semaphore, 

4713) -> Any: 

4714 step_id = step.get("id", "?") 

4715 

4716 def worker(state: PipelineState) -> PipelineState: 

4717 index = state[_MAP_REDUCE_ITEM_INDEX] 

4718 item_state = { 

4719 key: value 

4720 for key, value in state.items() 

4721 if key != _MAP_REDUCE_ITEM_INDEX 

4722 } 

4723 item = item_state.get("item") 

4724 item_ctx = step_runner._make_ctx(item_state) 

4725 with gate: 

4726 try: 

4727 outcome = plan.item_runner( 

4728 state, index, item, item_state, item_ctx 

4729 ) 

4730 except SkillLoadError: 

4731 raise 

4732 except RunHardFailure: 

4733 raise 

4734 except Exception as exc: # noqa: BLE001 

4735 log.error("[step] %s item failed: %s", step_id, exc) 

4736 outcome = _MapReduceItemOutcome( 

4737 {"error": str(exc)}, None, None, () 

4738 ) 

4739 return _map_reduce_worker_output(step_id, index, outcome) 

4740 

4741 return worker 

4742 

4743 

4744def _fold_map_reduce_errors(records: list, base_errors: list) -> list: 

4745 """Fold every item's soft errors into *base_errors* in item order.""" 

4746 merged = list(base_errors) 

4747 for record in records: 

4748 if not isinstance(record, dict): 

4749 continue 

4750 merged = StepRunner._merge_errors(merged, record.get("errors") or []) 

4751 return merged 

4752 

4753 

4754def _fold_map_reduce_side_channel(records: list, record_key: str) -> dict: 

4755 """Fold every item's side-channel map in item order (later item wins).""" 

4756 folded: dict = {} 

4757 for record in records: 

4758 if not isinstance(record, dict): 

4759 continue 

4760 value = record.get(record_key) 

4761 if isinstance(value, dict): 

4762 folded.update(value) 

4763 return folded 

4764 

4765 

4766def _fold_map_reduce_extras(flat: dict, records: list) -> dict: 

4767 """The join's per-item state extras: soft errors plus folded side channels. 

4768 

4769 Mirrors the in-node concurrent path: errors are merged in item order with 

4770 dedup, and the budget/side-channel counters follow the same item order. 

4771 """ 

4772 extras: dict = {} 

4773 base_errors = list(flat.get("errors") or []) 

4774 errors = _fold_map_reduce_errors(records, base_errors) 

4775 if errors != base_errors: 

4776 extras["errors"] = errors 

4777 for state_key, record_key in ( 

4778 ("step_context_metrics", "metrics"), 

4779 ("step_tool_meta", "tool_meta"), 

4780 ): 

4781 folded = _fold_map_reduce_side_channel(records, record_key) 

4782 if folded: 

4783 extras[state_key] = _merge_side_channel(flat.get(state_key), folded) 

4784 return extras 

4785 

4786 

4787def _make_map_reduce_join_fn( 

4788 step: dict, 

4789 plan: _MapReducePlan, 

4790 step_runner: Any, 

4791 channel_names: frozenset[str], 

4792 channel_types: dict[str, str], 

4793) -> Any: 

4794 step_id = step.get("id", "?") 

4795 

4796 def join(state: PipelineState) -> PipelineState: 

4797 flat = _flatten_channel_state(state) if channel_names else state 

4798 accumulated = state.get(_MAP_REDUCE_ACCUMULATOR_KEY) or {} 

4799 entries = accumulated.get(step_id) or {} 

4800 clear = {_MAP_REDUCE_ACCUMULATOR_KEY: {step_id: None}} 

4801 ctx = step_runner._make_ctx(flat) 

4802 items = plan.item_lister(flat, ctx) 

4803 if items is None: 

4804 return clear 

4805 records = [entries.get(index) for index in range(len(items))] 

4806 results = [ 

4807 record.get("result") if isinstance(record, dict) else None 

4808 for record in records 

4809 ] 

4810 step_result = plan.result_assembler(results) 

4811 extras = _fold_map_reduce_extras(flat, records) 

4812 if not channel_names: 

4813 return {step_id: step_result, **extras, **clear} 

4814 working_set = ChannelWorkingSet(flat, channel_types) 

4815 plan.channel_write_stager(results, records, working_set) 

4816 written_names = {name for name, _ in working_set.pending_writes()} 

4817 channels = working_set.commit() 

4818 emitted = {name: channels[name] for name in written_names} 

4819 core = _strip_channel_keys( 

4820 {**flat, step_id: step_result, **extras}, channel_names 

4821 ) 

4822 return {_CORE: {**core, **emitted}, **clear} 

4823 

4824 return join 

4825 

4826 

4827def _make_parallel_map_reduce_plan( 

4828 step: dict, step_runner: Any 

4829) -> _MapReducePlan: 

4830 """Plan for a concurrent `parallel` step: one agent call per item.""" 

4831 resolved_agent = step_runner.resolve_agent(step) 

4832 

4833 def run_item(state, index, item, item_state, item_ctx): 

4834 result, metrics, tool_meta, errors = step_runner._invoke_parallel_item( 

4835 step, resolved_agent, item, item_state, item_ctx 

4836 ) 

4837 return _MapReduceItemOutcome( 

4838 result, metrics, tool_meta, (), tuple(errors) 

4839 ) 

4840 

4841 return _MapReducePlan( 

4842 item_lister=lambda flat, ctx: step_runner._parallel_fanout_item_list( 

4843 step, flat, ctx 

4844 ), 

4845 item_runner=run_item, 

4846 result_assembler=lambda results: {"results": results}, 

4847 channel_write_stager=lambda results, records, working_set: ( 

4848 step_runner._stage_fanout_channel_writes(step, results, working_set) 

4849 ), 

4850 ) 

4851 

4852 

4853def _worker_committed_flat( 

4854 state: PipelineState, channel_names: frozenset[str] 

4855) -> dict: 

4856 """The committed channel snapshot visible to one worker. 

4857 

4858 A channels-path worker receives the flattened snapshot as its payload, so 

4859 its channel keys sit at the top level; the typed ``__core__`` shape (only 

4860 seen by the join) is unwrapped. 

4861 """ 

4862 if _CORE in state: 

4863 return _flatten_channel_state(state) 

4864 return {name: state[name] for name in channel_names if name in state} 

4865 

4866 

4867def _pending_channel_writes( 

4868 working_set: ChannelWorkingSet | None, 

4869) -> tuple[tuple[str, Any], ...]: 

4870 if working_set is None: 

4871 return () 

4872 return tuple(working_set.pending_writes()) 

4873 

4874 

4875def _stage_map_reduce_item_channel_writes( 

4876 results: list, records: list, working_set: ChannelWorkingSet 

4877) -> None: 

4878 """Stage each item's carried channel writes in item order.""" 

4879 for record in records: 

4880 if not isinstance(record, dict): 

4881 continue 

4882 for name, value in record.get("writes") or (): 

4883 working_set.stage(name, value) 

4884 

4885 

4886def _make_foreach_map_reduce_plan( 

4887 step: dict, 

4888 step_runner: Any, 

4889 channel_names: frozenset[str], 

4890 channel_types: dict[str, str], 

4891) -> _MapReducePlan: 

4892 """Plan for a concurrent `foreach` step: the body (or agent) runs per item.""" 

4893 step_id = step.get("id", "?") 

4894 body = step.get("body") or [] 

4895 declared_output = step.get("output") 

4896 runs_body = bool(body) 

4897 

4898 def result_assembler(results): 

4899 if runs_body: 

4900 return StepRunner._aggregate_body_results(results) 

4901 return results 

4902 

4903 def run_item(state, index, item, item_state, _item_ctx): 

4904 item_working_set = ( 

4905 ChannelWorkingSet( 

4906 _worker_committed_flat(state, channel_names), channel_types 

4907 ) 

4908 if channel_names 

4909 else None 

4910 ) 

4911 iter_state = { 

4912 **item_state, "item": item, "_item": item, "_foreach_item": item 

4913 } 

4914 if runs_body: 

4915 for sub_step in body: 

4916 iter_state = step_runner._run_body_step( 

4917 sub_step, iter_state, item_working_set 

4918 ) 

4919 return _MapReduceItemOutcome( 

4920 step_runner._body_item_result(iter_state, declared_output), 

4921 iter_state.get("step_context_metrics"), 

4922 iter_state.get("step_tool_meta"), 

4923 _pending_channel_writes(item_working_set), 

4924 tuple(iter_state.get("errors") or []), 

4925 ) 

4926 resolved = step_runner.resolve_agent(step) 

4927 item_read_state = step_runner._read_state(iter_state, item_working_set) 

4928 item_ctx = step_runner._make_ctx(item_read_state) 

4929 resolved_step = step_runner._resolve_step_runtime( 

4930 step, item_read_state, item_ctx 

4931 ) 

4932 resolved_step = step_runner._attach_communication_context( 

4933 resolved_step, item_read_state 

4934 ) 

4935 resolved_step = step_runner._attach_resolved_tools( 

4936 resolved_step, resolved 

4937 ) 

4938 result_state = step_runner._invoke_agent( 

4939 resolved, resolved_step, iter_state 

4940 ) 

4941 return _MapReduceItemOutcome( 

4942 result_state.get(step_id), 

4943 result_state.get("step_context_metrics"), 

4944 result_state.get("step_tool_meta"), 

4945 _pending_channel_writes(item_working_set), 

4946 tuple(result_state.get("errors") or []), 

4947 ) 

4948 

4949 return _MapReducePlan( 

4950 item_lister=lambda flat, ctx: step_runner._foreach_item_list( 

4951 step, flat, ctx 

4952 ), 

4953 item_runner=run_item, 

4954 result_assembler=result_assembler, 

4955 channel_write_stager=_stage_map_reduce_item_channel_writes, 

4956 ) 

4957 

4958 

4959def _make_map_reduce_parts( 

4960 step: dict, 

4961 plan: _MapReducePlan, 

4962 step_runner: Any, 

4963 channel_names: frozenset[str], 

4964 channel_types: dict[str, str], 

4965 max_concurrent: int, 

4966) -> _MapReduceParts: 

4967 step_id = step.get("id", "?") 

4968 worker_id = f"{step_id}__worker" 

4969 fallback_id = f"{step_id}__fallback" 

4970 join_id = f"{step_id}__join" 

4971 gate = threading.Semaphore(max(1, max_concurrent)) 

4972 return _MapReduceParts( 

4973 dispatch_id=step_id, 

4974 worker_id=worker_id, 

4975 fallback_id=fallback_id, 

4976 join_id=join_id, 

4977 dispatch_fn=lambda state: {}, 

4978 route_fn=_make_map_reduce_route_fn( 

4979 plan, step_runner, channel_names, worker_id, fallback_id, join_id 

4980 ), 

4981 worker_fn=_make_map_reduce_worker_fn(step, plan, step_runner, gate), 

4982 join_fn=_make_map_reduce_join_fn( 

4983 step, plan, step_runner, channel_names, channel_types 

4984 ), 

4985 ) 

4986 

4987 

4988def _resolved_send_concurrency(step: dict, build_ctx: dict) -> int: 

4989 """The build-time resolved ``max_concurrent`` for a Send-eligible step.""" 

4990 return _resolve_int(step.get("max_concurrent"), build_ctx, 1) 

4991 

4992 

4993def _is_send_parallel_step( 

4994 step: dict, route_plan: "_RoutePlan", build_ctx: dict 

4995) -> bool: 

4996 """Whether a top-level `parallel` step fans out through native ``Send``. 

4997 

4998 Restricted to a singleton group's statically concurrent, agent-backed, 

4999 item-sourced step with no `routes`, no `condition`/`skip_llm_if`, and an 

5000 explicit `max_concurrent > 1`; everything else keeps the in-node path. 

5001 """ 

5002 if step.get("type") != "parallel": 

5003 return False 

5004 if step.get("id") in route_plan.target_node_ids: 

5005 return False 

5006 if step.get("routes") or not step.get("agent"): 

5007 return False 

5008 if step.get("condition") is not None or step.get("skip_llm_if") is not None: 

5009 return False 

5010 if StepRunner._declared_item_source(step) is None: 

5011 return False 

5012 if step.get("max_concurrent") is None: 

5013 return False 

5014 return _resolved_send_concurrency(step, build_ctx) > 1 

5015 

5016 

5017def _is_send_foreach_step( 

5018 step: dict, route_plan: "_RoutePlan", build_ctx: dict 

5019) -> bool: 

5020 """Whether a top-level `foreach` step fans out through native ``Send``. 

5021 

5022 Restricted to a singleton group's body- or agent-backed, item-sourced step 

5023 with no `routes`, no `condition`/`skip_llm_if`, no step-level 

5024 `output_channel`, and no `max_output`; an explicit `max_concurrent > 1` is 

5025 required. The default (absent or ``1``) keeps the sequential in-node path, 

5026 and the excluded edges keep their existing in-node semantics. 

5027 """ 

5028 if step.get("type") != "foreach": 

5029 return False 

5030 if step.get("id") in route_plan.target_node_ids: 

5031 return False 

5032 if step.get("routes") or step.get("output_channel"): 

5033 return False 

5034 if step.get("condition") is not None or step.get("skip_llm_if") is not None: 

5035 return False 

5036 if step.get("max_output") is not None: 

5037 return False 

5038 if StepRunner._declared_item_source(step) is None: 

5039 return False 

5040 if not (step.get("body") or step.get("agent")): 

5041 return False 

5042 if step.get("max_concurrent") is None: 

5043 return False 

5044 return _resolved_send_concurrency(step, build_ctx) > 1 

5045 

5046 

5047class _ChannelGraphShim: 

5048 """Flat-dict boundary over a Core-owned channel-schema compiled graph. 

5049 

5050 Preserves the invoke contract used by main.py and tests: a flat dict in, 

5051 a flat dict out. The flat input is seeded through ``seed_committed`` and 

5052 nested under ``__core__`` for the compiled graph; the committed snapshot is 

5053 unwrapped back out. Streaming and state surfaces are not exposed because 

5054 they would leak or accept the internal ``__core__`` shape; read-only 

5055 introspection delegates to the compiled graph. 

5056 """ 

5057 

5058 def __init__(self, compiled: Any, specs: list[ChannelSpec]) -> None: 

5059 self._compiled = compiled 

5060 self._specs = specs 

5061 

5062 def invoke(self, flat_state: dict, *args: Any, **kwargs: Any) -> dict: 

5063 seeded = seed_committed(self._specs, flat_state) 

5064 result = self._compiled.invoke(wrap(seeded), *args, **kwargs) 

5065 return unwrap(result) 

5066 

5067 async def ainvoke(self, flat_state: dict, *args: Any, **kwargs: Any) -> dict: 

5068 seeded = seed_committed(self._specs, flat_state) 

5069 result = await self._compiled.ainvoke(wrap(seeded), *args, **kwargs) 

5070 return unwrap(result) 

5071 

5072 def stream(self, *args: Any, **kwargs: Any) -> Any: 

5073 self._reject_internal_shape("stream") 

5074 

5075 def astream(self, *args: Any, **kwargs: Any) -> Any: 

5076 self._reject_internal_shape("astream") 

5077 

5078 def update_state(self, *args: Any, **kwargs: Any) -> Any: 

5079 self._reject_internal_shape("update_state") 

5080 

5081 def aupdate_state(self, *args: Any, **kwargs: Any) -> Any: 

5082 self._reject_internal_shape("aupdate_state") 

5083 

5084 def get_state(self, *args: Any, **kwargs: Any) -> Any: 

5085 self._reject_internal_shape("get_state") 

5086 

5087 def aget_state(self, *args: Any, **kwargs: Any) -> Any: 

5088 self._reject_internal_shape("aget_state") 

5089 

5090 def _reject_internal_shape(self, surface: str) -> None: 

5091 raise NotImplementedError( 

5092 f"_ChannelGraphShim.{surface}() is not supported: only the flat " 

5093 ".invoke()/.ainvoke() contract is exposed through the channel " 

5094 "boundary shim" 

5095 ) 

5096 

5097 def __getattr__(self, name: str) -> Any: 

5098 return getattr(self._compiled, name) 

5099 

5100 

5101class GraphBuilder: 

5102 """ 

5103 Rakentaa LangGraph StateGraphin Pipeline-manifestista. 

5104 

5105 run_context: template-kontekstidata (budget, config jne.) — ei tallennu stateen, 

5106 käytetään vain {{ }} -templatejen evaluointiin. 

5107 """ 

5108 

5109 def __init__( 

5110 self, 

5111 registry: ManifestRegistry, 

5112 builtins: BuiltinExecutor | None = None, 

5113 invoke_agent_fn: Any | None = None, 

5114 run_context: dict | None = None, 

5115 tracer: Any | None = None, 

5116 ) -> None: 

5117 self._registry = registry 

5118 self._resolver = AgentResolver(registry) 

5119 self._invoke_agent_fn = invoke_agent_fn 

5120 self._builtins = builtins or _DEFAULT_BUILTINS 

5121 if invoke_agent_fn is not None: 

5122 self._builtins.invoke_agent_fn = invoke_agent_fn 

5123 self._step_functions = StepFunctionExecutor(registry) 

5124 self._tool_registry = build_platform_registry() 

5125 self._tool_resolver = ToolResolver(self._tool_registry, self._step_functions) 

5126 self._step_runner = StepRunner( 

5127 self._resolver, self._builtins, invoke_agent_fn, run_context, 

5128 step_functions=self._step_functions, 

5129 tracer=tracer, 

5130 tool_resolver=self._tool_resolver, 

5131 ) 

5132 

5133 def build(self, pipeline: dict): # -> CompiledGraph 

5134 """Rakentaa ja palauttaa compilatun LangGraph StateGraphin.""" 

5135 try: 

5136 from langgraph.graph import StateGraph, END 

5137 except ImportError as exc: 

5138 raise ImportError( 

5139 "langgraph is required to build a workflow. " 

5140 "Install it with: pip install langgraph" 

5141 ) from exc 

5142 

5143 raw_steps: list[dict] = (pipeline.get("spec") or {}).get("steps") or [] 

5144 

5145 channels: list[dict] = _validated_channels( 

5146 (pipeline.get("spec") or {}).get("channels") 

5147 ) 

5148 specs = parse_channel_specs( 

5149 [{"name": ch["name"], "type": ch.get("type", LAST_VALUE)} for ch in channels] 

5150 ) 

5151 channel_types = {spec.name: spec.type for spec in specs} 

5152 channel_names = frozenset(channel_types) 

5153 

5154 # Build-time call-step composition (ADR-0037 D4): each invoked Function 

5155 # body is compiled into its own native LangGraph subgraph node, so the 

5156 # runtime graph executes only native steps; a call step never survives. 

5157 steps = self._compose_calls( 

5158 raw_steps, channels, channel_names, channel_types 

5159 ) 

5160 if channels: 

5161 _validate_channel_names(channels, steps) 

5162 _validate_channel_inputs(channels, steps) 

5163 _validate_step_channel_bindings(steps, channel_names) 

5164 ordered = _topological_order(steps) 

5165 

5166 # Validate the pipeline's env map (spec.envs must be a map — ADR-0012). 

5167 envs = (pipeline.get("spec") or {}).get("envs") 

5168 if envs is not None and not isinstance(envs, dict): 

5169 raise RunHardFailure( 

5170 "VALIDATION_ERROR", 

5171 f"Pipeline '{((pipeline.get('metadata') or {}).get('name') or '?')}': " 

5172 "spec.envs must be a map of env name → image ref (ADR-0012)", 

5173 ) 

5174 

5175 graph = self._assemble_graph(ordered, channel_names, channel_types) 

5176 compiled = graph.compile() 

5177 if channel_names: 

5178 return _ChannelGraphShim(compiled, specs) 

5179 return compiled 

5180 

5181 def _compose_calls( 

5182 self, 

5183 steps: list[dict], 

5184 channels: list[dict], 

5185 channel_names: frozenset[str], 

5186 channel_types: dict[str, str], 

5187 ) -> list[dict]: 

5188 """Replace `call` steps with compiled native subgraph nodes. 

5189 

5190 Each invoked Function body is assembled through ``_assemble_graph`` and 

5191 compiled, then substituted as a single ``__subgraph__`` placeholder that 

5192 keeps the call id and its ``depends_on``. Downstream steps addressing the 

5193 call id therefore address the subgraph node unchanged. 

5194 """ 

5195 

5196 def assemble_subgraph(sub_steps: list[dict]): 

5197 return self._assemble_graph( 

5198 sub_steps, channel_names, channel_types 

5199 ).compile() 

5200 

5201 def assemble_while(while_step: dict, body_steps: list[dict]): 

5202 return self._assemble_while_graph( 

5203 while_step, body_steps, channel_names, channel_types 

5204 ) 

5205 

5206 def assemble_foreach(foreach_step: dict, body_steps: list[dict]): 

5207 return self._assemble_foreach_graph( 

5208 foreach_step, body_steps, channel_names, channel_types 

5209 ) 

5210 

5211 composer = _CallComposer( 

5212 self._registry, 

5213 assemble_subgraph, 

5214 channels, 

5215 channel_names, 

5216 assemble_while=assemble_while, 

5217 compose_whiles=True, 

5218 assemble_foreach=assemble_foreach, 

5219 compose_foreachs=True, 

5220 build_ctx=self._step_runner._make_ctx({}), 

5221 ) 

5222 return composer.compose(steps) 

5223 

5224 def _assemble_while_graph( 

5225 self, 

5226 step: dict, 

5227 body_steps: list[dict], 

5228 channel_names: frozenset[str], 

5229 channel_types: dict[str, str], 

5230 ) -> tuple[Any, int]: 

5231 """Compile a `while` step's body into a native LangGraph cycle. 

5232 

5233 The cycle is ``head -> body... -> cond`` with a conditional back-edge 

5234 from ``cond`` to ``head`` (continue) or ``exit`` (termination): the 

5235 entry condition/initial value live in the head, the feed + exit 

5236 evaluation in the cond node, and the exit node writes the documented 

5237 result. Returns the compiled cycle and the recursion bound the wrapper 

5238 must apply (a cycle consumes one super-step per node per iteration). 

5239 """ 

5240 from langgraph.graph import StateGraph, END 

5241 

5242 step_id = step.get("id", "?") 

5243 step_runner = self._step_runner 

5244 step_runner.set_channel_routing(channel_names) 

5245 

5246 state_schema = ( 

5247 _build_state_schema() 

5248 if channel_names 

5249 else Annotated[dict[str, Any], _merge_state] 

5250 ) 

5251 graph: StateGraph = StateGraph(state_schema) 

5252 

5253 max_iter = _resolve_int( 

5254 step.get("max_iterations", 2), step_runner._make_ctx({}), 2 

5255 ) 

5256 head_id = f"__while_{step_id}__head" 

5257 cond_id = f"__while_{step_id}__cond" 

5258 exit_id = f"__while_{step_id}__exit" 

5259 first_id = body_steps[0]["id"] if body_steps else cond_id 

5260 

5261 graph.add_node( 

5262 head_id, 

5263 _make_while_head_node(step, step_runner, channel_names, max_iter), 

5264 ) 

5265 for sub in body_steps: 

5266 graph.add_node( 

5267 sub["id"], 

5268 _make_while_body_node( 

5269 sub, step_runner, channel_names, channel_types 

5270 ), 

5271 ) 

5272 graph.add_node( 

5273 cond_id, _make_while_cond_node(step, step_runner, channel_names) 

5274 ) 

5275 graph.add_node( 

5276 exit_id, _make_while_exit_node(step, step_runner, channel_names) 

5277 ) 

5278 

5279 graph.set_entry_point(head_id) 

5280 head_paths = {first_id: first_id, exit_id: exit_id, END: END} 

5281 graph.add_conditional_edges( 

5282 head_id, 

5283 _make_while_head_router(step, channel_names, first_id, exit_id, END), 

5284 head_paths, 

5285 ) 

5286 for previous, sub in zip(body_steps, body_steps[1:]): 

5287 graph.add_edge(previous["id"], sub["id"]) 

5288 if body_steps: 

5289 graph.add_edge(body_steps[-1]["id"], cond_id) 

5290 graph.add_conditional_edges( 

5291 cond_id, 

5292 _make_while_cond_router(step, channel_names, head_id, exit_id), 

5293 {head_id: head_id, exit_id: exit_id}, 

5294 ) 

5295 graph.add_edge(exit_id, END) 

5296 

5297 recursion_limit = max(25, (len(body_steps) + 3) * (max_iter + 1) + 10) 

5298 return graph.compile(), recursion_limit 

5299 

5300 def _assemble_foreach_graph( 

5301 self, 

5302 step: dict, 

5303 body_steps: list[dict], 

5304 channel_names: frozenset[str], 

5305 channel_types: dict[str, str], 

5306 ) -> tuple[Any, int]: 

5307 """Compile a sequential `foreach` step's body into a native LangGraph cycle. 

5308 

5309 The cycle is ``head -> (body... | agent) -> cond`` with a conditional 

5310 back-edge from ``cond`` to ``head`` (next item) or ``exit`` (exhaustion 

5311 or `max_output` reached). The head resolves the item list once and 

5312 injects the current item; the cond collects the item result; the exit 

5313 node writes the documented aggregate. Returns the compiled cycle and 

5314 the recursion bound the wrapper must apply. 

5315 """ 

5316 from langgraph.graph import StateGraph, END 

5317 

5318 step_id = step.get("id", "?") 

5319 step_runner = self._step_runner 

5320 step_runner.set_channel_routing(channel_names) 

5321 

5322 state_schema = ( 

5323 _build_state_schema() 

5324 if channel_names 

5325 else Annotated[dict[str, Any], _merge_state] 

5326 ) 

5327 graph: StateGraph = StateGraph(state_schema) 

5328 

5329 max_iter = _resolve_int( 

5330 step.get("max_iterations"), 

5331 step_runner._make_ctx({}), 

5332 _FOREACH_DEFAULT_MAX_ITER, 

5333 ) 

5334 head_id = f"__foreach_{step_id}__head" 

5335 cond_id = f"__foreach_{step_id}__cond" 

5336 exit_id = f"__foreach_{step_id}__exit" 

5337 agent_id = f"__foreach_{step_id}__agent" 

5338 

5339 graph.add_node( 

5340 head_id, _make_foreach_head_node(step, step_runner, channel_names) 

5341 ) 

5342 if body_steps: 

5343 for sub in body_steps: 

5344 graph.add_node( 

5345 sub["id"], 

5346 _make_foreach_body_node( 

5347 sub, step_runner, channel_names, channel_types 

5348 ), 

5349 ) 

5350 first_id = body_steps[0]["id"] 

5351 else: 

5352 graph.add_node( 

5353 agent_id, 

5354 _make_foreach_agent_node( 

5355 step, step_runner, channel_names, channel_types 

5356 ), 

5357 ) 

5358 first_id = agent_id 

5359 graph.add_node( 

5360 cond_id, _make_foreach_cond_node(step, step_runner, channel_names) 

5361 ) 

5362 graph.add_node( 

5363 exit_id, _make_foreach_exit_node(step, step_runner, channel_names) 

5364 ) 

5365 

5366 graph.set_entry_point(head_id) 

5367 graph.add_conditional_edges( 

5368 head_id, 

5369 _make_foreach_head_router(step, channel_names, first_id, exit_id, END), 

5370 {first_id: first_id, exit_id: exit_id, END: END}, 

5371 ) 

5372 for previous, sub in zip(body_steps, body_steps[1:]): 

5373 graph.add_edge(previous["id"], sub["id"]) 

5374 if body_steps: 

5375 graph.add_edge(body_steps[-1]["id"], cond_id) 

5376 else: 

5377 graph.add_edge(agent_id, cond_id) 

5378 graph.add_conditional_edges( 

5379 cond_id, 

5380 _make_foreach_cond_router(step, channel_names, head_id, exit_id), 

5381 {head_id: head_id, exit_id: exit_id}, 

5382 ) 

5383 graph.add_edge(exit_id, END) 

5384 

5385 recursion_limit = max(25, (len(body_steps) + 3) * (max_iter + 1) + 10) 

5386 return graph.compile(), recursion_limit 

5387 

5388 def _assemble_graph( 

5389 self, 

5390 steps: list[dict], 

5391 channel_names: frozenset[str], 

5392 channel_types: dict[str, str], 

5393 ) -> "StateGraph": 

5394 from langgraph.graph import StateGraph, END 

5395 

5396 # Group steps into LangGraph nodes: 

5397 # Steps with the same depends_on set AND type==parallel → one node (LangGraph-level parallel) 

5398 nodes: list[list[dict]] = [] 

5399 for step in steps: 

5400 dep_sig = frozenset(step.get("depends_on") or []) 

5401 if nodes: 

5402 last_group_sig = frozenset( 

5403 dep 

5404 for s in nodes[-1] 

5405 for dep in (s.get("depends_on") or []) 

5406 ) 

5407 last_types = {s.get("type") for s in nodes[-1]} 

5408 if ( 

5409 dep_sig == last_group_sig 

5410 and step.get("type") == "parallel" 

5411 and last_types == {"parallel"} 

5412 and not step.get("routes") 

5413 and not any(s.get("routes") for s in nodes[-1]) 

5414 ): 

5415 nodes[-1].append(step) 

5416 continue 

5417 nodes.append([step]) 

5418 

5419 state_schema = ( 

5420 _build_state_schema() 

5421 if channel_names 

5422 else Annotated[dict[str, Any], _merge_state] 

5423 ) 

5424 graph: StateGraph = StateGraph(state_schema) 

5425 step_runner = self._step_runner 

5426 step_runner.set_channel_routing(channel_names) 

5427 

5428 node_ids: list[str] = [] 

5429 for group in nodes: 

5430 if len(group) == 1: 

5431 node_ids.append(group[0]["id"]) 

5432 else: 

5433 node_ids.append("parallel_" + "_".join(s["id"] for s in group)) 

5434 

5435 route_plan = _build_route_plan(nodes, node_ids) if nodes else None 

5436 

5437 send_parts: dict[int, _MapReduceParts] = {} 

5438 if route_plan is not None: 

5439 build_ctx = step_runner._make_ctx({}) 

5440 for index, group in enumerate(nodes): 

5441 if len(group) != 1: 

5442 continue 

5443 send_candidate = group[0] 

5444 if _is_send_parallel_step(send_candidate, route_plan, build_ctx): 

5445 plan = _make_parallel_map_reduce_plan( 

5446 send_candidate, step_runner 

5447 ) 

5448 elif _is_send_foreach_step(send_candidate, route_plan, build_ctx): 

5449 plan = _make_foreach_map_reduce_plan( 

5450 send_candidate, step_runner, channel_names, channel_types 

5451 ) 

5452 else: 

5453 continue 

5454 send_parts[index] = _make_map_reduce_parts( 

5455 send_candidate, 

5456 plan, 

5457 step_runner, 

5458 channel_names, 

5459 channel_types, 

5460 _resolved_send_concurrency(send_candidate, build_ctx), 

5461 ) 

5462 

5463 entry_ids: list[str] = [] 

5464 exit_ids: list[str] = [] 

5465 for index, group in enumerate(nodes): 

5466 parts = send_parts.get(index) 

5467 if parts is None: 

5468 captured_group = list(group) 

5469 if len(captured_group) == 1: 

5470 first = captured_group[0] 

5471 if first.get("type") == _SUBGRAPH_STEP_TYPE: 

5472 if first.get(_SUBGRAPH_WHILE_KEY): 

5473 subgraph_node = _make_while_subgraph_node( 

5474 first, step_runner, channel_names 

5475 ) 

5476 elif first.get(_SUBGRAPH_FOREACH_KEY): 

5477 subgraph_node = _make_foreach_subgraph_node( 

5478 first, step_runner, channel_names 

5479 ) 

5480 else: 

5481 subgraph_node = _make_subgraph_node( 

5482 first[_SUBGRAPH_GRAPH_KEY], 

5483 first.get("condition"), 

5484 step_runner, 

5485 channel_names, 

5486 ) 

5487 graph.add_node(node_ids[index], subgraph_node) 

5488 entry_ids.append(node_ids[index]) 

5489 exit_ids.append(node_ids[index]) 

5490 continue 

5491 fn = _make_step_node( 

5492 first, step_runner, channel_names, channel_types, 

5493 ) 

5494 else: 

5495 fn = _make_parallel_node( 

5496 captured_group, 

5497 step_runner, 

5498 channel_names, 

5499 channel_types, 

5500 ) 

5501 graph.add_node(node_ids[index], fn) 

5502 entry_ids.append(node_ids[index]) 

5503 exit_ids.append(node_ids[index]) 

5504 continue 

5505 fallback_step = group[0] 

5506 graph.add_node(parts.dispatch_id, parts.dispatch_fn) 

5507 graph.add_node(parts.worker_id, parts.worker_fn) 

5508 graph.add_node( 

5509 parts.fallback_id, 

5510 _make_step_node( 

5511 fallback_step, step_runner, channel_names, channel_types 

5512 ), 

5513 ) 

5514 graph.add_node(parts.join_id, parts.join_fn) 

5515 graph.add_conditional_edges( 

5516 parts.dispatch_id, 

5517 parts.route_fn, 

5518 [parts.worker_id, parts.fallback_id], 

5519 ) 

5520 graph.add_edge(parts.worker_id, parts.join_id) 

5521 graph.add_edge(parts.fallback_id, parts.join_id) 

5522 entry_ids.append(parts.dispatch_id) 

5523 exit_ids.append(parts.join_id) 

5524 

5525 if nodes: 

5526 _wire_dependency_edges( 

5527 graph, nodes, node_ids, END, route_plan, entry_ids, exit_ids 

5528 ) 

5529 _wire_route_edges(graph, route_plan, END) 

5530 else: 

5531 graph.add_node("noop", lambda s: s) 

5532 graph.set_entry_point("noop") 

5533 graph.add_edge("noop", END) 

5534 

5535 return graph 

5536 

5537 

5538# --------------------------------------------------------------------------- 

5539# Julkinen API 

5540# --------------------------------------------------------------------------- 

5541 

5542_registry_cache: ManifestRegistry | None = None 

5543 

5544 

5545def _get_registry(dirs: list[Path] | None = None) -> ManifestRegistry: 

5546 global _registry_cache 

5547 if _registry_cache is None or dirs is not None: 

5548 loader = ManifestLoader(dirs) 

5549 manifests = loader.load_all() 

5550 _registry_cache = ManifestRegistry(manifests) 

5551 return _registry_cache 

5552 

5553 

5554def build_workflow( 

5555 pipeline_name: str, 

5556 *, 

5557 dirs: list[Path] | None = None, 

5558 builtins: BuiltinExecutor | None = None, 

5559 invoke_agent_fn: Any | None = None, 

5560 run_context: dict | None = None, 

5561 tracer: Any | None = None, 

5562): 

5563 """ 

5564 Lataa Pipeline-manifesti nimeltä *pipeline_name* ja rakentaa 

5565 LangGraph-workflown. 

5566 

5567 Args: 

5568 pipeline_name: Pipeline metadata.name -arvo. 

5569 dirs: Manifestihakemistot (oletus: MANIFEST_DIRS). 

5570 builtins: Oma BuiltinExecutor (oletus: _DEFAULT_BUILTINS). 

5571 invoke_agent_fn: fn(resolved_spec, step, state) → state. 

5572 run_context: Template-kontekstidata, esim. {"budget": {...}, "config": {...}}. 

5573 

5574 Returns: 

5575 Compilattu LangGraph CompiledGraph. 

5576 """ 

5577 registry = _get_registry(dirs) 

5578 pipeline = registry.get("Pipeline", pipeline_name) 

5579 if pipeline is None: 

5580 available = [ 

5581 (m.get("metadata") or {}).get("name") 

5582 for m in registry.all_of_kind("Pipeline") 

5583 ] 

5584 raise RunHardFailure( 

5585 "VALIDATION_ERROR", 

5586 f"Pipeline '{pipeline_name}' not found. Available: {available}", 

5587 ) 

5588 builder = GraphBuilder(registry, builtins, invoke_agent_fn, run_context, tracer=tracer) 

5589 return builder.build(pipeline)