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

2558 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-25 03:42 +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 composed_step_ids = ( 

2004 step.get(_SUBGRAPH_STEP_IDS_KEY) 

2005 if not step.get(_SUBGRAPH_WHILE_KEY) 

2006 and not step.get(_SUBGRAPH_FOREACH_KEY) 

2007 else None 

2008 ) 

2009 if working_set is not None: 

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

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

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

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

2014 # commits already produced the authoritative values. 

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

2016 committed = _flatten_channel_state(sub_result) 

2017 return _filter_composed_step_ids( 

2018 {**state, **committed}, composed_step_ids 

2019 ) 

2020 return _filter_composed_step_ids( 

2021 compiled.invoke(state, config), composed_step_ids 

2022 ) 

2023 

2024 if step_type == "spawn": 

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

2026 

2027 raise RunHardFailure( 

2028 "VALIDATION_ERROR", 

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

2030 {"step_id": step_id, "step_type": step_type}, 

2031 ) 

2032 

2033 def _run_parallel_fanout( 

2034 self, 

2035 step: dict, 

2036 state: PipelineState, 

2037 items: list, 

2038 ctx: dict, 

2039 working_set: ChannelWorkingSet | None = None, 

2040 ) -> PipelineState: 

2041 """ 

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

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

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

2045 """ 

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

2047 agent_name = step.get("agent") 

2048 if not agent_name: 

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

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

2051 

2052 resolved = self._resolver.resolve(agent_name) 

2053 

2054 # Apply max_items cap 

2055 max_items_raw = step.get("max_items") 

2056 if max_items_raw is not None: 

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

2058 items = items[:max_items] 

2059 

2060 # Concurrency limit 

2061 max_conc_raw = step.get("max_concurrent") 

2062 if max_conc_raw is not None: 

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

2064 else: 

2065 max_conc = len(items) or 1 

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

2067 

2068 log.debug( 

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

2070 step_id, len(items), max_conc, 

2071 ) 

2072 

2073 read_state = self._read_state(state, working_set) 

2074 

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

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

2077 item_ctx = self._make_ctx(item_state) 

2078 return self._invoke_parallel_item( 

2079 step, resolved, item, item_state, item_ctx 

2080 ) 

2081 

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

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

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

2085 item_metrics: dict = {} 

2086 item_tool_meta: dict = {} 

2087 with ThreadPoolExecutor(max_workers=max_conc) as pool: 

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

2089 for future in as_completed(futures): 

2090 idx = futures[future] 

2091 try: 

2092 item_result, item_metrics_map, item_tool_meta_map, *_ = ( 

2093 future.result() 

2094 ) 

2095 results[idx] = item_result 

2096 if isinstance(item_metrics_map, dict): 

2097 item_metrics.update(item_metrics_map) 

2098 if isinstance(item_tool_meta_map, dict): 

2099 item_tool_meta.update(item_tool_meta_map) 

2100 except SkillLoadError: 

2101 raise 

2102 except RunHardFailure: 

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

2104 raise 

2105 except Exception as exc: # noqa: BLE001 

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

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

2108 

2109 self._stage_fanout_channel_writes(step, results, working_set) 

2110 

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

2112 if item_metrics: 

2113 final = { 

2114 **final, 

2115 "step_context_metrics": _merge_side_channel( 

2116 state.get("step_context_metrics"), item_metrics 

2117 ), 

2118 } 

2119 if item_tool_meta: 

2120 final = { 

2121 **final, 

2122 "step_tool_meta": _merge_side_channel( 

2123 state.get("step_tool_meta"), item_tool_meta 

2124 ), 

2125 } 

2126 return final 

2127 

2128 def _invoke_parallel_item( 

2129 self, 

2130 step: dict, 

2131 resolved: dict, 

2132 item: Any, 

2133 item_state: PipelineState, 

2134 item_ctx: dict, 

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

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

2137 

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

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

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

2141 """ 

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

2143 resolved_step = self._resolve_step_runtime( 

2144 step, item_state, item_ctx, 

2145 input_override=item, 

2146 ) 

2147 resolved_step = self._attach_communication_context( 

2148 resolved_step, item_state 

2149 ) 

2150 resolved_step = self._attach_resolved_tools(resolved_step, resolved) 

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

2152 return ( 

2153 result_state.get(step_id), 

2154 result_state.get("step_context_metrics"), 

2155 result_state.get("step_tool_meta"), 

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

2157 ) 

2158 

2159 def _stage_fanout_channel_writes( 

2160 self, 

2161 step: dict, 

2162 item_outputs: list, 

2163 working_set: ChannelWorkingSet | None, 

2164 ) -> None: 

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

2166 

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

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

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

2170 """ 

2171 if working_set is None or not self._channel_names: 

2172 return 

2173 for output in item_outputs: 

2174 writes = _declared_output_channel_writes( 

2175 step, output, self._channel_names 

2176 ) 

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

2178 working_set.stage(name, value) 

2179 

2180 def _run_foreach( 

2181 self, 

2182 step: dict, 

2183 state: PipelineState, 

2184 working_set: ChannelWorkingSet | None = None, 

2185 ) -> PipelineState: 

2186 """ 

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

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

2189 

2190 When max_concurrent > 1, body iterations run concurrently via 

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

2192 max_concurrent value sets the thread pool size (mirrors parallel 

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

2194 

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

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

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

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

2199 sequential early-exit guarantee cannot be reproduced without sacrificing 

2200 true concurrency. 

2201 

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

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

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

2205 """ 

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

2207 read_state = self._read_state(state, working_set) 

2208 ctx = self._make_ctx(read_state) 

2209 

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

2211 

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

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

2214 max_output_raw = step.get("max_output") 

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

2216 

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

2218 max_conc_raw = step.get("max_concurrent") 

2219 if max_conc_raw is not None: 

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

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

2222 else: 

2223 max_conc = 1 

2224 

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

2226 agent_name = step.get("agent") 

2227 

2228 if max_conc <= 1: 

2229 raise RunHardFailure( 

2230 "VALIDATION_ERROR", 

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

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

2233 "cycle) — internal invariant violated", 

2234 {"step_id": step_id}, 

2235 ) 

2236 return self._run_foreach_concurrent( 

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

2238 working_set, 

2239 ) 

2240 

2241 @staticmethod 

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

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

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

2245 if declared_output: 

2246 return iter_state.get(declared_output) 

2247 return iter_state.get("publish") 

2248 

2249 @staticmethod 

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

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

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

2253 if isinstance(item, dict): 

2254 verdict = item.get("verdict") 

2255 if verdict == "FAIL": 

2256 return False 

2257 if verdict == "PASS": 

2258 return True 

2259 passed = item.get("passed") 

2260 if isinstance(passed, bool): 

2261 return passed 

2262 return True 

2263 

2264 @staticmethod 

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

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

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

2268 return { 

2269 "results": item_results, 

2270 "passed": not failing, 

2271 "failing": failing, 

2272 "failed": bool(failing), 

2273 } 

2274 

2275 @staticmethod 

2276 def _merge_item_working_sets( 

2277 working_set: ChannelWorkingSet | None, 

2278 item_working_sets: list[ChannelWorkingSet | None], 

2279 ) -> None: 

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

2281 if working_set is None: 

2282 return 

2283 for item_working_set in item_working_sets: 

2284 if item_working_set is None: 

2285 continue 

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

2287 working_set.stage(name, value) 

2288 

2289 def _run_foreach_concurrent( 

2290 self, 

2291 step_id: str, 

2292 step: dict, 

2293 state: PipelineState, 

2294 items: list, 

2295 body: list, 

2296 agent_name: str | None, 

2297 max_output: int, 

2298 max_conc: int, 

2299 working_set: ChannelWorkingSet | None = None, 

2300 ) -> PipelineState: 

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

2302 log.info( 

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

2304 step_id, len(items), max_conc, 

2305 ) 

2306 

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

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

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

2310 body_polku = bool(body) 

2311 declared_output = step.get("output") 

2312 _SKIPPED = object() 

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

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

2315 lock = threading.Lock() 

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

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

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

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

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

2321 

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

2323 nonlocal item_errors 

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

2325 if max_output and stop_flag.is_set(): 

2326 return 

2327 

2328 item_working_set = ( 

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

2330 ) 

2331 item_working_sets[idx] = item_working_set 

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

2333 

2334 if body_polku: 

2335 for sub_step in body: 

2336 iter_state = self._run_body_step( 

2337 sub_step, iter_state, item_working_set 

2338 ) 

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

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

2341 if new_errs: 

2342 with lock: 

2343 item_errors = self._merge_errors(item_errors, new_errs) 

2344 item_metrics_map = iter_state.get("step_context_metrics") 

2345 if isinstance(item_metrics_map, dict): 

2346 with lock: 

2347 accumulated_metrics.update(item_metrics_map) 

2348 item_tool_meta_map = iter_state.get("step_tool_meta") 

2349 if isinstance(item_tool_meta_map, dict): 

2350 with lock: 

2351 accumulated_tool_meta.update(item_tool_meta_map) 

2352 publish_out = iter_state.get("publish") 

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

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

2355 with lock: 

2356 produced_outputs[0] += 1 

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

2358 stop_flag.set() 

2359 elif agent_name: 

2360 resolved = self._resolver.resolve(agent_name) 

2361 item_read_state = self._read_state(iter_state, item_working_set) 

2362 item_ctx = self._make_ctx(item_read_state) 

2363 resolved_step = self._resolve_step_runtime( 

2364 step, item_read_state, item_ctx 

2365 ) 

2366 resolved_step = self._attach_communication_context( 

2367 resolved_step, item_read_state 

2368 ) 

2369 resolved_step = self._attach_resolved_tools(resolved_step, resolved) 

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

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

2372 item_metrics_map = iter_state.get("step_context_metrics") 

2373 if isinstance(item_metrics_map, dict): 

2374 with lock: 

2375 accumulated_metrics.update(item_metrics_map) 

2376 item_tool_meta_map = iter_state.get("step_tool_meta") 

2377 if isinstance(item_tool_meta_map, dict): 

2378 with lock: 

2379 accumulated_tool_meta.update(item_tool_meta_map) 

2380 

2381 with ThreadPoolExecutor(max_workers=max_conc) as pool: 

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

2383 for future in as_completed(futures): 

2384 try: 

2385 future.result() 

2386 except SkillLoadError: 

2387 raise 

2388 except RunHardFailure: 

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

2390 raise 

2391 except Exception as exc: # noqa: BLE001 

2392 idx = futures[future] 

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

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

2395 

2396 self._merge_item_working_sets(working_set, item_working_sets) 

2397 

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

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

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

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

2402 if accumulated_metrics: 

2403 final = { 

2404 **final, 

2405 "step_context_metrics": _merge_side_channel( 

2406 state.get("step_context_metrics"), accumulated_metrics 

2407 ), 

2408 } 

2409 if accumulated_tool_meta: 

2410 final = { 

2411 **final, 

2412 "step_tool_meta": _merge_side_channel( 

2413 state.get("step_tool_meta"), accumulated_tool_meta 

2414 ), 

2415 } 

2416 if item_errors: 

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

2418 return final 

2419 

2420 def _run_spawn( 

2421 self, 

2422 step: dict, 

2423 state: PipelineState, 

2424 working_set: ChannelWorkingSet | None = None, 

2425 ) -> PipelineState: 

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

2427 

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

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

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

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

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

2433 """ 

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

2435 director = step.get("agent") 

2436 if not director: 

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

2438 return state 

2439 read_state = self._read_state(state, working_set) 

2440 ctx = self._make_ctx(read_state) 

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

2442 max_depth = _resolve_int( 

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

2444 ) 

2445 max_spawn_per_node = _resolve_int( 

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

2447 ) 

2448 max_calls_raw = policy.get("max_agent_calls") 

2449 budget = SpawnBudget( 

2450 _SPAWN_DEFAULT_MAX_AGENT_CALLS 

2451 if max_calls_raw is None 

2452 else _resolve_int(max_calls_raw, ctx, _SPAWN_DEFAULT_MAX_AGENT_CALLS) 

2453 ) 

2454 run_max_parallel = int( 

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

2456 "max_parallel", DEFAULT_MAX_PARALLEL 

2457 ) 

2458 ) 

2459 max_concurrent_raw = policy.get("max_concurrent_children") 

2460 max_concurrent_children = ( 

2461 run_max_parallel 

2462 if max_concurrent_raw is None 

2463 else _resolve_int(max_concurrent_raw, ctx, run_max_parallel) 

2464 ) 

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

2466 

2467 outcome = self._run_native_spawn( 

2468 step=step, 

2469 step_id=step_id, 

2470 director=director, 

2471 child_role=child_role, 

2472 base_state=read_state, 

2473 base_ctx=ctx, 

2474 budget=budget, 

2475 max_depth=max_depth, 

2476 max_spawn_per_node=max_spawn_per_node, 

2477 max_concurrent_children=max_concurrent_children, 

2478 ) 

2479 envelope = outcome["envelope"] 

2480 final: PipelineState = {**state, step_id: envelope} 

2481 if outcome["metrics"]: 

2482 final = { 

2483 **final, 

2484 "step_context_metrics": _merge_side_channel( 

2485 state.get("step_context_metrics"), outcome["metrics"] 

2486 ), 

2487 } 

2488 if outcome["tool_meta"]: 

2489 final = { 

2490 **final, 

2491 "step_tool_meta": _merge_side_channel( 

2492 state.get("step_tool_meta"), outcome["tool_meta"] 

2493 ), 

2494 } 

2495 self._stage_spawn_channel_writes(step, envelope["result"], working_set) 

2496 return final 

2497 

2498 def _run_native_spawn( 

2499 self, 

2500 *, 

2501 step: dict, 

2502 step_id: str, 

2503 director: str, 

2504 child_role: str, 

2505 base_state: PipelineState, 

2506 base_ctx: dict, 

2507 budget: SpawnBudget, 

2508 max_depth: int, 

2509 max_spawn_per_node: int | None, 

2510 max_concurrent_children: int, 

2511 ) -> dict: 

2512 """Compile and invoke the native spawn tree, returning its outcome. 

2513 

2514 The graph is a director node that seeds one ``Send`` per child to a 

2515 shared worker, whose worker re-``Send``s itself for deeper levels. An 

2516 outstanding-descendants barrier (``outstanding`` counter plus a ``check`` 

2517 node) makes the ``join`` run exactly once for uneven-depth trees; the 

2518 join assembles the result envelope by path key in plan order. 

2519 """ 

2520 from contextlib import nullcontext 

2521 from typing import TypedDict 

2522 

2523 from langgraph.graph import END, START, StateGraph 

2524 from langgraph.types import Command, Send 

2525 

2526 tracer = self._tracer 

2527 parent_obs = ( 

2528 tracer.open_parent_observation( 

2529 name=f"spawn:{step_id}", 

2530 input={"node_id": step_id, "role": director, "depth": 0}, 

2531 ) 

2532 if tracer is not None 

2533 else None 

2534 ) 

2535 root_obs_id = getattr(parent_obs, "id", None) 

2536 try: 

2537 child_gates: dict[str, threading.Semaphore] = {} 

2538 child_gates_lock = threading.Lock() 

2539 

2540 def child_gate(parent_id: str) -> threading.Semaphore: 

2541 """The concurrency gate for one spawning node's direct children. 

2542 

2543 ``max_concurrent_children`` bounds a single spawn node's fan-out: 

2544 each spawning node owns its own gate, so a shallow node's cap 

2545 never throttles a deeper sibling's subtree. 

2546 """ 

2547 with child_gates_lock: 

2548 gate = child_gates.get(parent_id) 

2549 if gate is None: 

2550 gate = threading.Semaphore(max(1, max_concurrent_children)) 

2551 child_gates[parent_id] = gate 

2552 return gate 

2553 

2554 def run_node( 

2555 *, 

2556 role: str, 

2557 node_step: dict, 

2558 input_override: Any, 

2559 node_id: str, 

2560 depth: int, 

2561 parent_id: str | None = None, 

2562 ) -> dict: 

2563 resolved_role = self._resolver.resolve(role) 

2564 resolved_step = self._resolve_step_runtime( 

2565 node_step, base_state, base_ctx, input_override=input_override 

2566 ) 

2567 resolved_step = self._attach_communication_context( 

2568 resolved_step, base_state 

2569 ) 

2570 resolved_step = self._attach_resolved_tools( 

2571 resolved_step, resolved_role 

2572 ) 

2573 if not budget.consume(): 

2574 raise RunHardFailure( 

2575 "SPAWN_BUDGET_EXHAUSTED", 

2576 f"spawn tree budget exhausted at node '{node_id}'", 

2577 {"node_id": node_id, "depth": depth}, 

2578 ) 

2579 gate = child_gate(parent_id) if parent_id is not None else nullcontext() 

2580 with gate: 

2581 return self._invoke_agent( 

2582 resolved_role, resolved_step, base_state 

2583 ) 

2584 

2585 def build_record( 

2586 *, 

2587 node_id: str, 

2588 path: tuple, 

2589 parent_path: tuple | None, 

2590 depth: int, 

2591 this_result: Any, 

2592 result_state: dict, 

2593 children: list, 

2594 ) -> tuple[dict, list]: 

2595 exhausted = False 

2596 plan: list = [] 

2597 if children: 

2598 if depth + 1 > max_depth: 

2599 exhausted = True 

2600 else: 

2601 plan = [ 

2602 { 

2603 "path": path + (index,), 

2604 "depth": depth + 1, 

2605 "child": child, 

2606 "parent_id": node_id, 

2607 "index": index, 

2608 } 

2609 for index, child in enumerate(children) 

2610 ] 

2611 record = { 

2612 "path": path, 

2613 "parent_path": parent_path, 

2614 "child_id": node_id, 

2615 "result": this_result, 

2616 "self_passed": not self._spawn_is_soft_fail(this_result), 

2617 "exhausted": exhausted, 

2618 "metrics": dict(result_state.get("step_context_metrics") or {}), 

2619 "tool_meta": dict(result_state.get("step_tool_meta") or {}), 

2620 } 

2621 return record, plan 

2622 

2623 def child_plan(node_id: str, this_result: Any, depth: int) -> list: 

2624 children = self._spawn_children_from_result(this_result) 

2625 if children and max_spawn_per_node is not None and len(children) > max_spawn_per_node: 

2626 raise RunHardFailure( 

2627 "SPAWN_OVER_FLIGHT", 

2628 f"spawn node '{node_id}' emitted {len(children)} children, " 

2629 f"exceeds max_spawn_per_node={max_spawn_per_node}", 

2630 { 

2631 "node_id": node_id, 

2632 "depth": depth, 

2633 "children": len(children), 

2634 "max_spawn_per_node": max_spawn_per_node, 

2635 }, 

2636 ) 

2637 return children 

2638 

2639 def director_node(_state: dict) -> Command: 

2640 result_state = run_node( 

2641 role=director, 

2642 node_step=step, 

2643 input_override=_UNSET, 

2644 node_id=step_id, 

2645 depth=0, 

2646 ) 

2647 this_result = result_state.get(step_id) 

2648 children = child_plan(step_id, this_result, 0) 

2649 record, plan = build_record( 

2650 node_id=step_id, 

2651 path=(), 

2652 parent_path=None, 

2653 depth=0, 

2654 this_result=this_result, 

2655 result_state=result_state, 

2656 children=children, 

2657 ) 

2658 update = { 

2659 "accum": {step_id: {(): record}}, 

2660 "outstanding": len(plan) - 1, 

2661 } 

2662 if not plan: 

2663 return Command(update=update, goto="join") 

2664 return Command( 

2665 update=update, 

2666 goto=[ 

2667 Send("worker", {**payload, "parent_obs_id": root_obs_id}) 

2668 for payload in plan 

2669 ], 

2670 ) 

2671 

2672 def worker_node(payload: dict) -> Command: 

2673 path = tuple(payload["path"]) 

2674 depth = payload["depth"] 

2675 parent_id = payload["parent_id"] 

2676 index = payload["index"] 

2677 child = payload["child"] 

2678 child_id = ( 

2679 child["id"] 

2680 if isinstance(child, dict) and child.get("id") 

2681 else f"{parent_id}.{index}" 

2682 ) 

2683 node_step = self._child_node_step( 

2684 node_id=child_id, 

2685 child=child, 

2686 parent_id=parent_id, 

2687 index=index, 

2688 base_state=base_state, 

2689 base_ctx=base_ctx, 

2690 ) 

2691 # The worker's own graph context does not carry the spawning 

2692 # node's observation (each Send target runs in a fresh context), 

2693 # so the parent chain is re-established from the payload before 

2694 # opening this node's observation (ADR-0035 D5). 

2695 parent_obs_id = payload.get("parent_obs_id") 

2696 stack_token = ( 

2697 _PARENT_STACK.set((parent_obs_id,)) 

2698 if tracer is not None and parent_obs_id is not None 

2699 else None 

2700 ) 

2701 child_obs = ( 

2702 tracer.open_parent_observation( 

2703 name=f"spawn:{child_id}", 

2704 input={"node_id": child_id, "role": child_role, "depth": depth}, 

2705 ) 

2706 if tracer is not None 

2707 else None 

2708 ) 

2709 try: 

2710 result_state = run_node( 

2711 role=child_role, 

2712 node_step=node_step, 

2713 input_override=node_step["input"], 

2714 node_id=child_id, 

2715 depth=depth, 

2716 parent_id=parent_id, 

2717 ) 

2718 finally: 

2719 if tracer is not None and child_obs is not None: 

2720 tracer.close_parent_observation(child_obs) 

2721 if stack_token is not None: 

2722 _PARENT_STACK.reset(stack_token) 

2723 this_result = result_state.get(child_id) 

2724 children = child_plan(child_id, this_result, depth) 

2725 record, plan = build_record( 

2726 node_id=child_id, 

2727 path=path, 

2728 parent_path=path[:-1], 

2729 depth=depth, 

2730 this_result=this_result, 

2731 result_state=result_state, 

2732 children=children, 

2733 ) 

2734 update = { 

2735 "accum": {step_id: {path: record}}, 

2736 "outstanding": len(plan) - 1, 

2737 } 

2738 if plan: 

2739 child_obs_id = getattr(child_obs, "id", None) 

2740 return Command( 

2741 update=update, 

2742 goto=[ 

2743 Send( 

2744 "worker", 

2745 {**payload, "parent_obs_id": child_obs_id}, 

2746 ) 

2747 for payload in plan 

2748 ], 

2749 ) 

2750 return Command(update=update, goto="check") 

2751 

2752 def check_node(state: dict) -> Command: 

2753 if state.get("outstanding") == 0: 

2754 return Command(goto="join") 

2755 return Command(goto=END) 

2756 

2757 def join_node(state: dict) -> dict: 

2758 records = (state.get("accum") or {}).get(step_id) or {} 

2759 envelope, metrics, tool_meta = _assemble_spawn_outcome(records) 

2760 return { 

2761 "spawn_outcome": { 

2762 "envelope": envelope, 

2763 "metrics": metrics, 

2764 "tool_meta": tool_meta, 

2765 }, 

2766 "accum": {step_id: None}, 

2767 } 

2768 

2769 schema = TypedDict( 

2770 "_SpawnState", 

2771 { 

2772 "accum": Annotated[dict, accumulate_map_reduce], 

2773 "outstanding": Annotated[int, operator.add], 

2774 "spawn_outcome": dict, 

2775 }, 

2776 ) 

2777 graph = StateGraph(schema) 

2778 graph.add_node("director", director_node) 

2779 graph.add_node("worker", worker_node) 

2780 graph.add_node("check", check_node) 

2781 graph.add_node("join", join_node) 

2782 graph.add_edge(START, "director") 

2783 graph.add_edge("join", END) 

2784 app = graph.compile() 

2785 out = app.invoke( 

2786 {"accum": {}, "outstanding": 1}, 

2787 config={"recursion_limit": _SPAWN_RECURSION_LIMIT}, 

2788 ) 

2789 return out["spawn_outcome"] 

2790 finally: 

2791 if tracer is not None and parent_obs is not None: 

2792 tracer.close_parent_observation(parent_obs) 

2793 

2794 def _child_node_step( 

2795 self, 

2796 *, 

2797 node_id: str, 

2798 child: Any, 

2799 parent_id: str, 

2800 index: int, 

2801 base_state: PipelineState, 

2802 base_ctx: dict, 

2803 ) -> dict: 

2804 """Resolve one child plan item into the worker's runtime step. 

2805 

2806 A child is a runtime-generated step: it carries only its id and its 

2807 resolved input, never the spawn step's declared channel bindings, so a 

2808 child's output can never route a channel write. 

2809 """ 

2810 if not self._spawn_has_input(child): 

2811 raise RunHardFailure( 

2812 "INVALID_CHILD_PLAN", 

2813 f"spawn child #{index} of node '{parent_id}' must be a dict " 

2814 "with a non-empty 'input'", 

2815 {"node_id": parent_id, "child_index": index, "child": repr(child)[:200]}, 

2816 ) 

2817 return {"id": node_id, "input": _resolve_input(child["input"], base_state, base_ctx)} 

2818 

2819 def _stage_spawn_channel_writes( 

2820 self, 

2821 step: dict, 

2822 result: Any, 

2823 working_set: ChannelWorkingSet | None, 

2824 ) -> None: 

2825 """Stage a spawn node's declared writes from its own agent result.""" 

2826 if working_set is None: 

2827 return 

2828 writes = _declared_output_channel_writes(step, result, self._channel_names) 

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

2830 working_set.stage(name, value) 

2831 

2832 @staticmethod 

2833 def _spawn_children_from_result(result: Any) -> list: 

2834 """The node's child plan at ``content.children``; [] for a valid leaf. 

2835 

2836 The director's parsed output envelope carries the plan at 

2837 ``content.children``; a result that is not a dict, or whose ``content`` 

2838 is not an object, or carries no ``children``, is a valid leaf. A 

2839 ``children`` value that is not a list is a hard ``INVALID_CHILD_PLAN``. 

2840 """ 

2841 content = _envelope_content(result) 

2842 if not isinstance(content, dict): 

2843 return [] 

2844 raw = content.get("children") 

2845 if raw is None: 

2846 return [] 

2847 if not isinstance(raw, list): 

2848 raise RunHardFailure( 

2849 "INVALID_CHILD_PLAN", 

2850 "spawn agent content 'children' must be a list", 

2851 {"children": repr(raw)[:200]}, 

2852 ) 

2853 return raw 

2854 

2855 @staticmethod 

2856 def _spawn_has_input(child: Any) -> bool: 

2857 """A valid child plan item: a dict carrying a non-empty 'input'.""" 

2858 if not isinstance(child, dict): 

2859 return False 

2860 input_val = child.get("input") 

2861 if input_val is None: 

2862 return False 

2863 if isinstance(input_val, str) and not input_val.strip(): 

2864 return False 

2865 if isinstance(input_val, (list, dict)) and not input_val: 

2866 return False 

2867 return True 

2868 

2869 @staticmethod 

2870 def _spawn_is_soft_fail(result: Any) -> bool: 

2871 """A soft/gap agent outcome (escalate, parse_error, error, budget:*) — not a hard error. 

2872 

2873 An envelope-shaped result is never soft: ``status:error`` is a hard 

2874 failure (the gate raises before this), and ``ok``/``partial``/ 

2875 ``no_success`` are valid non-fatal results. 

2876 """ 

2877 if not isinstance(result, dict): 

2878 return False 

2879 if result.get("status") in ENVELOPE_STATUSES: 

2880 return False 

2881 if _SPAWN_SOFT_FAIL_KEYS & result.keys(): 

2882 return True 

2883 return any(key.startswith("budget:") for key in result) 

2884 

2885 

2886# --------------------------------------------------------------------------- 

2887# GraphBuilder 

2888# --------------------------------------------------------------------------- 

2889 

2890def _topological_order(steps: list[dict]) -> list[dict]: 

2891 """ 

2892 Palauttaa stepit topologisessa järjestyksessä depends_on-kenttien perusteella. 

2893 """ 

2894 id_to_step = {s["id"]: s for s in steps} 

2895 visited: set[str] = set() 

2896 order: list[dict] = [] 

2897 

2898 def visit(step_id: str) -> None: 

2899 if step_id in visited: 

2900 return 

2901 visited.add(step_id) 

2902 for dep in (id_to_step[step_id].get("depends_on") or []): 

2903 if dep in id_to_step: 

2904 visit(dep) 

2905 order.append(id_to_step[step_id]) 

2906 

2907 for s in steps: 

2908 visit(s["id"]) 

2909 return order 

2910 

2911 

2912def _group_dependencies( 

2913 groups: list[list[dict]], 

2914) -> tuple[list[set[int]], list[set[int]]]: 

2915 """Group-index adjacency (predecessors, successors) from each step's depends_on.""" 

2916 step_to_group = { 

2917 step["id"]: index 

2918 for index, group in enumerate(groups) 

2919 for step in group 

2920 } 

2921 predecessors: list[set[int]] = [set() for _ in groups] 

2922 successors: list[set[int]] = [set() for _ in groups] 

2923 for index, group in enumerate(groups): 

2924 for step in group: 

2925 for dep in step.get("depends_on") or []: 

2926 dep_index = step_to_group.get(dep) 

2927 if dep_index is None or dep_index == index: 

2928 continue 

2929 predecessors[index].add(dep_index) 

2930 successors[dep_index].add(index) 

2931 return predecessors, successors 

2932 

2933 

2934@dataclass(frozen=True) 

2935class _RoutePlan: 

2936 """Compiled `routes` topology: route tables plus the static-edge suppression sets. 

2937 

2938 `routes` owns a routing step's outgoing control flow (ADR-0025). A route 

2939 target reached via a static `depends_on` edge is therefore suppressed and 

2940 replaced by the native conditional edge; the plan records both the targets 

2941 (so they are never made entry points) and the suppressed `(source, target)` 

2942 pairs. 

2943 """ 

2944 

2945 step_to_node: dict[str, str] 

2946 routes_by_step: dict[str, list[dict]] 

2947 target_node_ids: frozenset[str] 

2948 routing_node_ids: frozenset[str] 

2949 suppressed_edges: frozenset[tuple[str, str]] 

2950 

2951 

2952def _reject_nested_routes(step: dict) -> None: 

2953 """Reject `routes` on a nested `body` step (top-level-only rule, ADR-0012). 

2954 

2955 The engine wires `routes` only for top-level steps, so a nested declaration 

2956 would be silently ignored; nested routing is deferred. 

2957 """ 

2958 body = step.get("body") 

2959 if not isinstance(body, list): 

2960 return 

2961 for nested in _iter_steps(body): 

2962 if nested.get("routes"): 

2963 raise RunHardFailure( 

2964 "VALIDATION_ERROR", 

2965 f"Step '{nested.get('id')}' declares `routes` inside a nested " 

2966 "body; `routes` is only allowed on top-level steps", 

2967 ) 

2968 

2969 

2970def _build_route_plan(groups: list[list[dict]], node_ids: list[str]) -> _RoutePlan: 

2971 """Resolve every top-level step's `routes` into a node-level routing plan.""" 

2972 step_to_node = { 

2973 step["id"]: node_ids[index] 

2974 for index, group in enumerate(groups) 

2975 for step in group 

2976 } 

2977 step_to_deps = { 

2978 step["id"]: set(step.get("depends_on") or []) 

2979 for group in groups 

2980 for step in group 

2981 } 

2982 routes_by_step: dict[str, list[dict]] = {} 

2983 target_node_ids: set[str] = set() 

2984 routing_node_ids: set[str] = set() 

2985 suppressed_edges: set[tuple[str, str]] = set() 

2986 for group in groups: 

2987 for step in group: 

2988 _reject_nested_routes(step) 

2989 routes = step.get("routes") 

2990 if not routes: 

2991 continue 

2992 source = step["id"] 

2993 source_node = step_to_node[source] 

2994 routing_node_ids.add(source_node) 

2995 routes_by_step[source] = routes 

2996 for route in routes: 

2997 target = route.get("target") 

2998 target_node = step_to_node.get(target) 

2999 if target_node is None: 

3000 raise RunHardFailure( 

3001 "VALIDATION_ERROR", 

3002 f"Step '{source}' route target '{target}' is not a " 

3003 "top-level step in this pipeline", 

3004 ) 

3005 target_node_ids.add(target_node) 

3006 if source in step_to_deps.get(target, set()): 

3007 suppressed_edges.add((source_node, target_node)) 

3008 log.warning( 

3009 "[routes] suppressing static edge %s -> %s; route " 

3010 "targets are wired via `routes`", 

3011 source, 

3012 target, 

3013 ) 

3014 return _RoutePlan( 

3015 step_to_node=step_to_node, 

3016 routes_by_step=routes_by_step, 

3017 target_node_ids=frozenset(target_node_ids), 

3018 routing_node_ids=frozenset(routing_node_ids), 

3019 suppressed_edges=frozenset(suppressed_edges), 

3020 ) 

3021 

3022 

3023def _make_route_router(routes: list[dict], step_to_node: dict[str, str], end) -> Any: 

3024 """First-match-wins router over a routing step's declared routes. 

3025 

3026 Evaluates `when` in declaration order and returns the selected target node; 

3027 a route without `when` is the default branch. With no match and no default 

3028 it returns the graph terminal. 

3029 """ 

3030 def router(state: PipelineState) -> str: 

3031 ctx = _flatten_channel_state(state) if _CORE in state else state 

3032 for route in routes: 

3033 if _eval_condition(route.get("when"), ctx): 

3034 return step_to_node[route["target"]] 

3035 return end 

3036 

3037 return router 

3038 

3039 

3040def _wire_route_edges(graph, route_plan: _RoutePlan, end) -> None: 

3041 """Add one native conditional edge per routing step, with an explicit path_map.""" 

3042 for source, routes in route_plan.routes_by_step.items(): 

3043 target_nodes = [route_plan.step_to_node[r["target"]] for r in routes] 

3044 path_map = {node: node for node in target_nodes} 

3045 path_map[end] = end 

3046 graph.add_conditional_edges( 

3047 route_plan.step_to_node[source], 

3048 _make_route_router(routes, route_plan.step_to_node, end), 

3049 path_map, 

3050 ) 

3051 

3052 

3053def _wire_dependency_edges( 

3054 graph, 

3055 groups: list[list[dict]], 

3056 node_ids: list[str], 

3057 end, 

3058 route_plan: _RoutePlan, 

3059 entry_ids: list[str] | None = None, 

3060 exit_ids: list[str] | None = None, 

3061) -> None: 

3062 """Wire native branches and fan-in joins from group-level depends_on edges. 

3063 

3064 Static edges originating from a routing step to one of its route targets are 

3065 suppressed — `routes` supplies that control flow. A route target with no 

3066 other predecessor is reached by its conditional edge, never an entry point. 

3067 A group split into a Send fan-out enters through its dispatch node and exits 

3068 through its join node, so ``entry_ids``/``exit_ids`` name those boundaries. 

3069 """ 

3070 entry_ids = entry_ids if entry_ids is not None else node_ids 

3071 exit_ids = exit_ids if exit_ids is not None else node_ids 

3072 predecessors, successors = _group_dependencies(groups) 

3073 for index, node_id in enumerate(node_ids): 

3074 entry = entry_ids[index] 

3075 pred_nodes = [exit_ids[p] for p in sorted(predecessors[index])] 

3076 if not pred_nodes: 

3077 if node_id not in route_plan.target_node_ids: 

3078 graph.set_entry_point(entry) 

3079 else: 

3080 live_preds = [ 

3081 pred 

3082 for pred in pred_nodes 

3083 if (pred, entry) not in route_plan.suppressed_edges 

3084 ] 

3085 if len(live_preds) == 1: 

3086 graph.add_edge(live_preds[0], entry) 

3087 elif live_preds: 

3088 graph.add_edge(live_preds, entry) 

3089 if not successors[index] and node_id not in route_plan.routing_node_ids: 

3090 graph.add_edge(exit_ids[index], end) 

3091 

3092 

3093# --------------------------------------------------------------------------- 

3094# Call-step build-time expansion (ADR-0037 D3/D4) 

3095# --------------------------------------------------------------------------- 

3096# 

3097# A `type: call` step is composed at build time: the function's body steps plus 

3098# two synthesized op steps (`__call_bind` / `__call_emit`) are assembled into a 

3099# compiled subgraph added as a single parent-graph node keyed by the call id. The 

3100# runtime executes only native steps; LangGraph never sees a call step. The caller 

3101# reads the function outputs via the call result shape 

3102# `state[<call_id>] = {outputs: {<name>: value}}` (00-process-contract r.70). 

3103# The function body's `{{ params.<name> }}` references are rewritten to 

3104# `{{ <call_id>.params.<name> }}` so the bind step's params namespace resolves. A 

3105# call nested in a foreach/while body is flat-inlined instead, because loop bodies 

3106# execute imperatively and cannot host a compiled subgraph node. 

3107# 

3108# Isolation (ADR-0037 D5 / 00-functions § Isolation) and recursion (ADR-0037 D7): a 

3109# function body step must not reference a step-id outside its own body (a 

3110# one-way dependency check — outward references to caller step-ids are build 

3111# errors); a cycle in the function call graph (self or mutual) is a build 

3112# error. Bounded while loops are runtime constructs and are not cycle-flagged. 

3113 

3114# Call step result namespace keys (internal scaffolding). 

3115def _scope_params_value(value: Any, call_id: str) -> Any: 

3116 """Rewrite `params.` template references to the call-scoped namespace. 

3117 

3118 Operates on the function body's string values (templates). Dict keys are 

3119 untouched — only templated string VALUES are scoped, so a function body's 

3120 `{{ params.<name> }}` becomes `{{ <call_id>.params.<name> }}`. 

3121 """ 

3122 if isinstance(value, str): 

3123 return re.sub(r"\bparams\.", f"{call_id}.params.", value) 

3124 if isinstance(value, dict): 

3125 return {k: _scope_params_value(v, call_id) for k, v in value.items()} 

3126 if isinstance(value, list): 

3127 return [_scope_params_value(v, call_id) for v in value] 

3128 return value 

3129 

3130 

3131def _scope_params(step: dict, call_id: str) -> dict: 

3132 """Deep-scope a function body step's params references for a call id.""" 

3133 scoped: dict = dict(step) 

3134 for field in ("input", "params", "condition", "exit_condition", "agent", "function"): 

3135 if field in scoped and isinstance(scoped[field], (str, dict, list)): 

3136 scoped[field] = _scope_params_value(scoped[field], call_id) 

3137 if "body" in scoped and isinstance(scoped["body"], list): 

3138 scoped["body"] = [_scope_params(b, call_id) for b in scoped["body"]] 

3139 return scoped 

3140 

3141 

3142def _collect_step_ids(steps: list[dict]) -> set[str]: 

3143 """All step ids declared in *steps*, including nested body step ids.""" 

3144 ids: set[str] = set() 

3145 for step in steps: 

3146 step_id = step.get("id") 

3147 if isinstance(step_id, str): 

3148 ids.add(step_id) 

3149 body = step.get("body") 

3150 if isinstance(body, list): 

3151 ids |= _collect_step_ids(body) 

3152 return ids 

3153 

3154 

3155def _iter_steps(steps: list[dict]): 

3156 """Yield every step in *steps*, recursing into nested bodies.""" 

3157 for step in steps: 

3158 yield step 

3159 body = step.get("body") 

3160 if isinstance(body, list): 

3161 yield from _iter_steps(body) 

3162 

3163 

3164def _collect_composed_step_ids(steps: list[dict]) -> set[str]: 

3165 """All declared step ids, including ids composed into native subgraphs. 

3166 

3167 A ``call`` step becomes a ``__subgraph__`` placeholder whose inner step ids 

3168 live only inside the compiled subgraph. The placeholder records them under 

3169 ``_SUBGRAPH_STEP_IDS_KEY`` so name-collision validation sees the full set 

3170 even after body steps stop being textually inlined. 

3171 """ 

3172 ids = _collect_step_ids(steps) 

3173 for step in _iter_steps(steps): 

3174 composed_ids = step.get(_SUBGRAPH_STEP_IDS_KEY) 

3175 if isinstance(composed_ids, (set, frozenset)): 

3176 ids |= composed_ids 

3177 return ids 

3178 

3179 

3180_TEMPLATE_REF_RE = re.compile(r"\{\{\s*([^}]*)\}\}") 

3181_STRING_LITERAL_RE = re.compile(r"'[^']*'|\"[^\"]*\"") 

3182_IDENTIFIER_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") 

3183 

3184_REFERENCE_FIELDS = ("input", "params", "condition", "exit_condition") 

3185 

3186# Engine-reserved template namespaces — never step-id references, even if a 

3187# caller happens to declare a step with one of these names. 

3188_RESERVED_NAMESPACES = frozenset({"params", "loop", "item", "_item", "budget", "config"}) 

3189 

3190 

3191def _referenced_names(value: Any) -> set[str]: 

3192 """Root identifiers read by templated strings. 

3193 

3194 Only the leading identifier of each reference (before the first ``.`` or 

3195 ``[``) can name a step-id; nested field segments are ignored. Quoted 

3196 literals are ignored. 

3197 """ 

3198 names: set[str] = set() 

3199 if isinstance(value, str): 

3200 for expr in _TEMPLATE_REF_RE.findall(value): 

3201 bare = _STRING_LITERAL_RE.sub("", expr) 

3202 for match in _IDENTIFIER_RE.finditer(bare): 

3203 if match.start() == 0 or bare[match.start() - 1] != ".": 

3204 names.add(match.group()) 

3205 elif isinstance(value, dict): 

3206 for v in value.values(): 

3207 names |= _referenced_names(v) 

3208 elif isinstance(value, list): 

3209 for v in value: 

3210 names |= _referenced_names(v) 

3211 return names 

3212 

3213 

3214def _validate_function_purity( 

3215 fn_name: str, body: list[dict], visible_ids: set[str] 

3216) -> None: 

3217 """One-way isolation: a function body must not reference a caller step-id. 

3218 

3219 A body step may read its own body step-ids and the params namespace; a 

3220 reference to a step-id that exists outside the body (``visible_ids`` but 

3221 not declared in the body) is an outward dependency and a hard build error. 

3222 """ 

3223 local_ids = _collect_step_ids(body) 

3224 for step in _iter_steps(body): 

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

3226 for dep in step.get("depends_on") or []: 

3227 if dep in visible_ids and dep not in local_ids: 

3228 raise RunHardFailure( 

3229 "VALIDATION_ERROR", 

3230 f"function '{fn_name}' step '{step_id}': depends_on " 

3231 f"'{dep}' is outside the function body (isolation — " 

3232 "function bodies see only their own steps and params)", 

3233 ) 

3234 for field in _REFERENCE_FIELDS: 

3235 for ref in _referenced_names(step.get(field)): 

3236 if ref in _RESERVED_NAMESPACES: 

3237 continue 

3238 if ref in visible_ids and ref not in local_ids: 

3239 raise RunHardFailure( 

3240 "VALIDATION_ERROR", 

3241 f"function '{fn_name}' step '{step_id}': '{field}' " 

3242 f"references '{ref}', a step-id outside the function " 

3243 "body (isolation — function bodies see only their own " 

3244 "steps and params)", 

3245 ) 

3246 

3247 

3248def _reject_function_body_routes(fn_name: str, body: list[dict]) -> None: 

3249 """Reject `routes` anywhere in a function body (top-level-only rule). 

3250 

3251 A function body is inlined to the top level, so a body step declaring 

3252 `routes` would escape the nested-`body` guard and be silently rewired; 

3253 reject it at build time instead. 

3254 """ 

3255 for step in _iter_steps(body): 

3256 if step.get("routes"): 

3257 raise RunHardFailure( 

3258 "VALIDATION_ERROR", 

3259 f"function '{fn_name}' step '{step.get('id')}' declares `routes`; " 

3260 "`routes` is only allowed on top-level steps", 

3261 ) 

3262 

3263 

3264def _rewrite_call_deps(steps: list[dict], call_map: dict[str, str]) -> None: 

3265 """Rewrite `depends_on` references to call ids → the corresponding emit id. 

3266 

3267 After a call is expanded away, a downstream step that depends_on the call id 

3268 must instead wait on the emit step (which writes state[<call_id>]). This 

3269 guarantees the caller's `{{ <call_id>.outputs.<name> }}` reads resolve after 

3270 the function body ran. 

3271 """ 

3272 for step in steps: 

3273 deps = step.get("depends_on") or [] 

3274 if deps: 

3275 step["depends_on"] = [call_map.get(d, d) for d in deps] 

3276 if "body" in step and isinstance(step["body"], list): 

3277 _rewrite_call_deps(step["body"], call_map) 

3278 

3279 

3280def expand_calls(steps: list[dict], registry: ManifestRegistry) -> list[dict]: 

3281 """Expand every ``call`` step through the native subgraph composer. 

3282 

3283 Test-facing seam over the single call-expansion implementation: the same 

3284 ``_CallComposer`` that ``GraphBuilder`` uses builds the steps, so no second 

3285 (flat) expansion path exists. The embedded subgraph value is the un-compiled 

3286 sub-step list; a runnable graph only needs the compiled form, which 

3287 production obtains through ``GraphBuilder._compose_calls``. 

3288 """ 

3289 composer = _CallComposer( 

3290 registry, 

3291 assemble_subgraph=lambda sub_steps: sub_steps, 

3292 channels=[], 

3293 channel_names=frozenset(), 

3294 ) 

3295 return composer.compose(steps) 

3296 

3297 

3298_SUBGRAPH_STEP_TYPE = "__subgraph__" 

3299_SUBGRAPH_GRAPH_KEY = "__subgraph_graph__" 

3300_SUBGRAPH_STEP_IDS_KEY = "__subgraph_step_ids__" 

3301 

3302# A `while` step is composed into a `__subgraph__` placeholder whose embedded 

3303# graph is a native LangGraph cycle (head -> body -> cond -> head/exit). The 

3304# marker flags the placeholder so the parent uses the while node wrapper (which 

3305# keeps the loop-internal state out of the parent result) rather than the plain 

3306# call-subgraph node, and carries the build-time recursion bound. 

3307_SUBGRAPH_WHILE_KEY = "__subgraph_while__" 

3308_WHILE_RECURSION_LIMIT_KEY = "__while_recursion_limit__" 

3309 

3310# Engine-local cycle state: the active while's `{current, iteration, passed, 

3311# skipped, max_iter}` marker. Nested whiles live in isolated cycle invocations, 

3312# so a shared key cannot collide across them. 

3313_WHILE_STATE_KEY = "__while_state__" 

3314 

3315# State keys the while wrapper propagates from the isolated cycle back to the 

3316# parent (the loop result itself is carried separately under the step id). 

3317_WHILE_SIDE_CHANNEL_KEYS = ("errors", "step_context_metrics", "step_tool_meta") 

3318 

3319# A sequential `foreach` (max_concurrent absent or resolving to <= 1) is 

3320# composed into a `__subgraph__` placeholder whose embedded graph is a native 

3321# LangGraph cycle (head -> body/agent -> cond -> head/exit). The marker flags 

3322# the placeholder so the parent uses the foreach node wrapper, and carries the 

3323# build-time recursion bound. 

3324_SUBGRAPH_FOREACH_KEY = "__subgraph_foreach__" 

3325_FOREACH_RECURSION_LIMIT_KEY = "__foreach_recursion_limit__" 

3326 

3327# Engine-local cycle state: the active foreach's `{items, index, results, 

3328# produced_outputs, max_output}` marker. Nested foreachs live in isolated cycle 

3329# invocations, so a shared key cannot collide across them. 

3330_FOREACH_STATE_KEY = "__foreach_state__" 

3331 

3332# State keys the foreach wrapper propagates from the isolated cycle back to the 

3333# parent (the aggregate result itself is carried separately under the step id). 

3334_FOREACH_SIDE_CHANNEL_KEYS = ("errors", "step_context_metrics", "step_tool_meta") 

3335 

3336# Build-time recursion bound fallback for a foreach with no declared 

3337# `max_iterations`: the item list is uncapped, so reserve a generous bound. 

3338_FOREACH_DEFAULT_MAX_ITER = 1000 

3339 

3340 

3341class _CallComposer: 

3342 """Compose `call` steps into compiled native subgraph nodes (ADR-0037 D4). 

3343 

3344 A function body becomes its own compiled StateGraph added to the parent as a 

3345 single node keyed by the call id; the ``bind``/``emit`` scaffolding and the 

3346 body steps live inside that subgraph and share the parent state. Nested 

3347 function calls compose recursively. A `call` nested in a foreach/while body 

3348 is composed the same way; the loop body executes the embedded compiled graph 

3349 imperatively via ``run_step``'s ``__subgraph__`` branch. 

3350 """ 

3351 

3352 def __init__( 

3353 self, 

3354 registry: ManifestRegistry, 

3355 assemble_subgraph: Any, 

3356 channels: list[dict], 

3357 channel_names: frozenset[str], 

3358 *, 

3359 assemble_while: Any | None = None, 

3360 compose_whiles: bool = False, 

3361 assemble_foreach: Any | None = None, 

3362 compose_foreachs: bool = False, 

3363 build_ctx: dict | None = None, 

3364 ) -> None: 

3365 self._registry = registry 

3366 self._assemble_subgraph = assemble_subgraph 

3367 self._channels = channels 

3368 self._channel_names = channel_names 

3369 self._assemble_while = assemble_while 

3370 self._compose_whiles = compose_whiles 

3371 self._assemble_foreach = assemble_foreach 

3372 self._compose_foreachs = compose_foreachs 

3373 self._build_ctx = build_ctx or {} 

3374 self._stack: list[str] = [] 

3375 self._expanded_functions: dict[str, str] = {} 

3376 self._call_map: dict[str, str] = {} 

3377 

3378 def compose(self, steps: list[dict]) -> list[dict]: 

3379 visible_ids = _collect_step_ids(steps) 

3380 composed: list[dict] = [] 

3381 for step in steps: 

3382 composed.extend(self._compose_step(step, visible_ids=visible_ids)) 

3383 return composed 

3384 

3385 def _compose_step(self, step: dict, *, visible_ids: set[str]) -> list[dict]: 

3386 step_type = step.get("type") 

3387 if step_type == "call": 

3388 return [self._compose_subgraph_call(step, visible_ids)] 

3389 if step_type == "while" and self._compose_whiles: 

3390 return [self._compose_while(step, visible_ids)] 

3391 if step_type == "foreach" and self._should_compose_foreach(step): 

3392 return [self._compose_foreach(step, visible_ids)] 

3393 body = step.get("body") 

3394 if not isinstance(body, list): 

3395 return [step] 

3396 nested: list[dict] = [] 

3397 for sub in body: 

3398 nested.extend(self._compose_step(sub, visible_ids=visible_ids)) 

3399 _rewrite_call_deps(nested, self._call_map) 

3400 return [{**step, "body": nested}] 

3401 

3402 def _compose_while(self, step: dict, visible_ids: set[str]) -> dict: 

3403 """Compose a `while` step into a native LangGraph cycle subgraph. 

3404 

3405 The embedded graph owns the loop topology: a head that resolves the 

3406 entry condition / initial ``current``, the composed body step nodes, a 

3407 conditional back-edge that computes the feed + exit condition and routes 

3408 continue-vs-exit, and an exit node that writes the documented while 

3409 result. ``expand_calls`` leaves the step uncomposed (``compose_whiles`` 

3410 false) so the call-expansion seam still exposes the loop body. 

3411 """ 

3412 _reject_nested_routes(step) 

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

3414 nested: list[dict] = [] 

3415 for sub in body: 

3416 nested.extend(self._compose_step(sub, visible_ids=visible_ids)) 

3417 _validate_step_channel_bindings(nested, self._channel_names) 

3418 compiled, recursion_limit = self._assemble_while(step, nested) 

3419 placeholder: dict = { 

3420 "id": step["id"], 

3421 "type": _SUBGRAPH_STEP_TYPE, 

3422 _SUBGRAPH_GRAPH_KEY: compiled, 

3423 _SUBGRAPH_STEP_IDS_KEY: _collect_composed_step_ids(nested), 

3424 _SUBGRAPH_WHILE_KEY: True, 

3425 _WHILE_RECURSION_LIMIT_KEY: recursion_limit, 

3426 "depends_on": list(step.get("depends_on") or []), 

3427 } 

3428 if step.get("routes"): 

3429 placeholder["routes"] = step["routes"] 

3430 return placeholder 

3431 

3432 def _should_compose_foreach(self, step: dict) -> bool: 

3433 """Whether a `foreach` is the sequential form composed into a cycle. 

3434 

3435 Only a `max_concurrent` absent or resolving to ``<= 1`` is composed; a 

3436 declared ``max_concurrent > 1`` keeps the native ``Send`` / in-node 

3437 concurrent behaviour. 

3438 """ 

3439 if not self._compose_foreachs: 

3440 return False 

3441 return _resolved_send_concurrency(step, self._build_ctx) <= 1 

3442 

3443 def _compose_foreach(self, step: dict, visible_ids: set[str]) -> dict: 

3444 """Compose a sequential `foreach` into a native LangGraph cycle subgraph. 

3445 

3446 Mirrors ``_compose_while``: the embedded graph owns the loop topology 

3447 (a head that resolves the item list and injects the current item, the 

3448 composed body step nodes or the per-item agent node, a conditional 

3449 back-edge routing next-item-vs-exit, and an exit node that writes the 

3450 documented aggregate). ``expand_calls`` leaves the step uncomposed so 

3451 the call-expansion seam still exposes the loop body. 

3452 """ 

3453 _reject_nested_routes(step) 

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

3455 nested: list[dict] = [] 

3456 for sub in body: 

3457 nested.extend(self._compose_step(sub, visible_ids=visible_ids)) 

3458 _validate_step_channel_bindings(nested, self._channel_names) 

3459 compiled, recursion_limit = self._assemble_foreach(step, nested) 

3460 placeholder: dict = { 

3461 "id": step["id"], 

3462 "type": _SUBGRAPH_STEP_TYPE, 

3463 _SUBGRAPH_GRAPH_KEY: compiled, 

3464 _SUBGRAPH_STEP_IDS_KEY: _collect_composed_step_ids(nested), 

3465 _SUBGRAPH_FOREACH_KEY: True, 

3466 _FOREACH_RECURSION_LIMIT_KEY: recursion_limit, 

3467 "depends_on": list(step.get("depends_on") or []), 

3468 } 

3469 if step.get("routes"): 

3470 placeholder["routes"] = step["routes"] 

3471 return placeholder 

3472 

3473 def _compose_subgraph_call(self, step: dict, visible_ids: set[str]) -> dict: 

3474 call_id = step["id"] 

3475 function, body = self._check_call_guards(step, visible_ids) 

3476 

3477 nested_visible = visible_ids | _collect_step_ids(body) 

3478 outputs_spec = ( 

3479 ((function.get("spec") or {}).get("interface") or {}).get("outputs") or [] 

3480 ) 

3481 bind_id = f"_call__{call_id}__bind" 

3482 emit_id = f"_call__{call_id}__emit" 

3483 bind_step: dict = { 

3484 "id": bind_id, 

3485 "type": "builtin", 

3486 "component": "__call_bind", 

3487 "params": {**(step.get("params") or {}), "__call_id": call_id}, 

3488 "depends_on": [], 

3489 } 

3490 

3491 self._stack.append(step["function"]) 

3492 try: 

3493 expanded_body = self._compose_body( 

3494 body, call_id, bind_id, nested_visible 

3495 ) 

3496 finally: 

3497 self._stack.pop() 

3498 

3499 emit_step: dict = { 

3500 "id": emit_id, 

3501 "type": "builtin", 

3502 "component": "__call_emit", 

3503 "params": {"__call_id": call_id, "__outputs": outputs_spec}, 

3504 "depends_on": [bind_id] + [s["id"] for s in expanded_body], 

3505 } 

3506 sub_steps = [bind_step, *expanded_body, emit_step] 

3507 if self._channels: 

3508 _validate_channel_inputs(self._channels, sub_steps) 

3509 _validate_step_channel_bindings(sub_steps, self._channel_names) 

3510 placeholder: dict = { 

3511 "id": call_id, 

3512 "type": _SUBGRAPH_STEP_TYPE, 

3513 _SUBGRAPH_GRAPH_KEY: self._assemble_subgraph( 

3514 _topological_order(sub_steps) 

3515 ), 

3516 _SUBGRAPH_STEP_IDS_KEY: _collect_composed_step_ids(sub_steps), 

3517 "depends_on": list(step.get("depends_on") or []), 

3518 } 

3519 call_condition = step.get("condition") 

3520 if call_condition is not None: 

3521 placeholder["condition"] = call_condition 

3522 return placeholder 

3523 

3524 def _compose_body( 

3525 self, body: list[dict], call_id: str, bind_id: str, visible_ids: set[str] 

3526 ) -> list[dict]: 

3527 expanded: list[dict] = [] 

3528 for body_step in body: 

3529 scoped = _scope_params(body_step, call_id) 

3530 for sub in self._compose_step(scoped, visible_ids=visible_ids): 

3531 deps = list(sub.get("depends_on") or []) 

3532 if bind_id in deps: 

3533 expanded.append(sub) 

3534 else: 

3535 expanded.append({**sub, "depends_on": [bind_id, *deps]}) 

3536 return expanded 

3537 

3538 def _check_call_guards( 

3539 self, step: dict, visible_ids: set[str] 

3540 ) -> tuple[dict, list[dict]]: 

3541 """Single owner of every build-time ``call`` guard (ADR-0037 D3/D5/D7). 

3542 

3543 Rejects routes on the call step, function cycles, duplicate expansion, 

3544 unknown functions, function-body isolation (purity) violations, and 

3545 body-declared routes; returns the resolved function and its body. 

3546 """ 

3547 fn_name = step["function"] 

3548 call_id = step["id"] 

3549 

3550 if step.get("routes"): 

3551 raise RunHardFailure( 

3552 "VALIDATION_ERROR", 

3553 f"call step '{call_id}' declares `routes`; `routes` is not supported " 

3554 "on call steps", 

3555 ) 

3556 if fn_name in self._stack: 

3557 chain = " -> ".join(self._stack + [fn_name]) 

3558 raise RunHardFailure( 

3559 "VALIDATION_ERROR", 

3560 f"cycle detected in function expansion: {chain} " 

3561 "(ADR-0037 D7 — recursion must be a bounded while loop or " 

3562 "idempotent re-run)", 

3563 ) 

3564 if fn_name in self._expanded_functions: 

3565 raise RunHardFailure( 

3566 "VALIDATION_ERROR", 

3567 f"duplicate call expansion: function '{fn_name}' called at both " 

3568 f"'{self._expanded_functions[fn_name]}' and '{call_id}' in one " 

3569 "pipeline (ADR-0037 D7 — repeated calls to the same function are " 

3570 "not yet supported)", 

3571 ) 

3572 self._expanded_functions[fn_name] = call_id 

3573 

3574 function = self._registry.get("Function", fn_name) 

3575 if function is None: 

3576 raise RunHardFailure( 

3577 "VALIDATION_ERROR", 

3578 f"call step '{call_id}' references unknown function '{fn_name}' " 

3579 "(no kind: Function manifest in the workflows tree)", 

3580 ) 

3581 body = (function.get("spec") or {}).get("steps") or [] 

3582 _validate_function_purity(fn_name, body, visible_ids) 

3583 _reject_function_body_routes(fn_name, body) 

3584 return function, body 

3585 

3586 

3587_CHANNEL_TYPES = frozenset({"LastValue", "Topic", "BinaryOperatorAggregate"}) 

3588 

3589 

3590def _validate_channel_entry(index: int, channel: Any) -> None: 

3591 """Validate one spec.channels entry against the frozen ChannelSpec shape. 

3592 

3593 The engine build path does not run manifest-schema validation, so a 

3594 malformed entry is rejected here — before any ``["name"]`` indexing — with 

3595 an error that identifies the offending entry. 

3596 """ 

3597 if not isinstance(channel, dict): 

3598 raise RunHardFailure( 

3599 "CHANNEL_BINDING_ERROR", 

3600 f"Pipeline spec.channels[{index}] must be a mapping; got " 

3601 f"{type(channel).__name__}", 

3602 ) 

3603 name = channel.get("name") 

3604 if not isinstance(name, str) or not name: 

3605 raise RunHardFailure( 

3606 "CHANNEL_BINDING_ERROR", 

3607 f"Pipeline spec.channels[{index}] is missing a valid 'name'; each " 

3608 "channel requires a non-empty string 'name'", 

3609 ) 

3610 if "type" in channel and channel["type"] not in _CHANNEL_TYPES: 

3611 raise RunHardFailure( 

3612 "CHANNEL_BINDING_ERROR", 

3613 f"Pipeline spec.channels[{index}] ('{name}') has invalid type " 

3614 f"'{channel['type']}'; expected one of {sorted(_CHANNEL_TYPES)}", 

3615 ) 

3616 

3617 

3618def _validated_channels(raw_channels: Any) -> list[dict]: 

3619 """Return spec.channels as a validated list, or [] when absent. 

3620 

3621 ``None`` (absent) and an empty list are both valid no-channel pipelines. 

3622 """ 

3623 if raw_channels is None: 

3624 return [] 

3625 if not isinstance(raw_channels, list): 

3626 raise RunHardFailure( 

3627 "CHANNEL_BINDING_ERROR", 

3628 f"Pipeline spec.channels must be a list; got " 

3629 f"{type(raw_channels).__name__}", 

3630 ) 

3631 for index, channel in enumerate(raw_channels): 

3632 _validate_channel_entry(index, channel) 

3633 return raw_channels 

3634 

3635 

3636def _validate_step_channel_bindings( 

3637 steps: list[dict], channel_names: frozenset[str] 

3638) -> None: 

3639 """Reject malformed or undeclared step-level channel bindings at build time. 

3640 

3641 Every step's declared ``output_channel`` must be a non-empty declared 

3642 channel name, and every declared ``input_channels`` entry must be a 

3643 non-empty declared channel name. This runs unconditionally, so a binding 

3644 to a channel absent from ``spec.channels`` fails the build even when no 

3645 channels are declared. 

3646 """ 

3647 for step in _iter_steps(steps): 

3648 _validate_step_output_channel(step, channel_names) 

3649 _validate_step_input_channels(step, channel_names) 

3650 

3651 

3652def _validate_step_output_channel(step: dict, channel_names: frozenset[str]) -> None: 

3653 channel = step.get("output_channel") 

3654 if channel is None: 

3655 return 

3656 if not isinstance(channel, str) or not channel: 

3657 raise RunHardFailure( 

3658 "CHANNEL_BINDING_ERROR", 

3659 f"Step '{step.get('id', '?')}' output_channel must be a non-empty " 

3660 f"string; got {channel!r}", 

3661 ) 

3662 if channel not in channel_names: 

3663 raise RunHardFailure( 

3664 "CHANNEL_BINDING_ERROR", 

3665 f"Step '{step.get('id', '?')}' declares output_channel '{channel}' " 

3666 "which is not declared in spec.channels", 

3667 ) 

3668 

3669 

3670def _validate_step_input_channels(step: dict, channel_names: frozenset[str]) -> None: 

3671 channels = step.get("input_channels") 

3672 if channels is None: 

3673 return 

3674 if not isinstance(channels, list): 

3675 raise RunHardFailure( 

3676 "CHANNEL_BINDING_ERROR", 

3677 f"Step '{step.get('id', '?')}' input_channels must be a list of " 

3678 f"channel names; got {type(channels).__name__}", 

3679 ) 

3680 for channel in channels: 

3681 if not isinstance(channel, str) or not channel: 

3682 raise RunHardFailure( 

3683 "CHANNEL_BINDING_ERROR", 

3684 f"Step '{step.get('id', '?')}' input_channels has an invalid " 

3685 f"channel name '{channel}'; channel names must be non-empty " 

3686 "strings", 

3687 ) 

3688 if channel not in channel_names: 

3689 raise RunHardFailure( 

3690 "CHANNEL_BINDING_ERROR", 

3691 f"Step '{step.get('id', '?')}' reads undeclared channel " 

3692 f"'{channel}'; the channel must be declared in spec.channels", 

3693 ) 

3694 

3695 

3696def _validate_channel_names(channels: list[dict], steps: list[dict]) -> None: 

3697 """Reject duplicate and engine/step-reserved channel names.""" 

3698 forbidden = ( 

3699 {_CORE} 

3700 | set(_RESERVED_NAMESPACES) 

3701 | _CORE_STATE_KEYS 

3702 | _collect_composed_step_ids(steps) 

3703 ) 

3704 seen: set[str] = set() 

3705 for channel in channels: 

3706 name = channel.get("name") 

3707 if name in seen: 

3708 raise RunHardFailure( 

3709 "CHANNEL_BINDING_ERROR", 

3710 f"Pipeline declares channel '{name}' more than once; channel " 

3711 "names must be unique", 

3712 ) 

3713 seen.add(name) 

3714 if name in forbidden: 

3715 raise RunHardFailure( 

3716 "CHANNEL_BINDING_ERROR", 

3717 f"Pipeline channel '{name}' collides with an engine-reserved " 

3718 "name (a step id, engine state key, reserved namespace, or the " 

3719 "core state channel); channel names must be unique and " 

3720 "non-reserved", 

3721 ) 

3722 

3723 

3724def _input_path_root(value: str) -> str | None: 

3725 """Leading state key of a bare dot-path input string, or None. 

3726 

3727 Template strings, bracket-prefixed verbatim literals, and blank strings 

3728 are not state reads and yield None. 

3729 """ 

3730 if "{{" in value: 

3731 return None 

3732 stripped = value.lstrip() 

3733 if not stripped or stripped.startswith("["): 

3734 return None 

3735 return re.split(r"[.\[]", stripped, maxsplit=1)[0] 

3736 

3737 

3738def _iter_input_roots(input_spec: Any): 

3739 """Yield every bare dot-path root in an input spec, recursing into containers.""" 

3740 if isinstance(input_spec, str): 

3741 root = _input_path_root(input_spec) 

3742 if root: 

3743 yield root 

3744 elif isinstance(input_spec, dict): 

3745 for value in input_spec.values(): 

3746 yield from _iter_input_roots(value) 

3747 elif isinstance(input_spec, list): 

3748 for value in input_spec: 

3749 yield from _iter_input_roots(value) 

3750 

3751 

3752def _validate_channel_inputs(channels: list[dict], steps: list[dict]) -> None: 

3753 """Reject channel-pipeline input roots that name nothing the state exposes. 

3754 

3755 The channel state is the declared channels plus the engine state, so a bare 

3756 dot-path input root must be a declared channel, a known step id, a reserved 

3757 namespace, or an engine state key. Anything else is a hard build error — 

3758 there is no silent fallback to a missing key. 

3759 """ 

3760 allowed = ( 

3761 {ch["name"] for ch in channels} 

3762 | _collect_step_ids(steps) 

3763 | set(_RESERVED_NAMESPACES) 

3764 | _CORE_STATE_KEYS 

3765 ) 

3766 for step in _iter_steps(steps): 

3767 for root in _iter_input_roots(step.get("input")): 

3768 if root not in allowed: 

3769 raise RunHardFailure( 

3770 "CHANNEL_BINDING_ERROR", 

3771 f"Pipeline channel step '{step.get('id', '?')}' input " 

3772 f"references unknown root '{root}'; in a channel pipeline " 

3773 "an input root must be a declared channel, a step id, a " 

3774 "reserved namespace, or an engine state key", 

3775 ) 

3776 

3777 

3778def _flatten_channel_state(state: dict) -> dict: 

3779 """Read the flat committed snapshot nested under the Core namespace.""" 

3780 return dict(state.get(_CORE) or {}) 

3781 

3782 

3783def _strip_channel_keys(flat: dict, channel_names: frozenset[str]) -> dict: 

3784 """Drop channel-named keys so a node emits only the core state.""" 

3785 return {k: v for k, v in flat.items() if k not in channel_names} 

3786 

3787 

3788_FAILURE_ENVELOPE_KEYS = frozenset({"error", "parse_error"}) 

3789 

3790_NON_ERROR_ENVELOPE_STATUSES: frozenset[str] = ENVELOPE_STATUSES - {"error"} 

3791 

3792 

3793def _envelope_content(result: Any) -> Any: 

3794 """Return an envelope's optional ``content`` field (ADR-0023 D3). 

3795 

3796 ``content`` is opaque and optional: a non-mapping result, an absent field, 

3797 and an explicit ``null`` all read as ``None``. Callers apply their own 

3798 fallback and shape checks (e.g. ``[]`` for a spawn child plan). 

3799 """ 

3800 if isinstance(result, dict): 

3801 return result.get("content") 

3802 return None 

3803 

3804 

3805def _is_failure_envelope(value: Any) -> bool: 

3806 """True when a step output is not a successfully parsed dict. 

3807 

3808 Missing output, non-dict values, and the ``error``/``parse_error`` 

3809 envelopes written by the LLM failure paths are not routed. A valid 

3810 non-error envelope (``ok``/``partial``/``no_success``) is routed even when 

3811 it carries an explicit ``"error": null``. 

3812 """ 

3813 if not isinstance(value, dict): 

3814 return True 

3815 if value.get("status") in _NON_ERROR_ENVELOPE_STATUSES: 

3816 return False 

3817 return bool(_FAILURE_ENVELOPE_KEYS & value.keys()) 

3818 

3819 

3820def _declared_output_channel_writes( 

3821 step: dict, output: Any, channel_names: frozenset[str] 

3822) -> dict[str, Any]: 

3823 """Route one step output's content to its declared output channel. 

3824 

3825 Returns ``{channel_name: output["content"]}`` for the caller to stage into 

3826 the working set. Failure envelopes and steps without an ``output_channel`` 

3827 are not routed; an undeclared target channel is a hard error. 

3828 """ 

3829 if _is_failure_envelope(output): 

3830 return {} 

3831 channel = step.get("output_channel") 

3832 if not channel: 

3833 return {} 

3834 if channel not in channel_names: 

3835 raise RunHardFailure( 

3836 "CHANNEL_BINDING_ERROR", 

3837 f"Step '{step.get('id', '?')}' declares output_channel '{channel}' " 

3838 "which is not declared in spec.channels", 

3839 ) 

3840 return {channel: _envelope_content(output)} 

3841 

3842 

3843def _route_declared_channels( 

3844 step: dict, 

3845 pre_state: dict, 

3846 core: dict, 

3847 step_runner: Any, 

3848 channel_names: frozenset[str], 

3849) -> dict[str, Any]: 

3850 """Route a step's declared output channel write to its channel. 

3851 

3852 Returns ``{channel_name: value}`` for LangGraph to merge through the 

3853 channel's reducer, where ``channel_name`` is the step's single declared 

3854 ``output_channel`` and ``value`` is the agent envelope's ``content``. Only 

3855 an actual agent invocation is routed: a ``skip_llm_if`` passthrough and a 

3856 parallel fan-out envelope are excluded. A step without an ``output_channel`` 

3857 routes nothing (its output stays in ``core`` untouched), and failure 

3858 envelopes are not routed. A spawn step is routed at the spawn seam from its 

3859 node result, never here from its envelope. 

3860 """ 

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

3862 if step.get("type") == "spawn": 

3863 return {} 

3864 if step_id not in core: 

3865 return {} 

3866 agent_spec = step_runner.resolve_agent(step) 

3867 if agent_spec is None: 

3868 return {} 

3869 if not step_runner.ran_agent(step, pre_state): 

3870 return {} 

3871 return _declared_output_channel_writes(step, core[step_id], channel_names) 

3872 

3873 

3874def _make_step_node( 

3875 step: dict, 

3876 step_runner: Any, 

3877 channel_names: frozenset[str], 

3878 channel_types: dict[str, str], 

3879) -> Any: 

3880 if not channel_names: 

3881 def node_fn(state: PipelineState) -> PipelineState: 

3882 return step_runner.run_step(step, state) 

3883 return node_fn 

3884 

3885 def node_fn(state: PipelineState) -> PipelineState: 

3886 committed = _flatten_channel_state(state) 

3887 working_set = ChannelWorkingSet(committed, channel_types) 

3888 core = _strip_channel_keys( 

3889 step_runner.run_step(step, committed, working_set), channel_names 

3890 ) 

3891 writes = _route_declared_channels( 

3892 step, committed, core, step_runner, channel_names 

3893 ) 

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

3895 working_set.stage(name, value) 

3896 written_names = {name for name, _ in working_set.pending_writes()} 

3897 channels = working_set.commit() 

3898 emitted = {name: channels[name] for name in written_names} 

3899 return {_CORE: {**core, **emitted}} 

3900 return node_fn 

3901 

3902 

3903def _filter_composed_step_ids(result: dict, composed_step_ids: Any) -> dict: 

3904 """Drop function-body step ids so a call subgraph's internals stay invisible. 

3905 

3906 ADR-0037 D3: the caller sees only ``state[<call_id>].outputs`` plus 

3907 unrelated keys. ``composed_step_ids`` is the build-time set recorded on the 

3908 call placeholder. Channel-named keys are disjoint from it (a collision is a 

3909 build-time ``CHANNEL_BINDING_ERROR``), so channel state is never touched. 

3910 The input is left unmodified. 

3911 """ 

3912 if not composed_step_ids: 

3913 return dict(result) 

3914 filtered = { 

3915 key: value 

3916 for key, value in result.items() 

3917 if key not in composed_step_ids 

3918 } 

3919 core = filtered.get(_CORE) 

3920 if isinstance(core, dict): 

3921 filtered[_CORE] = { 

3922 key: value 

3923 for key, value in core.items() 

3924 if key not in composed_step_ids 

3925 } 

3926 return filtered 

3927 

3928 

3929def _make_subgraph_node( 

3930 compiled: Any, 

3931 condition: Any, 

3932 step_runner: Any, 

3933 channel_names: frozenset[str], 

3934 composed_step_ids: Any = None, 

3935) -> Any: 

3936 """Wrap a compiled call subgraph with the call step's entry condition. 

3937 

3938 A false condition returns no state updates, so the subgraph never runs and 

3939 ``state[<call_id>]`` is not written — matching the soft-skip semantics of a 

3940 normal step condition. The ctx is derived exactly like ``run_step``'s, 

3941 including channel-mode flattening. Every successful invocation is filtered 

3942 through ``_filter_composed_step_ids`` so the function body stays invisible. 

3943 """ 

3944 if condition is None: 

3945 def node_fn(state: PipelineState) -> PipelineState: 

3946 return _filter_composed_step_ids( 

3947 compiled.invoke(state), composed_step_ids 

3948 ) 

3949 return node_fn 

3950 

3951 if not channel_names: 

3952 def node_fn(state: PipelineState) -> PipelineState: 

3953 if not _eval_condition(condition, step_runner._make_ctx(state)): 

3954 return {} 

3955 return _filter_composed_step_ids( 

3956 compiled.invoke(state), composed_step_ids 

3957 ) 

3958 return node_fn 

3959 

3960 def node_fn(state: PipelineState) -> PipelineState: 

3961 committed = _flatten_channel_state(state) 

3962 if not _eval_condition(condition, step_runner._make_ctx(committed)): 

3963 return {} 

3964 return _filter_composed_step_ids( 

3965 compiled.invoke(state), composed_step_ids 

3966 ) 

3967 return node_fn 

3968 

3969 

3970# --------------------------------------------------------------------------- 

3971# Native `while` cycle nodes (ADR-0025 D1/D2) 

3972# --------------------------------------------------------------------------- 

3973# 

3974# A `while` step is a compiled cycle: `head -> body... -> cond` with a 

3975# conditional back-edge from `cond` to `head` (continue) or `exit`. The head 

3976# owns entry-condition handling and the initial `current`; the cond node owns 

3977# the feed, the iteration increment and the exit-condition evaluation; the exit 

3978# node writes the documented result and enforces `fail_if_exhausted`. The 

3979# nodes read/write the shared graph state, using the `_CORE` container when the 

3980# pipeline declares channels. 

3981 

3982 

3983def _while_marker_key(step_id: str) -> str: 

3984 """Per-while engine marker key (nested whiles live in isolated cycles).""" 

3985 return f"{_WHILE_STATE_KEY}__{step_id}" 

3986 

3987 

3988def _while_flat(state: PipelineState, channel_names: frozenset[str]) -> dict: 

3989 return _flatten_channel_state(state) if channel_names else state 

3990 

3991 

3992def _while_update(update: dict, channel_names: frozenset[str]) -> dict: 

3993 return {_CORE: update} if channel_names else update 

3994 

3995 

3996def _while_loop_context(step_id: str, marker: dict) -> dict: 

3997 """The body-visible `loop` namespace and the intermediate step result. 

3998 

3999 Written in this order so a step literally named ``loop`` shadows the 

4000 injected namespace — the legacy in-node loop had the same collision. 

4001 """ 

4002 current = marker["current"] 

4003 iteration = marker["iteration"] 

4004 update = {"loop": {"current": current, "iteration": iteration}} 

4005 update[step_id] = {"current": current, "passed": False} 

4006 return update 

4007 

4008 

4009def _while_invoke_config(step: dict) -> dict | None: 

4010 """Recursion bound for a while/foreach cycle placeholder, else None.""" 

4011 if step.get(_SUBGRAPH_WHILE_KEY): 

4012 return {"recursion_limit": step.get(_WHILE_RECURSION_LIMIT_KEY, 25)} 

4013 if step.get(_SUBGRAPH_FOREACH_KEY): 

4014 return {"recursion_limit": step.get(_FOREACH_RECURSION_LIMIT_KEY, 25)} 

4015 return None 

4016 

4017 

4018def _make_while_body_node( 

4019 sub: dict, 

4020 step_runner: Any, 

4021 channel_names: frozenset[str], 

4022 channel_types: dict[str, str], 

4023) -> Any: 

4024 """A while body step as a node: a nested subgraph or a normal step node.""" 

4025 if sub.get("type") == _SUBGRAPH_STEP_TYPE: 

4026 if sub.get(_SUBGRAPH_WHILE_KEY): 

4027 return _make_while_subgraph_node(sub, step_runner, channel_names) 

4028 if sub.get(_SUBGRAPH_FOREACH_KEY): 

4029 return _make_foreach_subgraph_node(sub, step_runner, channel_names) 

4030 return _make_subgraph_node( 

4031 sub[_SUBGRAPH_GRAPH_KEY], 

4032 sub.get("condition"), 

4033 step_runner, 

4034 channel_names, 

4035 sub.get(_SUBGRAPH_STEP_IDS_KEY), 

4036 ) 

4037 return _make_step_node(sub, step_runner, channel_names, channel_types) 

4038 

4039 

4040def _make_while_head_node( 

4041 step: dict, 

4042 step_runner: Any, 

4043 channel_names: frozenset[str], 

4044 max_iter: int, 

4045) -> Any: 

4046 """Entry node: handle the entry condition and seed the loop marker.""" 

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

4048 marker_key = _while_marker_key(step_id) 

4049 

4050 def node_fn(state: PipelineState) -> PipelineState: 

4051 flat = _while_flat(state, channel_names) 

4052 marker = flat.get(marker_key) 

4053 if marker is not None: 

4054 return _while_update( 

4055 _while_loop_context(step_id, marker), channel_names 

4056 ) 

4057 ctx = step_runner._make_ctx(flat) 

4058 entry_cond = step.get("condition") 

4059 if entry_cond is not None and not _eval_condition(entry_cond, ctx): 

4060 skip_input = _resolved_while_input(step, flat, ctx) 

4061 skip_marker = { 

4062 "current": None, 

4063 "iteration": 0, 

4064 "passed": True, 

4065 "skipped": True, 

4066 "max_iter": max_iter, 

4067 } 

4068 return _while_update( 

4069 { 

4070 step_id: { 

4071 "passed": True, 

4072 "iterations": 0, 

4073 "output": skip_input, 

4074 "exhausted": False, 

4075 "skipped": True, 

4076 }, 

4077 marker_key: skip_marker, 

4078 }, 

4079 channel_names, 

4080 ) 

4081 marker = { 

4082 "current": _resolved_while_input(step, flat, ctx), 

4083 "iteration": 0, 

4084 "passed": False, 

4085 "skipped": False, 

4086 "max_iter": max_iter, 

4087 } 

4088 update = _while_loop_context(step_id, marker) 

4089 update[marker_key] = marker 

4090 return _while_update(update, channel_names) 

4091 

4092 return node_fn 

4093 

4094 

4095def _resolved_while_input(step: dict, flat: dict, ctx: dict) -> Any: 

4096 input_spec = step.get("input") 

4097 if input_spec is None: 

4098 return None 

4099 return _resolve_input(input_spec, flat, ctx) 

4100 

4101 

4102def _make_while_head_router( 

4103 step: dict, 

4104 channel_names: frozenset[str], 

4105 first_id: str, 

4106 exit_id: str, 

4107 end: Any, 

4108) -> Any: 

4109 """Route from the head: skipped → end, bound already reached → exit, else body.""" 

4110 marker_key = _while_marker_key(step.get("id", "?")) 

4111 

4112 def router(state: PipelineState) -> str: 

4113 marker = _while_flat(state, channel_names).get(marker_key) or {} 

4114 if marker.get("skipped"): 

4115 return end 

4116 if marker.get("iteration", 0) >= marker.get("max_iter", 0): 

4117 return exit_id 

4118 return first_id 

4119 

4120 return router 

4121 

4122 

4123def _make_while_cond_node( 

4124 step: dict, 

4125 step_runner: Any, 

4126 channel_names: frozenset[str], 

4127) -> Any: 

4128 """Feed the declared output, increment the iteration, evaluate the exit.""" 

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

4130 marker_key = _while_marker_key(step_id) 

4131 declared_output = step.get("output") 

4132 exit_condition = step.get("exit_condition") 

4133 

4134 def node_fn(state: PipelineState) -> PipelineState: 

4135 flat = _while_flat(state, channel_names) 

4136 marker = dict(flat.get(marker_key) or {}) 

4137 current = marker.get("current") 

4138 if declared_output: 

4139 feed_value = flat.get(declared_output) 

4140 if isinstance(feed_value, dict) and "output" in feed_value: 

4141 current = feed_value["output"] 

4142 elif isinstance(feed_value, dict) and isinstance( 

4143 feed_value.get("results"), list 

4144 ): 

4145 current = feed_value["results"] 

4146 else: 

4147 current = feed_value 

4148 passed = False 

4149 if exit_condition: 

4150 if _eval_condition( 

4151 exit_condition, step_runner._make_ctx(flat) 

4152 ): 

4153 passed = True 

4154 else: 

4155 passed = True 

4156 marker.update( 

4157 { 

4158 "current": current, 

4159 "iteration": marker.get("iteration", 0) + 1, 

4160 "passed": passed, 

4161 } 

4162 ) 

4163 return _while_update({marker_key: marker}, channel_names) 

4164 

4165 return node_fn 

4166 

4167 

4168def _make_while_cond_router( 

4169 step: dict, 

4170 channel_names: frozenset[str], 

4171 head_id: str, 

4172 exit_id: str, 

4173) -> Any: 

4174 """Route from the cond: exit on pass or when the bound is reached, else head.""" 

4175 marker_key = _while_marker_key(step.get("id", "?")) 

4176 

4177 def router(state: PipelineState) -> str: 

4178 marker = _while_flat(state, channel_names).get(marker_key) or {} 

4179 if marker.get("passed"): 

4180 return exit_id 

4181 if marker.get("iteration", 0) >= marker.get("max_iter", 0): 

4182 return exit_id 

4183 return head_id 

4184 

4185 return router 

4186 

4187 

4188def _make_while_exit_node( 

4189 step: dict, 

4190 step_runner: Any, 

4191 channel_names: frozenset[str], 

4192) -> Any: 

4193 """Write the documented while result and enforce `fail_if_exhausted`.""" 

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

4195 marker_key = _while_marker_key(step_id) 

4196 fail_if_exhausted = step.get("fail_if_exhausted") 

4197 

4198 def node_fn(state: PipelineState) -> PipelineState: 

4199 flat = _while_flat(state, channel_names) 

4200 marker = flat.get(marker_key) or {} 

4201 passed = bool(marker.get("passed", False)) 

4202 exhausted = not passed 

4203 if ( 

4204 fail_if_exhausted is not None 

4205 and exhausted 

4206 and _eval_condition( 

4207 fail_if_exhausted, step_runner._make_ctx(flat) 

4208 ) 

4209 ): 

4210 raise RunHardFailure( 

4211 "EXHAUSTED", 

4212 f"while step '{step_id}' exhausted " 

4213 f"max_iterations={marker.get('max_iter')} without " 

4214 "exit_condition passing (fail_if_exhausted)", 

4215 {"step_id": step_id, "exhausted": True}, 

4216 ) 

4217 return _while_update( 

4218 { 

4219 step_id: { 

4220 "passed": passed, 

4221 "iterations": marker.get("iteration", 0), 

4222 "output": marker.get("current"), 

4223 "exhausted": exhausted, 

4224 "skipped": False, 

4225 } 

4226 }, 

4227 channel_names, 

4228 ) 

4229 

4230 return node_fn 

4231 

4232 

4233def _make_while_subgraph_node( 

4234 placeholder: dict, 

4235 step_runner: Any, 

4236 channel_names: frozenset[str], 

4237) -> Any: 

4238 """Parent node for a compiled `while` cycle: isolate and surface its result. 

4239 

4240 The cycle shares the graph state schema, so invoking it directly would leak 

4241 the body step results and loop plumbing into the parent. This wrapper runs 

4242 the cycle on a copy and returns only the documented result plus the side 

4243 channels, preserving the in-node loop's state contract exactly. 

4244 """ 

4245 compiled = placeholder[_SUBGRAPH_GRAPH_KEY] 

4246 step_id = placeholder["id"] 

4247 config = {"recursion_limit": placeholder.get(_WHILE_RECURSION_LIMIT_KEY, 25)} 

4248 

4249 if not channel_names: 

4250 def node_fn(state: PipelineState) -> PipelineState: 

4251 sub = compiled.invoke(state, config) 

4252 result = {step_id: sub.get(step_id)} 

4253 for key in _WHILE_SIDE_CHANNEL_KEYS: 

4254 if key in sub and sub.get(key) != state.get(key): 

4255 result[key] = sub.get(key) 

4256 return result 

4257 return node_fn 

4258 

4259 def node_fn(state: PipelineState) -> PipelineState: 

4260 sub = compiled.invoke(state, config) 

4261 flat = _flatten_channel_state(sub) 

4262 parent_flat = _flatten_channel_state(state) 

4263 result = {step_id: flat.get(step_id)} 

4264 for key in _WHILE_SIDE_CHANNEL_KEYS: 

4265 if key in flat and flat.get(key) != parent_flat.get(key): 

4266 result[key] = flat.get(key) 

4267 return {_CORE: _strip_channel_keys(result, channel_names)} 

4268 

4269 return node_fn 

4270 

4271 

4272# --------------------------------------------------------------------------- 

4273# Native sequential `foreach` cycle nodes 

4274# --------------------------------------------------------------------------- 

4275# 

4276# A sequential `foreach` is a compiled cycle: `head -> (body... | agent) -> 

4277# cond` with a conditional back-edge from `cond` to `head` (next item) or 

4278# `exit` (`max_output` reached or the item list exhausted). The head resolves 

4279# the item list once and injects the current item (`item` / `_item` / 

4280# `_foreach_item`); the cond collects the item result; the exit node writes the 

4281# documented aggregate (`{"results", "passed", "failing", "failed"}` for the 

4282# body path, the plain list for the agent path). Nodes read/write the shared 

4283# graph state, using the `_CORE` container when the pipeline declares channels. 

4284 

4285 

4286def _foreach_marker_key(step_id: str) -> str: 

4287 """Per-foreach engine marker key (nested foreachs live in isolated cycles).""" 

4288 return f"{_FOREACH_STATE_KEY}__{step_id}" 

4289 

4290 

4291def _foreach_flat(state: PipelineState, channel_names: frozenset[str]) -> dict: 

4292 return _flatten_channel_state(state) if channel_names else state 

4293 

4294 

4295def _foreach_update(update: dict, channel_names: frozenset[str]) -> dict: 

4296 return {_CORE: update} if channel_names else update 

4297 

4298 

4299def _make_foreach_head_node( 

4300 step: dict, 

4301 step_runner: Any, 

4302 channel_names: frozenset[str], 

4303) -> Any: 

4304 """Entry node: evaluate the entry condition, resolve items, inject the item.""" 

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

4306 marker_key = _foreach_marker_key(step_id) 

4307 

4308 def node_fn(state: PipelineState) -> PipelineState: 

4309 flat = _foreach_flat(state, channel_names) 

4310 marker = flat.get(marker_key) 

4311 if marker is None: 

4312 ctx = step_runner._make_ctx(flat) 

4313 entry_cond = step.get("condition") 

4314 if entry_cond is not None and not _eval_condition(entry_cond, ctx): 

4315 return _foreach_update({}, channel_names) 

4316 max_output_raw = step.get("max_output") 

4317 marker = { 

4318 "items": step_runner._foreach_item_list(step, flat, ctx), 

4319 "index": 0, 

4320 "results": [], 

4321 "produced_outputs": 0, 

4322 "max_output": ( 

4323 _resolve_int(max_output_raw, ctx, 0) 

4324 if max_output_raw is not None 

4325 else 0 

4326 ), 

4327 } 

4328 items = marker.get("items") or [] 

4329 index = marker.get("index", 0) 

4330 if index < len(items): 

4331 update = { 

4332 "_item": items[index], 

4333 "item": items[index], 

4334 "_foreach_item": items[index], 

4335 } 

4336 update[marker_key] = marker 

4337 return _foreach_update(update, channel_names) 

4338 return _foreach_update({marker_key: marker}, channel_names) 

4339 

4340 return node_fn 

4341 

4342 

4343def _make_foreach_head_router( 

4344 step: dict, 

4345 channel_names: frozenset[str], 

4346 first_id: str, 

4347 exit_id: str, 

4348 end: Any, 

4349) -> Any: 

4350 """Route from the head: condition-skipped → end, exhausted/budget → exit, else body.""" 

4351 marker_key = _foreach_marker_key(step.get("id", "?")) 

4352 

4353 def router(state: PipelineState) -> str: 

4354 marker = _foreach_flat(state, channel_names).get(marker_key) 

4355 if marker is None: 

4356 return end 

4357 items = marker.get("items") or [] 

4358 if marker.get("index", 0) >= len(items): 

4359 return exit_id 

4360 max_output = marker.get("max_output", 0) 

4361 if max_output and marker.get("produced_outputs", 0) >= max_output: 

4362 return exit_id 

4363 return first_id 

4364 

4365 return router 

4366 

4367 

4368def _make_foreach_body_node( 

4369 sub: dict, 

4370 step_runner: Any, 

4371 channel_names: frozenset[str], 

4372 channel_types: dict[str, str], 

4373) -> Any: 

4374 """A foreach body step as a node: a nested loop subgraph or a step node.""" 

4375 if sub.get("type") == _SUBGRAPH_STEP_TYPE: 

4376 if sub.get(_SUBGRAPH_WHILE_KEY): 

4377 return _make_while_subgraph_node(sub, step_runner, channel_names) 

4378 if sub.get(_SUBGRAPH_FOREACH_KEY): 

4379 return _make_foreach_subgraph_node(sub, step_runner, channel_names) 

4380 return _make_subgraph_node( 

4381 sub[_SUBGRAPH_GRAPH_KEY], 

4382 sub.get("condition"), 

4383 step_runner, 

4384 channel_names, 

4385 sub.get(_SUBGRAPH_STEP_IDS_KEY), 

4386 ) 

4387 return _make_step_node(sub, step_runner, channel_names, channel_types) 

4388 

4389 

4390def _make_foreach_agent_node( 

4391 step: dict, 

4392 step_runner: Any, 

4393 channel_names: frozenset[str], 

4394 channel_types: dict[str, str], 

4395) -> Any: 

4396 """Agent-path node: invoke the foreach step's agent for the current item.""" 

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

4398 

4399 def node_fn(state: PipelineState) -> PipelineState: 

4400 flat = _foreach_flat(state, channel_names) 

4401 resolved = step_runner.resolve_agent(step) 

4402 ctx = step_runner._make_ctx(flat) 

4403 resolved_step = step_runner._resolve_step_runtime(step, flat, ctx) 

4404 resolved_step = step_runner._attach_communication_context(resolved_step, flat) 

4405 resolved_step = step_runner._attach_resolved_tools(resolved_step, resolved) 

4406 result_state = step_runner._invoke_agent(resolved, resolved_step, flat) 

4407 if not channel_names: 

4408 return result_state 

4409 return {_CORE: _strip_channel_keys(result_state, channel_names)} 

4410 

4411 return node_fn 

4412 

4413 

4414def _make_foreach_cond_node( 

4415 step: dict, 

4416 step_runner: Any, 

4417 channel_names: frozenset[str], 

4418) -> Any: 

4419 """Collect the current item's result and advance the item index.""" 

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

4421 marker_key = _foreach_marker_key(step_id) 

4422 declared_output = step.get("output") 

4423 runs_body = bool(step.get("body")) 

4424 

4425 def node_fn(state: PipelineState) -> PipelineState: 

4426 flat = _foreach_flat(state, channel_names) 

4427 marker = dict(flat.get(marker_key) or {}) 

4428 results = list(marker.get("results") or []) 

4429 produced = marker.get("produced_outputs", 0) 

4430 if runs_body: 

4431 results.append(StepRunner._body_item_result(flat, declared_output)) 

4432 publish_out = flat.get("publish") 

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

4434 produced += 1 

4435 else: 

4436 results.append(flat.get(step_id)) 

4437 marker.update( 

4438 { 

4439 "results": results, 

4440 "index": marker.get("index", 0) + 1, 

4441 "produced_outputs": produced, 

4442 } 

4443 ) 

4444 return _foreach_update({marker_key: marker}, channel_names) 

4445 

4446 return node_fn 

4447 

4448 

4449def _make_foreach_cond_router( 

4450 step: dict, 

4451 channel_names: frozenset[str], 

4452 head_id: str, 

4453 exit_id: str, 

4454) -> Any: 

4455 """Route from the cond: exit on exhaustion or budget, else back to the head.""" 

4456 marker_key = _foreach_marker_key(step.get("id", "?")) 

4457 

4458 def router(state: PipelineState) -> str: 

4459 marker = _foreach_flat(state, channel_names).get(marker_key) or {} 

4460 items = marker.get("items") or [] 

4461 if marker.get("index", 0) >= len(items): 

4462 return exit_id 

4463 max_output = marker.get("max_output", 0) 

4464 if max_output and marker.get("produced_outputs", 0) >= max_output: 

4465 return exit_id 

4466 return head_id 

4467 

4468 return router 

4469 

4470 

4471def _make_foreach_exit_node( 

4472 step: dict, 

4473 step_runner: Any, 

4474 channel_names: frozenset[str], 

4475) -> Any: 

4476 """Write the documented foreach aggregate result.""" 

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

4478 marker_key = _foreach_marker_key(step_id) 

4479 runs_body = bool(step.get("body")) 

4480 

4481 def node_fn(state: PipelineState) -> PipelineState: 

4482 flat = _foreach_flat(state, channel_names) 

4483 marker = flat.get(marker_key) or {} 

4484 results = list(marker.get("results") or []) 

4485 step_result = ( 

4486 StepRunner._aggregate_body_results(results) if runs_body else results 

4487 ) 

4488 return _foreach_update({step_id: step_result}, channel_names) 

4489 

4490 return node_fn 

4491 

4492 

4493def _make_foreach_subgraph_node( 

4494 placeholder: dict, 

4495 step_runner: Any, 

4496 channel_names: frozenset[str], 

4497) -> Any: 

4498 """Parent node for a compiled sequential `foreach` cycle: isolate and surface. 

4499 

4500 The cycle shares the graph state schema, so invoking it directly would leak 

4501 body step results and loop plumbing into the parent. This wrapper runs the 

4502 cycle on a copy and returns only the documented aggregate plus the side 

4503 channels. A condition-skipped entry writes no step result (matching the 

4504 in-node soft-skip), so the step id is only surfaced when the cycle wrote it. 

4505 """ 

4506 compiled = placeholder[_SUBGRAPH_GRAPH_KEY] 

4507 step_id = placeholder["id"] 

4508 config = {"recursion_limit": placeholder.get(_FOREACH_RECURSION_LIMIT_KEY, 25)} 

4509 

4510 if not channel_names: 

4511 def node_fn(state: PipelineState) -> PipelineState: 

4512 sub = compiled.invoke(state, config) 

4513 if step_id not in sub: 

4514 return {} 

4515 result = {step_id: sub.get(step_id)} 

4516 for key in _FOREACH_SIDE_CHANNEL_KEYS: 

4517 if key in sub and sub.get(key) != state.get(key): 

4518 result[key] = sub.get(key) 

4519 return result 

4520 return node_fn 

4521 

4522 def node_fn(state: PipelineState) -> PipelineState: 

4523 sub = compiled.invoke(state, config) 

4524 flat = _flatten_channel_state(sub) 

4525 parent_flat = _flatten_channel_state(state) 

4526 if step_id not in flat: 

4527 return {} 

4528 result = {step_id: flat.get(step_id)} 

4529 for key in _FOREACH_SIDE_CHANNEL_KEYS: 

4530 if key in flat and flat.get(key) != parent_flat.get(key): 

4531 result[key] = flat.get(key) 

4532 return {_CORE: _strip_channel_keys(result, channel_names)} 

4533 

4534 return node_fn 

4535 

4536 

4537def _collect_parallel_channel_writes( 

4538 steps: list[dict], 

4539 pre_state: dict, 

4540 core: dict, 

4541 step_runner: Any, 

4542 channel_names: frozenset[str], 

4543 branch_working_sets: dict[str, ChannelWorkingSet] | None = None, 

4544) -> list[tuple[str, Any]]: 

4545 """Collect one parallel node's declared writes in branch order. 

4546 

4547 Every branch write is emitted, so fan-in feeds the channel-type reducer 

4548 every write in deterministic declaration/branch order: Topic appends each 

4549 write, LastValue keeps the last, and BinaryOperatorAggregate combines them 

4550 per its accumulated shape (list concat, collection-map union, dict merge, 

4551 scalar last). A branch's isolated body writes are emitted before its own 

4552 declared write, mirroring the top-level node's route-after-run order. 

4553 """ 

4554 writes: list[tuple[str, Any]] = [] 

4555 for step in steps: 

4556 if branch_working_sets is not None: 

4557 branch_working_set = branch_working_sets.get(step["id"]) 

4558 if branch_working_set is not None: 

4559 writes.extend(branch_working_set.pending_writes()) 

4560 branch = _route_declared_channels( 

4561 step, pre_state, core, step_runner, channel_names 

4562 ) 

4563 for channel_name, value in branch.items(): 

4564 writes.append((channel_name, value)) 

4565 return writes 

4566 

4567 

4568def _make_parallel_node( 

4569 group_steps: list[dict], 

4570 step_runner: Any, 

4571 channel_names: frozenset[str], 

4572 channel_types: dict[str, str], 

4573) -> Any: 

4574 def node_fn(state: PipelineState) -> PipelineState: 

4575 base = ( 

4576 _flatten_channel_state(state) 

4577 if channel_names else state 

4578 ) 

4579 working_set = ( 

4580 ChannelWorkingSet(base, channel_types) if channel_names else None 

4581 ) 

4582 # Each branch writes into its own fork; the shared set is advanced only 

4583 # at fan-in, so branch-internal body writes never race. 

4584 branch_working_sets = ( 

4585 {s["id"]: working_set.fork() for s in group_steps} 

4586 if working_set is not None else None 

4587 ) 

4588 

4589 def submit_branch(pool: Any, step: dict) -> Any: 

4590 if branch_working_sets is None: 

4591 return pool.submit(step_runner.run_step, step, base) 

4592 return pool.submit( 

4593 step_runner.run_step, step, base, branch_working_sets[step["id"]] 

4594 ) 

4595 

4596 results: dict[str, Any] = {} 

4597 branch_metrics: dict = {} 

4598 branch_tool_meta: dict = {} 

4599 with ThreadPoolExecutor(max_workers=len(group_steps)) as pool: 

4600 futures = {submit_branch(pool, s): s["id"] for s in group_steps} 

4601 for future in as_completed(futures): 

4602 sid = futures[future] 

4603 try: 

4604 out = future.result() 

4605 results[sid] = out.get(sid) 

4606 branch_metrics.update(out.get("step_context_metrics") or {}) 

4607 branch_tool_meta.update(out.get("step_tool_meta") or {}) 

4608 except SkillLoadError: 

4609 raise 

4610 except RunHardFailure: 

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

4612 raise 

4613 except Exception as exc: # noqa: BLE001 

4614 log.error("Step '%s' failed: %s", sid, exc) 

4615 results[sid] = {"error": str(exc)} 

4616 if channel_names: 

4617 merged = _strip_channel_keys({**base, **results}, channel_names) 

4618 if branch_metrics: 

4619 merged["step_context_metrics"] = _merge_side_channel( 

4620 merged.get("step_context_metrics"), branch_metrics 

4621 ) 

4622 if branch_tool_meta: 

4623 merged["step_tool_meta"] = _merge_side_channel( 

4624 merged.get("step_tool_meta"), branch_tool_meta 

4625 ) 

4626 writes = _collect_parallel_channel_writes( 

4627 group_steps, base, merged, step_runner, channel_names, 

4628 branch_working_sets, 

4629 ) 

4630 written_names = {name for name, _ in writes} 

4631 for name, value in writes: 

4632 working_set.stage(name, value) 

4633 channels = working_set.commit() 

4634 emitted = {name: channels[name] for name in written_names} 

4635 return {_CORE: {**merged, **emitted}} 

4636 final: PipelineState = {**state, **results} 

4637 if branch_metrics: 

4638 final = { 

4639 **final, 

4640 "step_context_metrics": _merge_side_channel( 

4641 state.get("step_context_metrics"), branch_metrics 

4642 ), 

4643 } 

4644 if branch_tool_meta: 

4645 final = { 

4646 **final, 

4647 "step_tool_meta": _merge_side_channel( 

4648 state.get("step_tool_meta"), branch_tool_meta 

4649 ), 

4650 } 

4651 return final 

4652 return node_fn 

4653 

4654 

4655@dataclass(frozen=True) 

4656class _MapReduceParts: 

4657 """The nodes and edges of one concurrent step's Send fan-out. 

4658 

4659 ``dispatch_id`` reuses the step id so the step's incoming dependency edges are 

4660 unchanged; ``join_id`` carries the step's outgoing edges. 

4661 """ 

4662 

4663 dispatch_id: str 

4664 worker_id: str 

4665 fallback_id: str 

4666 join_id: str 

4667 dispatch_fn: Any 

4668 route_fn: Any 

4669 worker_fn: Any 

4670 join_fn: Any 

4671 

4672 

4673@dataclass(frozen=True) 

4674class _MapReduceItemOutcome: 

4675 """One worker's per-item result plus its errors, side channels and writes. 

4676 

4677 ``errors`` carries the item's soft errors (and the inherited soft errors it 

4678 read), so the join can fold them in item order exactly like the in-node 

4679 concurrent path. ``channel_writes`` is empty for the parallel strategy (its 

4680 channel writes are derived from the item result at the join) and carries the 

4681 foreach body's pending writes so the join can stage them in item order. 

4682 """ 

4683 

4684 result: Any 

4685 metrics: dict | None 

4686 tool_meta: dict | None 

4687 channel_writes: tuple[tuple[str, Any], ...] 

4688 errors: tuple[str, ...] = () 

4689 

4690 

4691@dataclass(frozen=True) 

4692class _MapReducePlan: 

4693 """The per-step behaviour injected into the shared Send machinery. 

4694 

4695 The parallel strategy runs a single agent call per item; the foreach 

4696 strategy runs the body (or agent) per item. ``item_lister`` resolves the 

4697 fan-out list, ``item_runner`` executes one item, ``result_assembler`` 

4698 shapes the step result, and ``channel_write_stager`` stages per-item 

4699 channel writes at the join. 

4700 """ 

4701 

4702 item_lister: Any 

4703 item_runner: Any 

4704 result_assembler: Any 

4705 channel_write_stager: Any 

4706 

4707 

4708def _map_reduce_worker_payload(flat: dict, item: Any, index: int) -> dict: 

4709 """Build a worker's ``Send`` payload from the resolved source state.""" 

4710 return {**flat, "item": item, "_item": item, _MAP_REDUCE_ITEM_INDEX: index} 

4711 

4712 

4713def _map_reduce_worker_output( 

4714 step_id: str, 

4715 index: int, 

4716 outcome: _MapReduceItemOutcome, 

4717) -> PipelineState: 

4718 """The accumulator record carrying every per-item fold input. 

4719 

4720 The slot is ``{index: {"result", "writes", "errors", "metrics", 

4721 "tool_meta"}}`` so the join can assemble the step result, stage channel 

4722 writes, and fold soft errors plus side channels in item order — the same 

4723 observable result as the in-node concurrent path. 

4724 """ 

4725 record = { 

4726 "result": outcome.result, 

4727 "writes": list(outcome.channel_writes), 

4728 "errors": list(outcome.errors), 

4729 "metrics": outcome.metrics if isinstance(outcome.metrics, dict) else None, 

4730 "tool_meta": ( 

4731 outcome.tool_meta if isinstance(outcome.tool_meta, dict) else None 

4732 ), 

4733 } 

4734 return {_MAP_REDUCE_ACCUMULATOR_KEY: {step_id: {index: record}}} 

4735 

4736 

4737def _make_map_reduce_route_fn( 

4738 plan: _MapReducePlan, 

4739 step_runner: Any, 

4740 channel_names: frozenset[str], 

4741 worker_id: str, 

4742 fallback_id: str, 

4743 join_id: str, 

4744) -> Any: 

4745 def route(state: PipelineState) -> list: 

4746 flat = _flatten_channel_state(state) if channel_names else state 

4747 ctx = step_runner._make_ctx(flat) 

4748 items = plan.item_lister(flat, ctx) 

4749 if items is None: 

4750 return [Send(fallback_id, state)] 

4751 if not items: 

4752 return [Send(join_id, state)] 

4753 return [ 

4754 Send(worker_id, _map_reduce_worker_payload(flat, item, index)) 

4755 for index, item in enumerate(items) 

4756 ] 

4757 

4758 return route 

4759 

4760 

4761def _make_map_reduce_worker_fn( 

4762 step: dict, 

4763 plan: _MapReducePlan, 

4764 step_runner: Any, 

4765 gate: threading.Semaphore, 

4766) -> Any: 

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

4768 

4769 def worker(state: PipelineState) -> PipelineState: 

4770 index = state[_MAP_REDUCE_ITEM_INDEX] 

4771 item_state = { 

4772 key: value 

4773 for key, value in state.items() 

4774 if key != _MAP_REDUCE_ITEM_INDEX 

4775 } 

4776 item = item_state.get("item") 

4777 item_ctx = step_runner._make_ctx(item_state) 

4778 with gate: 

4779 try: 

4780 outcome = plan.item_runner( 

4781 state, index, item, item_state, item_ctx 

4782 ) 

4783 except SkillLoadError: 

4784 raise 

4785 except RunHardFailure: 

4786 raise 

4787 except Exception as exc: # noqa: BLE001 

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

4789 outcome = _MapReduceItemOutcome( 

4790 {"error": str(exc)}, None, None, () 

4791 ) 

4792 return _map_reduce_worker_output(step_id, index, outcome) 

4793 

4794 return worker 

4795 

4796 

4797def _fold_map_reduce_errors(records: list, base_errors: list) -> list: 

4798 """Fold every item's soft errors into *base_errors* in item order.""" 

4799 merged = list(base_errors) 

4800 for record in records: 

4801 if not isinstance(record, dict): 

4802 continue 

4803 merged = StepRunner._merge_errors(merged, record.get("errors") or []) 

4804 return merged 

4805 

4806 

4807def _fold_map_reduce_side_channel(records: list, record_key: str) -> dict: 

4808 """Fold every item's side-channel map in item order (later item wins).""" 

4809 folded: dict = {} 

4810 for record in records: 

4811 if not isinstance(record, dict): 

4812 continue 

4813 value = record.get(record_key) 

4814 if isinstance(value, dict): 

4815 folded.update(value) 

4816 return folded 

4817 

4818 

4819def _fold_map_reduce_extras(flat: dict, records: list) -> dict: 

4820 """The join's per-item state extras: soft errors plus folded side channels. 

4821 

4822 Mirrors the in-node concurrent path: errors are merged in item order with 

4823 dedup, and the budget/side-channel counters follow the same item order. 

4824 """ 

4825 extras: dict = {} 

4826 base_errors = list(flat.get("errors") or []) 

4827 errors = _fold_map_reduce_errors(records, base_errors) 

4828 if errors != base_errors: 

4829 extras["errors"] = errors 

4830 for state_key, record_key in ( 

4831 ("step_context_metrics", "metrics"), 

4832 ("step_tool_meta", "tool_meta"), 

4833 ): 

4834 folded = _fold_map_reduce_side_channel(records, record_key) 

4835 if folded: 

4836 extras[state_key] = _merge_side_channel(flat.get(state_key), folded) 

4837 return extras 

4838 

4839 

4840def _make_map_reduce_join_fn( 

4841 step: dict, 

4842 plan: _MapReducePlan, 

4843 step_runner: Any, 

4844 channel_names: frozenset[str], 

4845 channel_types: dict[str, str], 

4846) -> Any: 

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

4848 

4849 def join(state: PipelineState) -> PipelineState: 

4850 flat = _flatten_channel_state(state) if channel_names else state 

4851 accumulated = state.get(_MAP_REDUCE_ACCUMULATOR_KEY) or {} 

4852 entries = accumulated.get(step_id) or {} 

4853 clear = {_MAP_REDUCE_ACCUMULATOR_KEY: {step_id: None}} 

4854 ctx = step_runner._make_ctx(flat) 

4855 items = plan.item_lister(flat, ctx) 

4856 if items is None: 

4857 return clear 

4858 records = [entries.get(index) for index in range(len(items))] 

4859 results = [ 

4860 record.get("result") if isinstance(record, dict) else None 

4861 for record in records 

4862 ] 

4863 step_result = plan.result_assembler(results) 

4864 extras = _fold_map_reduce_extras(flat, records) 

4865 if not channel_names: 

4866 return {step_id: step_result, **extras, **clear} 

4867 working_set = ChannelWorkingSet(flat, channel_types) 

4868 plan.channel_write_stager(results, records, working_set) 

4869 written_names = {name for name, _ in working_set.pending_writes()} 

4870 channels = working_set.commit() 

4871 emitted = {name: channels[name] for name in written_names} 

4872 core = _strip_channel_keys( 

4873 {**flat, step_id: step_result, **extras}, channel_names 

4874 ) 

4875 return {_CORE: {**core, **emitted}, **clear} 

4876 

4877 return join 

4878 

4879 

4880def _make_parallel_map_reduce_plan( 

4881 step: dict, step_runner: Any 

4882) -> _MapReducePlan: 

4883 """Plan for a concurrent `parallel` step: one agent call per item.""" 

4884 resolved_agent = step_runner.resolve_agent(step) 

4885 

4886 def run_item(state, index, item, item_state, item_ctx): 

4887 result, metrics, tool_meta, errors = step_runner._invoke_parallel_item( 

4888 step, resolved_agent, item, item_state, item_ctx 

4889 ) 

4890 return _MapReduceItemOutcome( 

4891 result, metrics, tool_meta, (), tuple(errors) 

4892 ) 

4893 

4894 return _MapReducePlan( 

4895 item_lister=lambda flat, ctx: step_runner._parallel_fanout_item_list( 

4896 step, flat, ctx 

4897 ), 

4898 item_runner=run_item, 

4899 result_assembler=lambda results: {"results": results}, 

4900 channel_write_stager=lambda results, records, working_set: ( 

4901 step_runner._stage_fanout_channel_writes(step, results, working_set) 

4902 ), 

4903 ) 

4904 

4905 

4906def _worker_committed_flat( 

4907 state: PipelineState, channel_names: frozenset[str] 

4908) -> dict: 

4909 """The committed channel snapshot visible to one worker. 

4910 

4911 A channels-path worker receives the flattened snapshot as its payload, so 

4912 its channel keys sit at the top level; the typed ``__core__`` shape (only 

4913 seen by the join) is unwrapped. 

4914 """ 

4915 if _CORE in state: 

4916 return _flatten_channel_state(state) 

4917 return {name: state[name] for name in channel_names if name in state} 

4918 

4919 

4920def _pending_channel_writes( 

4921 working_set: ChannelWorkingSet | None, 

4922) -> tuple[tuple[str, Any], ...]: 

4923 if working_set is None: 

4924 return () 

4925 return tuple(working_set.pending_writes()) 

4926 

4927 

4928def _stage_map_reduce_item_channel_writes( 

4929 results: list, records: list, working_set: ChannelWorkingSet 

4930) -> None: 

4931 """Stage each item's carried channel writes in item order.""" 

4932 for record in records: 

4933 if not isinstance(record, dict): 

4934 continue 

4935 for name, value in record.get("writes") or (): 

4936 working_set.stage(name, value) 

4937 

4938 

4939def _make_foreach_map_reduce_plan( 

4940 step: dict, 

4941 step_runner: Any, 

4942 channel_names: frozenset[str], 

4943 channel_types: dict[str, str], 

4944) -> _MapReducePlan: 

4945 """Plan for a concurrent `foreach` step: the body (or agent) runs per item.""" 

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

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

4948 declared_output = step.get("output") 

4949 runs_body = bool(body) 

4950 

4951 def result_assembler(results): 

4952 if runs_body: 

4953 return StepRunner._aggregate_body_results(results) 

4954 return results 

4955 

4956 def run_item(state, index, item, item_state, _item_ctx): 

4957 item_working_set = ( 

4958 ChannelWorkingSet( 

4959 _worker_committed_flat(state, channel_names), channel_types 

4960 ) 

4961 if channel_names 

4962 else None 

4963 ) 

4964 iter_state = { 

4965 **item_state, "item": item, "_item": item, "_foreach_item": item 

4966 } 

4967 if runs_body: 

4968 for sub_step in body: 

4969 iter_state = step_runner._run_body_step( 

4970 sub_step, iter_state, item_working_set 

4971 ) 

4972 return _MapReduceItemOutcome( 

4973 step_runner._body_item_result(iter_state, declared_output), 

4974 iter_state.get("step_context_metrics"), 

4975 iter_state.get("step_tool_meta"), 

4976 _pending_channel_writes(item_working_set), 

4977 tuple(iter_state.get("errors") or []), 

4978 ) 

4979 resolved = step_runner.resolve_agent(step) 

4980 item_read_state = step_runner._read_state(iter_state, item_working_set) 

4981 item_ctx = step_runner._make_ctx(item_read_state) 

4982 resolved_step = step_runner._resolve_step_runtime( 

4983 step, item_read_state, item_ctx 

4984 ) 

4985 resolved_step = step_runner._attach_communication_context( 

4986 resolved_step, item_read_state 

4987 ) 

4988 resolved_step = step_runner._attach_resolved_tools( 

4989 resolved_step, resolved 

4990 ) 

4991 result_state = step_runner._invoke_agent( 

4992 resolved, resolved_step, iter_state 

4993 ) 

4994 return _MapReduceItemOutcome( 

4995 result_state.get(step_id), 

4996 result_state.get("step_context_metrics"), 

4997 result_state.get("step_tool_meta"), 

4998 _pending_channel_writes(item_working_set), 

4999 tuple(result_state.get("errors") or []), 

5000 ) 

5001 

5002 return _MapReducePlan( 

5003 item_lister=lambda flat, ctx: step_runner._foreach_item_list( 

5004 step, flat, ctx 

5005 ), 

5006 item_runner=run_item, 

5007 result_assembler=result_assembler, 

5008 channel_write_stager=_stage_map_reduce_item_channel_writes, 

5009 ) 

5010 

5011 

5012def _make_map_reduce_parts( 

5013 step: dict, 

5014 plan: _MapReducePlan, 

5015 step_runner: Any, 

5016 channel_names: frozenset[str], 

5017 channel_types: dict[str, str], 

5018 max_concurrent: int, 

5019) -> _MapReduceParts: 

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

5021 worker_id = f"{step_id}__worker" 

5022 fallback_id = f"{step_id}__fallback" 

5023 join_id = f"{step_id}__join" 

5024 gate = threading.Semaphore(max(1, max_concurrent)) 

5025 return _MapReduceParts( 

5026 dispatch_id=step_id, 

5027 worker_id=worker_id, 

5028 fallback_id=fallback_id, 

5029 join_id=join_id, 

5030 dispatch_fn=lambda state: {}, 

5031 route_fn=_make_map_reduce_route_fn( 

5032 plan, step_runner, channel_names, worker_id, fallback_id, join_id 

5033 ), 

5034 worker_fn=_make_map_reduce_worker_fn(step, plan, step_runner, gate), 

5035 join_fn=_make_map_reduce_join_fn( 

5036 step, plan, step_runner, channel_names, channel_types 

5037 ), 

5038 ) 

5039 

5040 

5041def _resolved_send_concurrency(step: dict, build_ctx: dict) -> int: 

5042 """The build-time resolved ``max_concurrent`` for a Send-eligible step.""" 

5043 return _resolve_int(step.get("max_concurrent"), build_ctx, 1) 

5044 

5045 

5046def _is_send_parallel_step( 

5047 step: dict, route_plan: "_RoutePlan", build_ctx: dict 

5048) -> bool: 

5049 """Whether a top-level `parallel` step fans out through native ``Send``. 

5050 

5051 Restricted to a singleton group's statically concurrent, agent-backed, 

5052 item-sourced step with no `routes`, no `condition`/`skip_llm_if`, and an 

5053 explicit `max_concurrent > 1`; everything else keeps the in-node path. 

5054 """ 

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

5056 return False 

5057 if step.get("id") in route_plan.target_node_ids: 

5058 return False 

5059 if step.get("routes") or not step.get("agent"): 

5060 return False 

5061 if step.get("condition") is not None or step.get("skip_llm_if") is not None: 

5062 return False 

5063 if StepRunner._declared_item_source(step) is None: 

5064 return False 

5065 if step.get("max_concurrent") is None: 

5066 return False 

5067 return _resolved_send_concurrency(step, build_ctx) > 1 

5068 

5069 

5070def _is_send_foreach_step( 

5071 step: dict, route_plan: "_RoutePlan", build_ctx: dict 

5072) -> bool: 

5073 """Whether a top-level `foreach` step fans out through native ``Send``. 

5074 

5075 Restricted to a singleton group's body- or agent-backed, item-sourced step 

5076 with no `routes`, no `condition`/`skip_llm_if`, no step-level 

5077 `output_channel`, and no `max_output`; an explicit `max_concurrent > 1` is 

5078 required. The default (absent or ``1``) keeps the sequential in-node path, 

5079 and the excluded edges keep their existing in-node semantics. 

5080 """ 

5081 if step.get("type") != "foreach": 

5082 return False 

5083 if step.get("id") in route_plan.target_node_ids: 

5084 return False 

5085 if step.get("routes") or step.get("output_channel"): 

5086 return False 

5087 if step.get("condition") is not None or step.get("skip_llm_if") is not None: 

5088 return False 

5089 if step.get("max_output") is not None: 

5090 return False 

5091 if StepRunner._declared_item_source(step) is None: 

5092 return False 

5093 if not (step.get("body") or step.get("agent")): 

5094 return False 

5095 if step.get("max_concurrent") is None: 

5096 return False 

5097 return _resolved_send_concurrency(step, build_ctx) > 1 

5098 

5099 

5100class _ChannelGraphShim: 

5101 """Flat-dict boundary over a Core-owned channel-schema compiled graph. 

5102 

5103 Preserves the invoke contract used by main.py and tests: a flat dict in, 

5104 a flat dict out. The flat input is seeded through ``seed_committed`` and 

5105 nested under ``__core__`` for the compiled graph; the committed snapshot is 

5106 unwrapped back out. Streaming and state surfaces are not exposed because 

5107 they would leak or accept the internal ``__core__`` shape; read-only 

5108 introspection delegates to the compiled graph. 

5109 """ 

5110 

5111 def __init__(self, compiled: Any, specs: list[ChannelSpec]) -> None: 

5112 self._compiled = compiled 

5113 self._specs = specs 

5114 

5115 def invoke(self, flat_state: dict, *args: Any, **kwargs: Any) -> dict: 

5116 seeded = seed_committed(self._specs, flat_state) 

5117 result = self._compiled.invoke(wrap(seeded), *args, **kwargs) 

5118 return unwrap(result) 

5119 

5120 async def ainvoke(self, flat_state: dict, *args: Any, **kwargs: Any) -> dict: 

5121 seeded = seed_committed(self._specs, flat_state) 

5122 result = await self._compiled.ainvoke(wrap(seeded), *args, **kwargs) 

5123 return unwrap(result) 

5124 

5125 def stream(self, *args: Any, **kwargs: Any) -> Any: 

5126 self._reject_internal_shape("stream") 

5127 

5128 def astream(self, *args: Any, **kwargs: Any) -> Any: 

5129 self._reject_internal_shape("astream") 

5130 

5131 def update_state(self, *args: Any, **kwargs: Any) -> Any: 

5132 self._reject_internal_shape("update_state") 

5133 

5134 def aupdate_state(self, *args: Any, **kwargs: Any) -> Any: 

5135 self._reject_internal_shape("aupdate_state") 

5136 

5137 def get_state(self, *args: Any, **kwargs: Any) -> Any: 

5138 self._reject_internal_shape("get_state") 

5139 

5140 def aget_state(self, *args: Any, **kwargs: Any) -> Any: 

5141 self._reject_internal_shape("aget_state") 

5142 

5143 def _reject_internal_shape(self, surface: str) -> None: 

5144 raise NotImplementedError( 

5145 f"_ChannelGraphShim.{surface}() is not supported: only the flat " 

5146 ".invoke()/.ainvoke() contract is exposed through the channel " 

5147 "boundary shim" 

5148 ) 

5149 

5150 def __getattr__(self, name: str) -> Any: 

5151 return getattr(self._compiled, name) 

5152 

5153 

5154class GraphBuilder: 

5155 """ 

5156 Rakentaa LangGraph StateGraphin Pipeline-manifestista. 

5157 

5158 run_context: template-kontekstidata (budget, config jne.) — ei tallennu stateen, 

5159 käytetään vain {{ }} -templatejen evaluointiin. 

5160 """ 

5161 

5162 def __init__( 

5163 self, 

5164 registry: ManifestRegistry, 

5165 builtins: BuiltinExecutor | None = None, 

5166 invoke_agent_fn: Any | None = None, 

5167 run_context: dict | None = None, 

5168 tracer: Any | None = None, 

5169 ) -> None: 

5170 self._registry = registry 

5171 self._resolver = AgentResolver(registry) 

5172 self._invoke_agent_fn = invoke_agent_fn 

5173 self._builtins = builtins or _DEFAULT_BUILTINS 

5174 if invoke_agent_fn is not None: 

5175 self._builtins.invoke_agent_fn = invoke_agent_fn 

5176 self._step_functions = StepFunctionExecutor(registry) 

5177 self._tool_registry = build_platform_registry() 

5178 self._tool_resolver = ToolResolver(self._tool_registry, self._step_functions) 

5179 self._step_runner = StepRunner( 

5180 self._resolver, self._builtins, invoke_agent_fn, run_context, 

5181 step_functions=self._step_functions, 

5182 tracer=tracer, 

5183 tool_resolver=self._tool_resolver, 

5184 ) 

5185 

5186 def build(self, pipeline: dict): # -> CompiledGraph 

5187 """Rakentaa ja palauttaa compilatun LangGraph StateGraphin.""" 

5188 try: 

5189 from langgraph.graph import StateGraph, END 

5190 except ImportError as exc: 

5191 raise ImportError( 

5192 "langgraph is required to build a workflow. " 

5193 "Install it with: pip install langgraph" 

5194 ) from exc 

5195 

5196 raw_steps: list[dict] = (pipeline.get("spec") or {}).get("steps") or [] 

5197 

5198 channels: list[dict] = _validated_channels( 

5199 (pipeline.get("spec") or {}).get("channels") 

5200 ) 

5201 specs = parse_channel_specs( 

5202 [{"name": ch["name"], "type": ch.get("type", LAST_VALUE)} for ch in channels] 

5203 ) 

5204 channel_types = {spec.name: spec.type for spec in specs} 

5205 channel_names = frozenset(channel_types) 

5206 

5207 # Build-time call-step composition (ADR-0037 D4): each invoked Function 

5208 # body is compiled into its own native LangGraph subgraph node, so the 

5209 # runtime graph executes only native steps; a call step never survives. 

5210 steps = self._compose_calls( 

5211 raw_steps, channels, channel_names, channel_types 

5212 ) 

5213 if channels: 

5214 _validate_channel_names(channels, steps) 

5215 _validate_channel_inputs(channels, steps) 

5216 _validate_step_channel_bindings(steps, channel_names) 

5217 ordered = _topological_order(steps) 

5218 

5219 # Validate the pipeline's env map (spec.envs must be a map — ADR-0012). 

5220 envs = (pipeline.get("spec") or {}).get("envs") 

5221 if envs is not None and not isinstance(envs, dict): 

5222 raise RunHardFailure( 

5223 "VALIDATION_ERROR", 

5224 f"Pipeline '{((pipeline.get('metadata') or {}).get('name') or '?')}': " 

5225 "spec.envs must be a map of env name → image ref (ADR-0012)", 

5226 ) 

5227 

5228 graph = self._assemble_graph(ordered, channel_names, channel_types) 

5229 compiled = graph.compile() 

5230 if channel_names: 

5231 return _ChannelGraphShim(compiled, specs) 

5232 return compiled 

5233 

5234 def _compose_calls( 

5235 self, 

5236 steps: list[dict], 

5237 channels: list[dict], 

5238 channel_names: frozenset[str], 

5239 channel_types: dict[str, str], 

5240 ) -> list[dict]: 

5241 """Replace `call` steps with compiled native subgraph nodes. 

5242 

5243 Each invoked Function body is assembled through ``_assemble_graph`` and 

5244 compiled, then substituted as a single ``__subgraph__`` placeholder that 

5245 keeps the call id and its ``depends_on``. Downstream steps addressing the 

5246 call id therefore address the subgraph node unchanged. 

5247 """ 

5248 

5249 def assemble_subgraph(sub_steps: list[dict]): 

5250 return self._assemble_graph( 

5251 sub_steps, channel_names, channel_types 

5252 ).compile() 

5253 

5254 def assemble_while(while_step: dict, body_steps: list[dict]): 

5255 return self._assemble_while_graph( 

5256 while_step, body_steps, channel_names, channel_types 

5257 ) 

5258 

5259 def assemble_foreach(foreach_step: dict, body_steps: list[dict]): 

5260 return self._assemble_foreach_graph( 

5261 foreach_step, body_steps, channel_names, channel_types 

5262 ) 

5263 

5264 composer = _CallComposer( 

5265 self._registry, 

5266 assemble_subgraph, 

5267 channels, 

5268 channel_names, 

5269 assemble_while=assemble_while, 

5270 compose_whiles=True, 

5271 assemble_foreach=assemble_foreach, 

5272 compose_foreachs=True, 

5273 build_ctx=self._step_runner._make_ctx({}), 

5274 ) 

5275 return composer.compose(steps) 

5276 

5277 def _assemble_while_graph( 

5278 self, 

5279 step: dict, 

5280 body_steps: list[dict], 

5281 channel_names: frozenset[str], 

5282 channel_types: dict[str, str], 

5283 ) -> tuple[Any, int]: 

5284 """Compile a `while` step's body into a native LangGraph cycle. 

5285 

5286 The cycle is ``head -> body... -> cond`` with a conditional back-edge 

5287 from ``cond`` to ``head`` (continue) or ``exit`` (termination): the 

5288 entry condition/initial value live in the head, the feed + exit 

5289 evaluation in the cond node, and the exit node writes the documented 

5290 result. Returns the compiled cycle and the recursion bound the wrapper 

5291 must apply (a cycle consumes one super-step per node per iteration). 

5292 """ 

5293 from langgraph.graph import StateGraph, END 

5294 

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

5296 step_runner = self._step_runner 

5297 step_runner.set_channel_routing(channel_names) 

5298 

5299 state_schema = ( 

5300 _build_state_schema() 

5301 if channel_names 

5302 else Annotated[dict[str, Any], _merge_state] 

5303 ) 

5304 graph: StateGraph = StateGraph(state_schema) 

5305 

5306 max_iter = _resolve_int( 

5307 step.get("max_iterations", 2), step_runner._make_ctx({}), 2 

5308 ) 

5309 head_id = f"__while_{step_id}__head" 

5310 cond_id = f"__while_{step_id}__cond" 

5311 exit_id = f"__while_{step_id}__exit" 

5312 first_id = body_steps[0]["id"] if body_steps else cond_id 

5313 

5314 graph.add_node( 

5315 head_id, 

5316 _make_while_head_node(step, step_runner, channel_names, max_iter), 

5317 ) 

5318 for sub in body_steps: 

5319 graph.add_node( 

5320 sub["id"], 

5321 _make_while_body_node( 

5322 sub, step_runner, channel_names, channel_types 

5323 ), 

5324 ) 

5325 graph.add_node( 

5326 cond_id, _make_while_cond_node(step, step_runner, channel_names) 

5327 ) 

5328 graph.add_node( 

5329 exit_id, _make_while_exit_node(step, step_runner, channel_names) 

5330 ) 

5331 

5332 graph.set_entry_point(head_id) 

5333 head_paths = {first_id: first_id, exit_id: exit_id, END: END} 

5334 graph.add_conditional_edges( 

5335 head_id, 

5336 _make_while_head_router(step, channel_names, first_id, exit_id, END), 

5337 head_paths, 

5338 ) 

5339 for previous, sub in zip(body_steps, body_steps[1:]): 

5340 graph.add_edge(previous["id"], sub["id"]) 

5341 if body_steps: 

5342 graph.add_edge(body_steps[-1]["id"], cond_id) 

5343 graph.add_conditional_edges( 

5344 cond_id, 

5345 _make_while_cond_router(step, channel_names, head_id, exit_id), 

5346 {head_id: head_id, exit_id: exit_id}, 

5347 ) 

5348 graph.add_edge(exit_id, END) 

5349 

5350 recursion_limit = max(25, (len(body_steps) + 3) * (max_iter + 1) + 10) 

5351 return graph.compile(), recursion_limit 

5352 

5353 def _assemble_foreach_graph( 

5354 self, 

5355 step: dict, 

5356 body_steps: list[dict], 

5357 channel_names: frozenset[str], 

5358 channel_types: dict[str, str], 

5359 ) -> tuple[Any, int]: 

5360 """Compile a sequential `foreach` step's body into a native LangGraph cycle. 

5361 

5362 The cycle is ``head -> (body... | agent) -> cond`` with a conditional 

5363 back-edge from ``cond`` to ``head`` (next item) or ``exit`` (exhaustion 

5364 or `max_output` reached). The head resolves the item list once and 

5365 injects the current item; the cond collects the item result; the exit 

5366 node writes the documented aggregate. Returns the compiled cycle and 

5367 the recursion bound the wrapper must apply. 

5368 """ 

5369 from langgraph.graph import StateGraph, END 

5370 

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

5372 step_runner = self._step_runner 

5373 step_runner.set_channel_routing(channel_names) 

5374 

5375 state_schema = ( 

5376 _build_state_schema() 

5377 if channel_names 

5378 else Annotated[dict[str, Any], _merge_state] 

5379 ) 

5380 graph: StateGraph = StateGraph(state_schema) 

5381 

5382 max_iter = _resolve_int( 

5383 step.get("max_iterations"), 

5384 step_runner._make_ctx({}), 

5385 _FOREACH_DEFAULT_MAX_ITER, 

5386 ) 

5387 head_id = f"__foreach_{step_id}__head" 

5388 cond_id = f"__foreach_{step_id}__cond" 

5389 exit_id = f"__foreach_{step_id}__exit" 

5390 agent_id = f"__foreach_{step_id}__agent" 

5391 

5392 graph.add_node( 

5393 head_id, _make_foreach_head_node(step, step_runner, channel_names) 

5394 ) 

5395 if body_steps: 

5396 for sub in body_steps: 

5397 graph.add_node( 

5398 sub["id"], 

5399 _make_foreach_body_node( 

5400 sub, step_runner, channel_names, channel_types 

5401 ), 

5402 ) 

5403 first_id = body_steps[0]["id"] 

5404 else: 

5405 graph.add_node( 

5406 agent_id, 

5407 _make_foreach_agent_node( 

5408 step, step_runner, channel_names, channel_types 

5409 ), 

5410 ) 

5411 first_id = agent_id 

5412 graph.add_node( 

5413 cond_id, _make_foreach_cond_node(step, step_runner, channel_names) 

5414 ) 

5415 graph.add_node( 

5416 exit_id, _make_foreach_exit_node(step, step_runner, channel_names) 

5417 ) 

5418 

5419 graph.set_entry_point(head_id) 

5420 graph.add_conditional_edges( 

5421 head_id, 

5422 _make_foreach_head_router(step, channel_names, first_id, exit_id, END), 

5423 {first_id: first_id, exit_id: exit_id, END: END}, 

5424 ) 

5425 for previous, sub in zip(body_steps, body_steps[1:]): 

5426 graph.add_edge(previous["id"], sub["id"]) 

5427 if body_steps: 

5428 graph.add_edge(body_steps[-1]["id"], cond_id) 

5429 else: 

5430 graph.add_edge(agent_id, cond_id) 

5431 graph.add_conditional_edges( 

5432 cond_id, 

5433 _make_foreach_cond_router(step, channel_names, head_id, exit_id), 

5434 {head_id: head_id, exit_id: exit_id}, 

5435 ) 

5436 graph.add_edge(exit_id, END) 

5437 

5438 recursion_limit = max(25, (len(body_steps) + 3) * (max_iter + 1) + 10) 

5439 return graph.compile(), recursion_limit 

5440 

5441 def _assemble_graph( 

5442 self, 

5443 steps: list[dict], 

5444 channel_names: frozenset[str], 

5445 channel_types: dict[str, str], 

5446 ) -> "StateGraph": 

5447 from langgraph.graph import StateGraph, END 

5448 

5449 # Group steps into LangGraph nodes: 

5450 # Steps with the same depends_on set AND type==parallel → one node (LangGraph-level parallel) 

5451 nodes: list[list[dict]] = [] 

5452 for step in steps: 

5453 dep_sig = frozenset(step.get("depends_on") or []) 

5454 if nodes: 

5455 last_group_sig = frozenset( 

5456 dep 

5457 for s in nodes[-1] 

5458 for dep in (s.get("depends_on") or []) 

5459 ) 

5460 last_types = {s.get("type") for s in nodes[-1]} 

5461 if ( 

5462 dep_sig == last_group_sig 

5463 and step.get("type") == "parallel" 

5464 and last_types == {"parallel"} 

5465 and not step.get("routes") 

5466 and not any(s.get("routes") for s in nodes[-1]) 

5467 ): 

5468 nodes[-1].append(step) 

5469 continue 

5470 nodes.append([step]) 

5471 

5472 state_schema = ( 

5473 _build_state_schema() 

5474 if channel_names 

5475 else Annotated[dict[str, Any], _merge_state] 

5476 ) 

5477 graph: StateGraph = StateGraph(state_schema) 

5478 step_runner = self._step_runner 

5479 step_runner.set_channel_routing(channel_names) 

5480 

5481 node_ids: list[str] = [] 

5482 for group in nodes: 

5483 if len(group) == 1: 

5484 node_ids.append(group[0]["id"]) 

5485 else: 

5486 node_ids.append("parallel_" + "_".join(s["id"] for s in group)) 

5487 

5488 route_plan = _build_route_plan(nodes, node_ids) if nodes else None 

5489 

5490 send_parts: dict[int, _MapReduceParts] = {} 

5491 if route_plan is not None: 

5492 build_ctx = step_runner._make_ctx({}) 

5493 for index, group in enumerate(nodes): 

5494 if len(group) != 1: 

5495 continue 

5496 send_candidate = group[0] 

5497 if _is_send_parallel_step(send_candidate, route_plan, build_ctx): 

5498 plan = _make_parallel_map_reduce_plan( 

5499 send_candidate, step_runner 

5500 ) 

5501 elif _is_send_foreach_step(send_candidate, route_plan, build_ctx): 

5502 plan = _make_foreach_map_reduce_plan( 

5503 send_candidate, step_runner, channel_names, channel_types 

5504 ) 

5505 else: 

5506 continue 

5507 send_parts[index] = _make_map_reduce_parts( 

5508 send_candidate, 

5509 plan, 

5510 step_runner, 

5511 channel_names, 

5512 channel_types, 

5513 _resolved_send_concurrency(send_candidate, build_ctx), 

5514 ) 

5515 

5516 entry_ids: list[str] = [] 

5517 exit_ids: list[str] = [] 

5518 for index, group in enumerate(nodes): 

5519 parts = send_parts.get(index) 

5520 if parts is None: 

5521 captured_group = list(group) 

5522 if len(captured_group) == 1: 

5523 first = captured_group[0] 

5524 if first.get("type") == _SUBGRAPH_STEP_TYPE: 

5525 if first.get(_SUBGRAPH_WHILE_KEY): 

5526 subgraph_node = _make_while_subgraph_node( 

5527 first, step_runner, channel_names 

5528 ) 

5529 elif first.get(_SUBGRAPH_FOREACH_KEY): 

5530 subgraph_node = _make_foreach_subgraph_node( 

5531 first, step_runner, channel_names 

5532 ) 

5533 else: 

5534 subgraph_node = _make_subgraph_node( 

5535 first[_SUBGRAPH_GRAPH_KEY], 

5536 first.get("condition"), 

5537 step_runner, 

5538 channel_names, 

5539 first.get(_SUBGRAPH_STEP_IDS_KEY), 

5540 ) 

5541 graph.add_node(node_ids[index], subgraph_node) 

5542 entry_ids.append(node_ids[index]) 

5543 exit_ids.append(node_ids[index]) 

5544 continue 

5545 fn = _make_step_node( 

5546 first, step_runner, channel_names, channel_types, 

5547 ) 

5548 else: 

5549 fn = _make_parallel_node( 

5550 captured_group, 

5551 step_runner, 

5552 channel_names, 

5553 channel_types, 

5554 ) 

5555 graph.add_node(node_ids[index], fn) 

5556 entry_ids.append(node_ids[index]) 

5557 exit_ids.append(node_ids[index]) 

5558 continue 

5559 fallback_step = group[0] 

5560 graph.add_node(parts.dispatch_id, parts.dispatch_fn) 

5561 graph.add_node(parts.worker_id, parts.worker_fn) 

5562 graph.add_node( 

5563 parts.fallback_id, 

5564 _make_step_node( 

5565 fallback_step, step_runner, channel_names, channel_types 

5566 ), 

5567 ) 

5568 graph.add_node(parts.join_id, parts.join_fn) 

5569 graph.add_conditional_edges( 

5570 parts.dispatch_id, 

5571 parts.route_fn, 

5572 [parts.worker_id, parts.fallback_id], 

5573 ) 

5574 graph.add_edge(parts.worker_id, parts.join_id) 

5575 graph.add_edge(parts.fallback_id, parts.join_id) 

5576 entry_ids.append(parts.dispatch_id) 

5577 exit_ids.append(parts.join_id) 

5578 

5579 if nodes: 

5580 _wire_dependency_edges( 

5581 graph, nodes, node_ids, END, route_plan, entry_ids, exit_ids 

5582 ) 

5583 _wire_route_edges(graph, route_plan, END) 

5584 else: 

5585 graph.add_node("noop", lambda s: s) 

5586 graph.set_entry_point("noop") 

5587 graph.add_edge("noop", END) 

5588 

5589 return graph 

5590 

5591 

5592# --------------------------------------------------------------------------- 

5593# Julkinen API 

5594# --------------------------------------------------------------------------- 

5595 

5596_registry_cache: ManifestRegistry | None = None 

5597 

5598 

5599def _get_registry(dirs: list[Path] | None = None) -> ManifestRegistry: 

5600 global _registry_cache 

5601 if _registry_cache is None or dirs is not None: 

5602 loader = ManifestLoader(dirs) 

5603 manifests = loader.load_all() 

5604 _registry_cache = ManifestRegistry(manifests) 

5605 return _registry_cache 

5606 

5607 

5608def build_workflow( 

5609 pipeline_name: str, 

5610 *, 

5611 dirs: list[Path] | None = None, 

5612 builtins: BuiltinExecutor | None = None, 

5613 invoke_agent_fn: Any | None = None, 

5614 run_context: dict | None = None, 

5615 tracer: Any | None = None, 

5616): 

5617 """ 

5618 Lataa Pipeline-manifesti nimeltä *pipeline_name* ja rakentaa 

5619 LangGraph-workflown. 

5620 

5621 Args: 

5622 pipeline_name: Pipeline metadata.name -arvo. 

5623 dirs: Manifestihakemistot (oletus: MANIFEST_DIRS). 

5624 builtins: Oma BuiltinExecutor (oletus: _DEFAULT_BUILTINS). 

5625 invoke_agent_fn: fn(resolved_spec, step, state) → state. 

5626 run_context: Template-kontekstidata, esim. {"budget": {...}, "config": {...}}. 

5627 

5628 Returns: 

5629 Compilattu LangGraph CompiledGraph. 

5630 """ 

5631 registry = _get_registry(dirs) 

5632 pipeline = registry.get("Pipeline", pipeline_name) 

5633 if pipeline is None: 

5634 available = [ 

5635 (m.get("metadata") or {}).get("name") 

5636 for m in registry.all_of_kind("Pipeline") 

5637 ] 

5638 raise RunHardFailure( 

5639 "VALIDATION_ERROR", 

5640 f"Pipeline '{pipeline_name}' not found. Available: {available}", 

5641 ) 

5642 builder = GraphBuilder(registry, builtins, invoke_agent_fn, run_context, tracer=tracer) 

5643 return builder.build(pipeline)