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

1843 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-16 06:41 +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 contextvars 

19import hashlib 

20import logging 

21import os 

22import re 

23import threading 

24import uuid 

25from concurrent.futures import ThreadPoolExecutor, as_completed 

26from pathlib import Path 

27from typing import Any 

28 

29import yaml 

30 

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

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

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

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

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

36try: 

37 from _llm import DEFAULT_MAX_PARALLEL, SkillLoadError # noqa: F401 

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

39 from langgraph._llm import DEFAULT_MAX_PARALLEL, SkillLoadError # noqa: F401 

40 

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

42try: 

43 from _step_functions import StepFunctionExecutor # noqa: F401 

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

45 from langgraph._step_functions import StepFunctionExecutor # noqa: F401 

46 

47# Core-owned prompt-text registry for whitelisted builtins — same dual-import 

48# pattern as _llm. 

49try: 

50 from _instructions import ( 

51 USAGE_INSTRUCTIONS_KEY, 

52 instruction_text, 

53 resolve_usage_instructions, 

54 ) 

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

56 from langgraph._instructions import ( 

57 USAGE_INSTRUCTIONS_KEY, 

58 instruction_text, 

59 resolve_usage_instructions, 

60 ) 

61 

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

63# dual-import pattern as _llm. 

64try: 

65 from _data_structure import ( 

66 COMMUNICATION_CONTEXT_KEY, 

67 resolve_communication_context, 

68 ) 

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

70 from langgraph._data_structure import ( 

71 COMMUNICATION_CONTEXT_KEY, 

72 resolve_communication_context, 

73 ) 

74 

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

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

77# native LangGraph channels. 

78try: 

79 from _channels import ( 

80 CORE_KEY, 

81 LAST_VALUE, 

82 ChannelSpec, 

83 ChannelWorkingSet, 

84 parse_channel_specs, 

85 seed_committed, 

86 unwrap, 

87 wrap, 

88 ) 

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

90 from langgraph._channels import ( 

91 CORE_KEY, 

92 LAST_VALUE, 

93 ChannelSpec, 

94 ChannelWorkingSet, 

95 parse_channel_specs, 

96 seed_committed, 

97 unwrap, 

98 wrap, 

99 ) 

100 

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

102try: 

103 from _envelope import ENVELOPE_ERROR_CODE, ENVELOPE_STATUSES, is_error_envelope 

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

105 from langgraph._envelope import ( # noqa: F401 

106 ENVELOPE_ERROR_CODE, 

107 ENVELOPE_STATUSES, 

108 is_error_envelope, 

109 ) 

110 

111log = logging.getLogger(__name__) 

112 

113 

114class RunHardFailure(Exception): 

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

116 

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

118 (kuratoidun kontekstin katkaisu), TOOL_FAILURE-koodi (työkalun 

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

120 ENVELOPE_ERROR_CODE) tai kontekstiylityksen signatuurin kantava 

121 VALIDATION_ERROR (assembled_tokens + context_budget, 

122 _llm._context_overflow_result). 

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

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

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

126 """ 

127 

128 HARD_ERROR_CODES: frozenset[str] = frozenset( 

129 {"TRUNCATION_ERROR", "TOOL_FAILURE", ENVELOPE_ERROR_CODE} 

130 ) 

131 

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

133 super().__init__(message) 

134 self.code = code 

135 self.message = message 

136 self.details = details 

137 

138 

139class SpawnBudget: 

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

141 

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

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

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

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

146 make several LLM calls (e.g. an agent-side tool loop via file_access); 

147 those LLM roundtrips are bounded by the run's `max_llm_calls`, not by 

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

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

150 """ 

151 

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

153 self._remaining: int | None = ( 

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

155 ) 

156 self._lock = threading.Lock() 

157 

158 def consume(self) -> bool: 

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

160 with self._lock: 

161 if self._remaining is None: 

162 return True 

163 if self._remaining <= 0: 

164 return False 

165 self._remaining -= 1 

166 return True 

167 

168 

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

170 

171_SPAWN_DEFAULT_MAX_AGENT_CALLS: int = 64 

172_SPAWN_DEFAULT_MAX_DEPTH: int = 1 

173_SPAWN_DEFAULT_MAX_SPAWN_PER_NODE: int = 16 

174 

175# --------------------------------------------------------------------------- 

176# Polut 

177# --------------------------------------------------------------------------- 

178 

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

180MANIFEST_DIRS = [ 

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

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

183] 

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

185 

186 

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

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

189 

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

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

192 """ 

193 return [ 

194 d for d in ( 

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

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

197 ) 

198 if d.is_dir() 

199 ] 

200 

201# --------------------------------------------------------------------------- 

202# Template-resoluutio 

203# --------------------------------------------------------------------------- 

204 

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

206 

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

208_UNRESOLVED = object() 

209 

210 

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

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

213 

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

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

216 """ 

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

218 base = m.group(1) 

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

220 return base, indices 

221 

222 

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

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

225 for idx in indices: 

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

227 return _UNRESOLVED 

228 obj = obj[idx] 

229 return obj 

230 

231 

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

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

234 

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

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

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

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

239 """ 

240 base, indices = _split_key_indices(part) 

241 if isinstance(obj, dict): 

242 if base: 

243 if base not in obj: 

244 return _UNRESOLVED 

245 obj = obj[base] 

246 return _apply_indices(obj, indices) 

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

248 if indices: 

249 return _apply_indices(obj, indices) 

250 if base and base.isdigit(): 

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

252 return _UNRESOLVED 

253 return _UNRESOLVED 

254 

255 

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

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

258 

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

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

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

262 """ 

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

264 obj = _dot_get_segment(obj, part) 

265 if obj is _UNRESOLVED: 

266 return None 

267 return obj 

268 

269 

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

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

272 

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

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

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

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

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

278 """ 

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

280 if obj is None: 

281 raise ValueError( 

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

283 ) 

284 obj = _dot_get_segment(obj, part) 

285 if obj is _UNRESOLVED: 

286 raise ValueError( 

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

288 ) 

289 return obj 

290 

291 

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

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

294 

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

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

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

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

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

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

301 """ 

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

303 if not out_spec: 

304 return None 

305 source = out_spec.get("source") 

306 if not source: 

307 return None 

308 return _dot_get(final_state, source) 

309 

310 

311def _merge_metrics(base: dict | None, additions: dict) -> dict: 

312 """Yhdistä step_context_metrics-envelopet: myöhempi kirjoitus voittaa 

313 (sama semantiikka kuin state-merge {**state, ...}).""" 

314 merged = dict(base or {}) 

315 merged.update(additions) 

316 return merged 

317 

318 

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

320_SINGLE_OPS = '+-<>' 

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

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

323 

324 

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

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

327 

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

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

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

331 """ 

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

333 i, n = 0, len(expr) 

334 while i < n: 

335 ch = expr[i] 

336 if ch.isspace(): 

337 i += 1 

338 continue 

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

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

341 i += 1 

342 continue 

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

344 two = expr[i:i + 2] 

345 if two in _MULTI_OPS: 

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

347 i += 2 

348 continue 

349 if ch in _SINGLE_OPS: 

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

351 i += 1 

352 continue 

353 raise ValueError(f"Invalid operator in expression: {two!r}") 

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

355 j = i + 1 

356 buf: list[str] = [] 

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

358 buf.append(expr[j]) 

359 j += 1 

360 if j >= n: 

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

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

363 i = j + 1 

364 continue 

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

366 j = i 

367 if ch == '.': 

368 j += 1 

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

370 j += 1 

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

372 else: 

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

374 j += 1 

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

376 j += 1 

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

378 j += 1 

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

380 else: 

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

382 i = j 

383 continue 

384 j = i 

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

386 j += 1 

387 if j == i: 

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

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

390 i = j 

391 return tokens 

392 

393 

394# AST node shapes: 

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

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

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

398# ('neg', node) unary minus 

399# ('not', node) not X 

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

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

402 

403 

404class _ExprParser: 

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

406 

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

408 Grammar: 

409 expr := or_expr 

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

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

412 not_expr := 'not' not_expr | cmp_expr 

413 cmp_expr := add_expr (cmp_op add_expr)? 

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

415 unary_expr:= '-' unary_expr | primary 

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

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

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

419 unresolved read there never raises. 

420 """ 

421 

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

423 self._tokens = tokens 

424 self._i = 0 

425 

426 def parse(self) -> tuple: 

427 node = self._or_expr() 

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

429 tok = self._tokens[self._i] 

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

431 return node 

432 

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

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

435 

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

437 tok = self._tokens[self._i] 

438 self._i += 1 

439 return tok 

440 

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

442 tok = self._peek() 

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

444 

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

446 tok = self._peek() 

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

448 

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

450 tok = self._next() 

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

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

453 

454 def _or_expr(self) -> tuple: 

455 node = self._and_expr() 

456 while self._peek_name('or'): 

457 self._next() 

458 right = self._and_expr() 

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

460 return node 

461 

462 def _and_expr(self) -> tuple: 

463 node = self._not_expr() 

464 while self._peek_name('and'): 

465 self._next() 

466 right = self._not_expr() 

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

468 return node 

469 

470 def _not_expr(self) -> tuple: 

471 if self._peek_name('not'): 

472 self._next() 

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

474 return self._cmp_expr() 

475 

476 def _cmp_expr(self) -> tuple: 

477 node = self._add_expr() 

478 tok = self._peek() 

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

480 self._next() 

481 right = self._add_expr() 

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

483 return node 

484 

485 def _add_expr(self) -> tuple: 

486 node = self._unary_expr() 

487 while self._peek_op('+'): 

488 self._next() 

489 right = self._unary_expr() 

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

491 return node 

492 

493 def _unary_expr(self) -> tuple: 

494 if self._peek_op('-'): 

495 self._next() 

496 node = self._unary_expr() 

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

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

499 return ('neg', node) 

500 return self._primary() 

501 

502 def _primary(self) -> tuple: 

503 tok = self._peek() 

504 if tok is None: 

505 raise ValueError("Unexpected end of expression") 

506 kind, value = tok 

507 if kind == 'num': 

508 self._next() 

509 return ('lit', value) 

510 if kind == 'str': 

511 self._next() 

512 return ('lit', value) 

513 if kind == 'punct': 

514 if value == '(': 

515 self._next() 

516 node = self._or_expr() 

517 self._expect_punct(')') 

518 return node 

519 if value == '[': 

520 self._next() 

521 items: list[tuple] = [] 

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

523 while True: 

524 items.append(self._or_expr()) 

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

526 self._next() 

527 continue 

528 break 

529 self._expect_punct(']') 

530 return ('list', items) 

531 raise ValueError(f"Unexpected punctuation in expression: {value!r}") 

532 if kind == 'name': 

533 self._next() 

534 lowered = value.lower() 

535 if lowered == 'true': 

536 return ('lit', True) 

537 if lowered == 'false': 

538 return ('lit', False) 

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

540 return ('lit', None) 

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

542 raise ValueError(f"Unexpected keyword in expression: {value!r}") 

543 return ('ref', value) 

544 raise ValueError(f"Unexpected token in expression: {value!r}") 

545 

546 

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

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

549 

550 

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

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

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

554 return left + right 

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

556 return left + right 

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

558 return left + right 

559 raise ValueError( 

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

561 ) 

562 

563 

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

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

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

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

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

569 return node[1] 

570 return _eval_ast(node, ctx, strict) 

571 

572 

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

574 left = _eval_ast(left_node, ctx, strict) 

575 right = _eval_comparison_rhs(right_node, ctx, strict) 

576 try: 

577 if op == '==': 

578 return left == right 

579 if op == '!=': 

580 return left != right 

581 if op == '<=': 

582 return left <= right 

583 if op == '>=': 

584 return left >= right 

585 if op == '<': 

586 return left < right 

587 if op == '>': 

588 return left > right 

589 except TypeError: 

590 return False 

591 raise ValueError(f"Unknown comparison operator: {op!r}") 

592 

593 

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

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

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

597 kind = node[0] 

598 if kind == 'lit': 

599 return node[1] 

600 if kind == 'ref': 

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

602 if kind == 'list': 

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

604 if kind == 'neg': 

605 return -_eval_ast(node[1], ctx, strict) 

606 if kind == 'not': 

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

608 if kind == 'cmp': 

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

610 if kind == 'binop': 

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

612 if op == 'and': 

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

614 return False 

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

616 if op == 'or': 

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

618 return True 

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

620 if op == '+': 

621 left = _eval_ast(left_node, ctx, strict) 

622 right = _eval_ast(right_node, ctx, strict) 

623 return _add_values(left, right) 

624 raise ValueError(f"Unknown binary operator: {op!r}") 

625 raise ValueError(f"Unknown expression node: {kind!r}") 

626 

627 

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

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

630 

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

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

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

634 side already determines the result. 

635 """ 

636 expr = expr.strip() 

637 tokens = _tokenize_expr(expr) 

638 if not tokens: 

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

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

641 

642 

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

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

645 if isinstance(value, str): 

646 stripped = value.strip() 

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

648 m = _TMPL_RE.fullmatch(stripped) 

649 if m: 

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

651 # Inline substitution → always returns string 

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

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

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

655 return _TMPL_RE.sub(_replace, value) 

656 if isinstance(value, dict): 

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

658 if isinstance(value, list): 

659 return [_resolve_template(v, ctx, strict) for v in value] 

660 return value 

661 

662 

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

664 """ 

665 Resolve step input spec to a concrete value: 

666 "{{ item }}" → template 

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

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

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

670 None → None 

671 """ 

672 if input_spec is None: 

673 return None 

674 if isinstance(input_spec, str): 

675 if '{{' in input_spec: 

676 return _resolve_template(input_spec, ctx) 

677 stripped = input_spec.lstrip() 

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

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

680 # builtin verbatim so it resolves the placeholder itself. 

681 if stripped.startswith('['): 

682 return input_spec 

683 # Dot-path into state (no braces) 

684 return _dot_get(state, stripped) 

685 if isinstance(input_spec, dict): 

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

687 if isinstance(input_spec, list): 

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

689 return input_spec 

690 

691 

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

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

694 

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

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

697 raises, never silently evaluates to falsy. 

698 """ 

699 if cond is None: 

700 return True 

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

702 if isinstance(resolved, bool): 

703 return resolved 

704 if isinstance(resolved, str): 

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

706 return bool(resolved) 

707 

708 

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

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

711 try: 

712 return int(v) 

713 except (TypeError, ValueError): 

714 return default 

715 

716 

717# --------------------------------------------------------------------------- 

718# ManifestLoader 

719# --------------------------------------------------------------------------- 

720 

721 

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

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

724 try: 

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

726 except OSError as exc: 

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

728 return [] 

729 

730 results: list[dict] = [] 

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

732 raw_clean = "\n".join( 

733 line for line in raw.splitlines() 

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

735 ) 

736 try: 

737 doc = yaml.safe_load(raw_clean) 

738 except yaml.YAMLError as exc: 

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

740 continue 

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

742 results.append(doc) 

743 return results 

744 

745 

746class ManifestLoader: 

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

748 

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

750 self._dirs = dirs or MANIFEST_DIRS 

751 

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

753 manifests: list[dict] = [] 

754 for d in self._dirs: 

755 if not d.exists(): 

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

757 continue 

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

759 docs = _parse_manifest_file(path) 

760 manifests.extend(docs) 

761 if docs: 

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

763 return manifests 

764 

765 

766# --------------------------------------------------------------------------- 

767# ManifestRegistry 

768# --------------------------------------------------------------------------- 

769 

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

771 

772 

773class ManifestRegistry: 

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

775 

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

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

778 for doc in manifests: 

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

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

781 if kind and name: 

782 key: ResourceKey = (kind, name) 

783 if key in self._store: 

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

785 self._store[key] = doc 

786 

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

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

789 

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

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

792 

793 

794# --------------------------------------------------------------------------- 

795# AgentResolver 

796# --------------------------------------------------------------------------- 

797 

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

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

800 result = dict(base) 

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

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

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

804 else: 

805 result[k] = v 

806 return result 

807 

808 

809class AgentResolver: 

810 """ 

811 Palauttaa fully-resolved agent spec:n instanssille. 

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

813 """ 

814 

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

816 self._registry = registry 

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

818 

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

820 if instance_name in self._cache: 

821 return self._cache[instance_name] 

822 

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

824 if instance is None: 

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

826 if agent is None: 

827 raise KeyError(f"No Agent or AgentInstance named '{instance_name}'") 

828 resolved = agent 

829 else: 

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

831 if based_on: 

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

833 if base_agent is None: 

834 raise KeyError( 

835 f"AgentInstance '{instance_name}' references unknown Agent '{based_on}'" 

836 ) 

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

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

839 if k != "based_on"} 

840 merged_spec = _deep_merge(base_spec, inst_spec) 

841 else: 

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

843 if k != "based_on"} 

844 

845 resolved = { 

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

847 "kind": "Agent", 

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

849 "spec": merged_spec, 

850 } 

851 

852 self._cache[instance_name] = resolved 

853 return resolved 

854 

855 

856# --------------------------------------------------------------------------- 

857# State type 

858# --------------------------------------------------------------------------- 

859 

860PipelineState = dict[str, Any] 

861 

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

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

864_CORE = CORE_KEY 

865 

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

867_CORE_STATE_KEYS = frozenset({ 

868 "errors", "stats", "run_status", "project_root", "run_id", 

869 "_stats", "step_context_metrics", 

870}) 

871 

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

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

874# never shadow them. 

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

876 

877 

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

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

880 

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

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

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

884 bounded, filename-safe length. 

885 """ 

886 for key in _ENGINE_LOCAL_ITERATION_KEYS: 

887 if key in state: 

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

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

890 return None 

891 

892 

893 

894def _build_state_schema() -> Any: 

895 """Build the channel-path TypedDict: a single Core-owned ``__core__`` field. 

896 

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

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

899 node-to-node under ``__core__``. LangGraph stays the graph executor. 

900 """ 

901 from typing import TypedDict 

902 

903 return TypedDict("PipelineState", {_CORE: dict[str, Any]}) 

904 

905 

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

907_UNSET: Any = object() 

908 

909# --------------------------------------------------------------------------- 

910# BuiltinExecutor 

911# --------------------------------------------------------------------------- 

912 

913class BuiltinExecutor: 

914 """ 

915 Ajaa builtin-komponentit. 

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

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

918 """ 

919 

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

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

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

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

924 

925 def __init__(self) -> None: 

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

927 "passthrough": self._passthrough, 

928 # Engine-internal call-expansion scaffolding (ADR-0025 D4): these are 

929 # synthesized at build time by expand_calls(), never authored in a 

930 # manifest. The steps that generate them are removed from the flat 

931 # runtime graph — only their body + these two op steps remain. 

932 "__call_bind": self._call_bind, 

933 "__call_emit": self._call_emit, 

934 } 

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

936 # GraphBuilder. Profile builtins that make LLM decisions 

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

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

939 # executes (binding happens at build_workflow time). 

940 self.invoke_agent_fn: Any | None = None 

941 self._lazy_loaded = False 

942 

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

944 self._handlers[name] = fn 

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

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

947 if normalised != name: 

948 self._handlers[normalised] = fn 

949 

950 def _load_lazy_modules(self) -> None: 

951 if self._lazy_loaded: 

952 return 

953 self._lazy_loaded = True 

954 import importlib 

955 for module_name in self._LAZY_MODULES: 

956 try: 

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

958 except ImportError: 

959 continue 

960 mod.register_all(self) 

961 

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

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

964 self._load_lazy_modules() 

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

966 

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

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

969 if fn is not None: 

970 return fn 

971 self._load_lazy_modules() 

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

973 if fn is None: 

974 raise ValueError(f"Unknown builtin component '{name}'") 

975 return fn 

976 

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

978 fn = self.get(component) 

979 return fn(step, state) 

980 

981 # --- default builtins --- 

982 

983 @staticmethod 

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

985 return state 

986 

987 @staticmethod 

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

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

990 

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

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

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

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

995 """ 

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

997 if not call_id: 

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

999 bound = { 

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

1001 } 

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

1003 

1004 @staticmethod 

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

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

1007 

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

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

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

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

1012 

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

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

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

1016 """ 

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

1018 call_id = params.get("__call_id") 

1019 if not call_id: 

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

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

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

1023 for spec in outputs_spec: 

1024 if not isinstance(spec, dict): 

1025 continue 

1026 name = spec.get("name") 

1027 source = spec.get("source") 

1028 if name is None: 

1029 continue 

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

1031 if source is None: 

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

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

1034 if optional: 

1035 continue 

1036 raise RunHardFailure( 

1037 "UNRESOLVED_OUTPUT", 

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

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

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

1041 ) 

1042 try: 

1043 value = _dot_get_strict(state, source) 

1044 except ValueError: 

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

1046 if optional: 

1047 continue 

1048 raise RunHardFailure( 

1049 "UNRESOLVED_OUTPUT", 

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

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

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

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

1054 ) from None 

1055 outputs[name] = value 

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

1057 

1058 

1059_DEFAULT_BUILTINS = BuiltinExecutor() 

1060 

1061 

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

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

1064 

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

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

1067 """ 

1068 try: 

1069 from langgraph import _builtins # type: ignore 

1070 except ImportError: 

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

1072 import importlib.util 

1073 spec = importlib.util.spec_from_file_location( 

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

1075 ) 

1076 if spec and spec.loader: 

1077 _builtins = importlib.util.module_from_spec(spec) 

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

1079 else: 

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

1081 return 

1082 _builtins.register_all(executor) 

1083 _register_function_builtins(executor) 

1084 

1085 

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

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

1088 

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

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

1091 """ 

1092 try: 

1093 from langgraph import _builtins_functions # type: ignore 

1094 except ImportError: 

1095 import importlib.util 

1096 spec = importlib.util.spec_from_file_location( 

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

1098 ) 

1099 if spec and spec.loader: 

1100 _builtins_functions = importlib.util.module_from_spec(spec) 

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

1102 else: 

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

1104 return 

1105 _builtins_functions.register_all(executor) 

1106 

1107 

1108_register_domain_builtins(_DEFAULT_BUILTINS) 

1109 

1110# --------------------------------------------------------------------------- 

1111# StepRunner 

1112# --------------------------------------------------------------------------- 

1113 

1114class StepRunner: 

1115 """ 

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

1117 

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

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

1120 """ 

1121 

1122 def __init__( 

1123 self, 

1124 resolver: AgentResolver, 

1125 builtins: BuiltinExecutor | None = None, 

1126 invoke_agent_fn: Any | None = None, 

1127 run_context: dict | None = None, 

1128 *, 

1129 step_functions: StepFunctionExecutor | None = None, 

1130 tracer: Any | None = None, 

1131 ) -> None: 

1132 self._resolver = resolver 

1133 self._builtins = builtins or _DEFAULT_BUILTINS 

1134 # Order matters: the hard-failure gate runs first (an error envelope 

1135 # fail-fasts before any requested call), then the envelope's requested 

1136 # deterministic-step calls execute before the result is stored/routed. 

1137 self._invoke_agent = self._wrap_calls_gate( 

1138 self._wrap_hard_failure_gate( 

1139 invoke_agent_fn or self._dummy_invoke 

1140 ) 

1141 ) 

1142 self._run_context: dict = run_context or {} 

1143 self._step_functions = step_functions 

1144 self._tracer = tracer 

1145 self._contracts: dict[str, dict] = {} 

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

1147 

1148 def set_channel_routing( 

1149 self, contracts: dict[str, dict], channel_names: frozenset[str] 

1150 ) -> None: 

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

1152 self._contracts = contracts 

1153 self._channel_names = channel_names 

1154 

1155 @staticmethod 

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

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

1158 

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

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

1161 truthy expression skips the LLM. 

1162 """ 

1163 raw = step.get("skip_llm_if") 

1164 if raw is None: 

1165 return False 

1166 resolved = _resolve_template(raw, ctx) 

1167 if isinstance(resolved, bool): 

1168 return resolved 

1169 if isinstance(resolved, str): 

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

1171 return bool(resolved) 

1172 

1173 @staticmethod 

1174 def _skip_llm_passthrough( 

1175 step: dict, 

1176 state: PipelineState, 

1177 ctx: dict, 

1178 read_state: PipelineState, 

1179 ) -> PipelineState: 

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

1181 

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

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

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

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

1186 """ 

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

1188 input_spec = step.get("input") 

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

1190 if isinstance(resolved, list): 

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

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

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

1194 return {**state, step_id: resolved} 

1195 

1196 @staticmethod 

1197 def _parallel_fanout_items( 

1198 step: dict, state: PipelineState, ctx: dict 

1199 ) -> list | None: 

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

1201 

1202 A parallel step whose resolved input is a list invokes the agent once 

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

1204 single agent output. 

1205 """ 

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

1207 return None 

1208 input_spec = step.get("input") 

1209 if input_spec is None: 

1210 return None 

1211 input_val = _resolve_input(input_spec, state, ctx) 

1212 return input_val if isinstance(input_val, list) else None 

1213 

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

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

1216 

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

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

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

1220 are excluded from inverse projection. 

1221 """ 

1222 if self.resolve_agent(step) is None: 

1223 return False 

1224 ctx = self._make_ctx(state) 

1225 if self._should_skip_llm(step, ctx): 

1226 return False 

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

1228 

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

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

1231 ctx: dict = {} 

1232 ctx.update(state) 

1233 ctx.update(self._run_context) 

1234 ctx.update(extras) 

1235 return ctx 

1236 

1237 @staticmethod 

1238 def _read_state( 

1239 state: PipelineState, working_set: ChannelWorkingSet | None 

1240 ) -> PipelineState: 

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

1242 

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

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

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

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

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

1248 declared channel with the same name cannot shadow them. 

1249 """ 

1250 if working_set is None: 

1251 return state 

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

1253 for key in _ENGINE_LOCAL_ITERATION_KEYS: 

1254 if key in state: 

1255 resolved[key] = state[key] 

1256 return resolved 

1257 

1258 def _run_body_step( 

1259 self, 

1260 step: dict, 

1261 state: PipelineState, 

1262 working_set: ChannelWorkingSet | None, 

1263 ) -> PipelineState: 

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

1265 

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

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

1268 """ 

1269 if working_set is None: 

1270 return self.run_step(step, state) 

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

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

1273 return result 

1274 

1275 def _stage_body_channel_writes( 

1276 self, 

1277 step: dict, 

1278 pre_state: PipelineState, 

1279 result_state: PipelineState, 

1280 working_set: ChannelWorkingSet, 

1281 ) -> None: 

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

1283 

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

1285 a step carrying an agent routes its declared writes. 

1286 """ 

1287 if not self._channel_names: 

1288 return 

1289 writes = _route_declared_channels( 

1290 step, 

1291 self._read_state(pre_state, working_set), 

1292 result_state, 

1293 self, 

1294 self._contracts, 

1295 self._channel_names, 

1296 ) 

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

1298 working_set.stage(name, value) 

1299 

1300 def _resolve_step_runtime( 

1301 self, 

1302 step: dict, 

1303 state: dict, 

1304 ctx: dict, 

1305 *, 

1306 input_override: Any = _UNSET, 

1307 extra_params: dict | None = None, 

1308 ) -> dict: 

1309 """ 

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

1311 templaattien osalta state + run_context -kontekstia vasten. 

1312 

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

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

1315 """ 

1316 resolved: dict = dict(step) 

1317 

1318 if input_override is not _UNSET: 

1319 resolved["input"] = input_override 

1320 else: 

1321 input_spec = step.get("input") 

1322 if input_spec is not None: 

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

1324 

1325 params_spec = step.get("params") 

1326 if params_spec or extra_params: 

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

1328 if not isinstance(base, dict): 

1329 base = {} 

1330 if extra_params: 

1331 base = {**base, **extra_params} 

1332 resolved["params"] = base 

1333 

1334 return resolved 

1335 

1336 def _attach_communication_context( 

1337 self, resolved_spec: dict, resolved_step: dict, read_state: PipelineState 

1338 ) -> dict: 

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

1340 

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

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

1343 only the agent contract's declared readable channels — never whole 

1344 state. A step without a read declaration is returned unchanged. 

1345 """ 

1346 agent_spec = resolved_spec.get("spec") or {} 

1347 communication = resolve_communication_context( 

1348 agent_spec, self._contracts, read_state 

1349 ) 

1350 if not communication: 

1351 return resolved_step 

1352 return {**resolved_step, COMMUNICATION_CONTEXT_KEY: communication} 

1353 

1354 def _attach_usage_instructions(self, resolved_step: dict) -> dict: 

1355 """Attach the resolved usage-instruction entries for a step's `allow`. 

1356 

1357 The whitelist is implicit: the instruction block is derived from the 

1358 step's `allow` entries, resolved builtin-first, and carried to the 

1359 invoker (ADR-0036 D1 element 5). A step without an `allow` whitelist 

1360 is returned unchanged. 

1361 """ 

1362 entries = resolve_usage_instructions( 

1363 resolved_step.get("allow"), self._builtins, self._step_functions 

1364 ) 

1365 if not entries: 

1366 return resolved_step 

1367 return {**resolved_step, USAGE_INSTRUCTIONS_KEY: entries} 

1368 

1369 @staticmethod 

1370 def _dummy_invoke( 

1371 resolved_spec: dict, step: dict, state: PipelineState 

1372 ) -> PipelineState: 

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

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

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

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

1377 

1378 @staticmethod 

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

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

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

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

1383 if not additions: 

1384 return base 

1385 merged = list(base) 

1386 for err in additions: 

1387 if err not in merged: 

1388 merged.append(err) 

1389 return merged 

1390 

1391 @staticmethod 

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

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

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

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

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

1397 def invoke_with_gate( 

1398 resolved_spec: dict, step: dict, state: PipelineState 

1399 ) -> PipelineState: 

1400 result = invoke_fn(resolved_spec, step, state) 

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

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

1403 return result 

1404 return invoke_with_gate 

1405 

1406 def _wrap_calls_gate(self, invoke_fn: Any) -> Any: 

1407 """Kietoo invokerin: agentin pyytämät deterministic-step kutsut 

1408 (envelopen `calls`) suoritetaan, kun hard-failure-portti on päästänyt 

1409 tuloksen läpi ja ennen kuin step-tulos tallennetaan/reititetään.""" 

1410 def invoke_with_calls( 

1411 resolved_spec: dict, step: dict, state: PipelineState 

1412 ) -> PipelineState: 

1413 result = invoke_fn(resolved_spec, step, state) 

1414 self._execute_requested_calls(step, result, state) 

1415 return result 

1416 return invoke_with_calls 

1417 

1418 def _execute_requested_calls( 

1419 self, step: dict, result: PipelineState, state: PipelineState 

1420 ) -> None: 

1421 """Suorita agentin envelopessa pyytämät deterministic-step kutsut. 

1422 

1423 No-op ellei tulos ole envelope, jossa on ei-tyhjä `calls`-lista. Kutsut 

1424 suoritetaan järjestyksessä builtin-then-StepFunction-polkua pitkin; 

1425 onnistunut kutsu on side-effect-only (palautettu state hylätään). 

1426 """ 

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

1428 envelope = result.get(step_id) if isinstance(result, dict) else None 

1429 calls = envelope.get("calls") if isinstance(envelope, dict) else None 

1430 if not calls: 

1431 return 

1432 allow = step.get("allow") or [] 

1433 for index, call in enumerate(calls): 

1434 self._execute_requested_call(step_id, allow, index, call, state) 

1435 

1436 def _execute_requested_call( 

1437 self, 

1438 step_id: str, 

1439 allow: list, 

1440 index: int, 

1441 call: Any, 

1442 state: PipelineState, 

1443 ) -> None: 

1444 """Validoi ja suorita yksi pyydetty kutsu, fail-fast virhekoodeilla.""" 

1445 name = call.get("step") if isinstance(call, dict) else None 

1446 if not isinstance(name, str) or name not in allow: 

1447 raise RunHardFailure( 

1448 "CALL_NOT_ALLOWED", 

1449 f"step '{step_id}' requested deterministic step {name!r} which " 

1450 "is not in its allow whitelist", 

1451 {"step_id": step_id, "requested_step": name, "allow": list(allow)}, 

1452 ) 

1453 params = self._call_params(call, step_id, name) 

1454 self._validate_call_params(name, params, step_id) 

1455 call_step = self._synthesize_call_step(step_id, index, name, params, state) 

1456 try: 

1457 self._dispatch_call_step(name, call_step, state) 

1458 except RunHardFailure as exc: 

1459 if exc.code == "BUILTIN_NAME_COLLISION": 

1460 raise 

1461 raise RunHardFailure( 

1462 "CALL_FAILED", 

1463 f"step '{step_id}' call to '{name}' failed: {exc.message}", 

1464 { 

1465 "step_id": step_id, 

1466 "requested_step": name, 

1467 "error_code": exc.code, 

1468 }, 

1469 ) from exc 

1470 except Exception as exc: # noqa: BLE001 

1471 raise RunHardFailure( 

1472 "CALL_FAILED", 

1473 f"step '{step_id}' call to '{name}' failed: {exc}", 

1474 {"step_id": step_id, "requested_step": name}, 

1475 ) from exc 

1476 

1477 @staticmethod 

1478 def _call_params(call: dict, step_id: str, name: str) -> dict: 

1479 """The call's params object ({} when absent), else CALL_INVALID_PARAMS.""" 

1480 params = call.get("params") 

1481 if params is None: 

1482 return {} 

1483 if not isinstance(params, dict): 

1484 raise RunHardFailure( 

1485 "CALL_INVALID_PARAMS", 

1486 f"step '{step_id}' call to '{name}' has non-object params", 

1487 {"step_id": step_id, "requested_step": name}, 

1488 ) 

1489 return params 

1490 

1491 def _validate_call_params(self, name: str, params: dict, step_id: str) -> None: 

1492 """Validoi StepFunction-kutsun paramsit interfacea vasten. 

1493 

1494 Builtineilla ei ole formaalia param-interfacea — niiden params-objekti 

1495 on jo strukturaalisesti validoitu envelopessa. Säännöllinen 

1496 StepFunction-suoritus säilyttää nykyisen käytöksensä; tämä portti 

1497 koskee vain calls-polkua. 

1498 """ 

1499 if self._builtins.has(name) or self._step_functions is None: 

1500 return 

1501 declared = self._step_functions.params_for(name) 

1502 if declared is None: 

1503 return 

1504 declared_names = {entry["name"] for entry in declared} 

1505 missing = [ 

1506 entry["name"] 

1507 for entry in declared 

1508 if entry.get("required") and entry["name"] not in params 

1509 ] 

1510 unknown = [key for key in params if key not in declared_names] 

1511 if not missing and not unknown: 

1512 return 

1513 raise RunHardFailure( 

1514 "CALL_INVALID_PARAMS", 

1515 f"step '{step_id}' call to '{name}' has invalid params " 

1516 f"(missing required: {missing}; undeclared: {unknown})", 

1517 { 

1518 "step_id": step_id, 

1519 "requested_step": name, 

1520 "missing": missing, 

1521 "unknown": unknown, 

1522 }, 

1523 ) 

1524 

1525 @staticmethod 

1526 def _synthesize_call_step( 

1527 parent_step_id: str, 

1528 index: int, 

1529 name: str, 

1530 params: dict, 

1531 state: PipelineState, 

1532 ) -> dict: 

1533 """Rakentaa deterministisen synteettisen stepin yhdelle kutsulle. 

1534 

1535 The id is unique per invocation: the engine-local iteration 

1536 discriminator is included when present so concurrent foreach/parallel 

1537 items get distinct ids, and a process-unique token guarantees 

1538 uniqueness otherwise (and against equal discriminators). The id is 

1539 not persisted — calls are side-effect-only. 

1540 """ 

1541 discriminator = _iteration_discriminator(state) 

1542 token = uuid.uuid4().hex 

1543 suffix = f"{discriminator}.{token}" if discriminator else token 

1544 return { 

1545 "id": f"{parent_step_id}.call.{index}.{name}.{suffix}", 

1546 "type": "builtin", 

1547 "component": name, 

1548 "params": params, 

1549 } 

1550 

1551 def _dispatch_call_step( 

1552 self, name: str, step: dict, state: PipelineState 

1553 ) -> None: 

1554 """Suorita yksi deterministic-step builtin-then-StepFunction-järjestyksessä. 

1555 

1556 Peilaa `run_step`-builtin-haaran resolutiöjärjestystä (ADR-0037 D2): 

1557 builtin-rekisteri voittaa, ja nimi molemmissa rekistereissä on kova 

1558 virhe. Palautettu state-snapshot hylätään — kutsu on side-effect-only. 

1559 """ 

1560 is_builtin = self._builtins.has(name) 

1561 is_step_function = ( 

1562 self._step_functions is not None and self._step_functions.has(name) 

1563 ) 

1564 if is_builtin and is_step_function: 

1565 raise RunHardFailure( 

1566 "BUILTIN_NAME_COLLISION", 

1567 f"component '{name}' is present in both the builtin register " 

1568 "and the StepFunction registry (ADR-0037 — fail-fast)", 

1569 {"component": name}, 

1570 ) 

1571 if is_step_function: 

1572 self._step_functions.execute(name, step, state) 

1573 return 

1574 builtin_state = self._builtins.execute(name, step, state) 

1575 self._raise_on_hard_error(builtin_state.get(step["id"]), step["id"]) 

1576 

1577 @staticmethod 

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

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

1580 (TRUNCATION_ERROR, TOOL_FAILURE, AGENT_ERROR) tai VALIDATION_ERROR 

1581 kantaa kontekstiylityksen signatuurin (assembled_tokens + 

1582 context_budget — _llm._context_overflow_result). 

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

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

1585 code = details.get("error_code") 

1586 if code in RunHardFailure.HARD_ERROR_CODES: 

1587 return True 

1588 if code == "VALIDATION_ERROR": 

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

1590 return False 

1591 

1592 @staticmethod 

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

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

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

1596 kulkeutuu poikkeuksen mukana.""" 

1597 if not isinstance(step_result, dict): 

1598 return 

1599 if is_error_envelope(step_result): 

1600 StepRunner._raise_on_error_envelope(step_result, step_id) 

1601 return 

1602 details = step_result.get("details") 

1603 if not isinstance(details, dict): 

1604 return 

1605 if not StepRunner._is_hard_fail(details): 

1606 return 

1607 code = details.get("error_code") 

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

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

1610 raise RunHardFailure(code, message, details) 

1611 

1612 @staticmethod 

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

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

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

1616 envelope_error = step_result.get("error") 

1617 if isinstance(envelope_error, dict): 

1618 details = envelope_error 

1619 message = envelope_error.get("message") 

1620 else: 

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

1622 message = None 

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

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

1625 raise RunHardFailure(ENVELOPE_ERROR_CODE, message, details) 

1626 

1627 @staticmethod 

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

1629 """ 

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

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

1632 epäonnistuneeksi eikä hiljennä virhettä. 

1633 

1634 Kaikki virhetyypit propagoidaan: 

1635 - budget:max_llm_calls / budget:run_timeout → budjettiloki 

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

1637 """ 

1638 step_result = result.get(step_id) 

1639 if not isinstance(step_result, dict): 

1640 return result 

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

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

1643 return result 

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

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

1646 if message not in errors: 

1647 errors.append(message) 

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

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

1650 else: 

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

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

1653 

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

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

1656 agent_name = step.get("agent") 

1657 if not agent_name: 

1658 return None 

1659 return self._resolver.resolve(agent_name) 

1660 

1661 def run_step( 

1662 self, 

1663 step: dict, 

1664 state: PipelineState, 

1665 working_set: ChannelWorkingSet | None = None, 

1666 ) -> PipelineState: 

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

1668 read_state = self._read_state(state, working_set) 

1669 ctx = self._make_ctx(read_state) 

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

1671 

1672 # Evaluate condition — if false, skip step. A `while` whose entry 

1673 # condition is false writes a first-class skip result (r.68) instead of 

1674 # returning state unchanged, so `_run_while` owns its skip path. 

1675 cond = step.get("condition") 

1676 if cond is not None and not _eval_condition(cond, ctx) and step_type != "while": 

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

1678 return state 

1679 

1680 if step_type == "builtin": 

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

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

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

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

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

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

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

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

1689 is_builtin = self._builtins.has(component) 

1690 is_step_function = ( 

1691 self._step_functions is not None 

1692 and self._step_functions.has(component) 

1693 ) 

1694 if is_builtin and is_step_function: 

1695 raise RunHardFailure( 

1696 "BUILTIN_NAME_COLLISION", 

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

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

1699 "fail-fast)", 

1700 {"component": component}, 

1701 ) 

1702 if is_step_function: 

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

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

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

1706 return builtin_state 

1707 

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

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

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

1711 if ( 

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

1713 and self._should_skip_llm(step, ctx) 

1714 ): 

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

1716 

1717 if step_type == "parallel": 

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

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

1720 if items is not None: 

1721 return self._run_parallel_fanout( 

1722 step, state, items, ctx, working_set 

1723 ) 

1724 # Fallback: single agent call 

1725 agent_name = step.get("agent") 

1726 if not agent_name: 

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

1728 return state 

1729 resolved = self._resolver.resolve(agent_name) 

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

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

1732 resolved_step = self._attach_communication_context( 

1733 resolved, resolved_step, read_state 

1734 ) 

1735 resolved_step = self._attach_usage_instructions(resolved_step) 

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

1737 return self._propagate_step_errors(result, step_id) 

1738 

1739 if step_type == "sequential": 

1740 agent_name = step.get("agent") 

1741 if not agent_name: 

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

1743 return state 

1744 resolved = self._resolver.resolve(agent_name) 

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

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

1747 resolved_step = self._attach_communication_context( 

1748 resolved, resolved_step, read_state 

1749 ) 

1750 resolved_step = self._attach_usage_instructions(resolved_step) 

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

1752 return self._propagate_step_errors(result, step_id) 

1753 

1754 if step_type == "foreach": 

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

1756 

1757 if step_type == "while": 

1758 return self._run_while(step, state, working_set) 

1759 

1760 if step_type == "call": 

1761 # Build-time expansion (ADR-0025 D4) is a later phase; a call step 

1762 # reaching the runtime is wiring that is not yet expanded. 

1763 raise ValueError( 

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

1765 "expansion (ADR-0025) — not yet supported" 

1766 ) 

1767 

1768 if step_type == "spawn": 

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

1770 

1771 raise ValueError(f"Unknown step type '{step_type}' in step '{step_id}'") 

1772 

1773 def _run_parallel_fanout( 

1774 self, 

1775 step: dict, 

1776 state: PipelineState, 

1777 items: list, 

1778 ctx: dict, 

1779 working_set: ChannelWorkingSet | None = None, 

1780 ) -> PipelineState: 

1781 """ 

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

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

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

1785 """ 

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

1787 agent_name = step.get("agent") 

1788 if not agent_name: 

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

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

1791 

1792 resolved = self._resolver.resolve(agent_name) 

1793 

1794 # Apply max_items cap 

1795 max_items_raw = step.get("max_items") 

1796 if max_items_raw is not None: 

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

1798 items = items[:max_items] 

1799 

1800 # Concurrency limit 

1801 max_conc_raw = step.get("max_concurrent") 

1802 if max_conc_raw is not None: 

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

1804 else: 

1805 max_conc = len(items) or 1 

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

1807 

1808 log.debug( 

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

1810 step_id, len(items), max_conc, 

1811 ) 

1812 

1813 read_state = self._read_state(state, working_set) 

1814 

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

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

1817 item_ctx = self._make_ctx(item_state) 

1818 resolved_step = self._resolve_step_runtime( 

1819 step, item_state, item_ctx, 

1820 input_override=item, 

1821 ) 

1822 resolved_step = self._attach_communication_context( 

1823 resolved, resolved_step, item_state 

1824 ) 

1825 resolved_step = self._attach_usage_instructions(resolved_step) 

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

1827 return result_state.get(step_id), result_state.get("step_context_metrics") 

1828 

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

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

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

1832 item_metrics: dict = {} 

1833 with ThreadPoolExecutor(max_workers=max_conc) as pool: 

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

1835 for future in as_completed(futures): 

1836 idx = futures[future] 

1837 try: 

1838 item_result, item_metrics_map = future.result() 

1839 results[idx] = item_result 

1840 if isinstance(item_metrics_map, dict): 

1841 item_metrics.update(item_metrics_map) 

1842 except SkillLoadError: 

1843 raise 

1844 except RunHardFailure: 

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

1846 raise 

1847 except Exception as exc: # noqa: BLE001 

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

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

1850 

1851 self._stage_fanout_channel_writes(resolved, results, step_id, working_set) 

1852 

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

1854 if item_metrics: 

1855 final = { 

1856 **final, 

1857 "step_context_metrics": _merge_metrics( 

1858 state.get("step_context_metrics"), item_metrics 

1859 ), 

1860 } 

1861 return final 

1862 

1863 def _stage_fanout_channel_writes( 

1864 self, 

1865 agent_spec: dict, 

1866 item_outputs: list, 

1867 step_id: str, 

1868 working_set: ChannelWorkingSet | None, 

1869 ) -> None: 

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

1871 

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

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

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

1875 """ 

1876 if working_set is None or not self._channel_names: 

1877 return 

1878 for output in item_outputs: 

1879 writes = _declared_output_channel_writes( 

1880 agent_spec, output, self._contracts, self._channel_names, step_id 

1881 ) 

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

1883 working_set.stage(name, value) 

1884 

1885 def _run_foreach( 

1886 self, 

1887 step: dict, 

1888 state: PipelineState, 

1889 working_set: ChannelWorkingSet | None = None, 

1890 ) -> PipelineState: 

1891 """ 

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

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

1894 

1895 When max_concurrent > 1, body iterations run concurrently via 

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

1897 max_concurrent value sets the thread pool size (mirrors parallel 

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

1899 

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

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

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

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

1904 sequential early-exit guarantee cannot be reproduced without sacrificing 

1905 true concurrency. 

1906 

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

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

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

1910 """ 

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

1912 read_state = self._read_state(state, working_set) 

1913 ctx = self._make_ctx(read_state) 

1914 

1915 # Determine items 

1916 input_spec = step.get("input") 

1917 over_key = step.get("over") 

1918 

1919 if input_spec is not None: 

1920 items: list = _resolve_input(input_spec, read_state, ctx) or [] 

1921 if not isinstance(items, list): 

1922 items = [items] if items is not None else [] 

1923 elif over_key: 

1924 items = read_state.get(over_key, [None]) 

1925 if not isinstance(items, list): 

1926 items = [items] 

1927 else: 

1928 items = [None] 

1929 

1930 # max_iterations cap 

1931 max_iter_raw = step.get("max_iterations") 

1932 if max_iter_raw is not None: 

1933 max_iter = _resolve_int(max_iter_raw, ctx, len(items)) 

1934 items = items[:max_iter] 

1935 

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

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

1938 max_output_raw = step.get("max_output") 

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

1940 

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

1942 max_conc_raw = step.get("max_concurrent") 

1943 if max_conc_raw is not None: 

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

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

1946 else: 

1947 max_conc = 1 

1948 

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

1950 agent_name = step.get("agent") 

1951 

1952 if max_conc <= 1: 

1953 return self._run_foreach_sequential( 

1954 step_id, step, state, items, body, agent_name, max_output, 

1955 working_set, 

1956 ) 

1957 return self._run_foreach_concurrent( 

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

1959 working_set, 

1960 ) 

1961 

1962 @staticmethod 

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

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

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

1966 if declared_output: 

1967 return iter_state.get(declared_output) 

1968 return iter_state.get("publish") 

1969 

1970 @staticmethod 

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

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

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

1974 if isinstance(item, dict): 

1975 verdict = item.get("verdict") 

1976 if verdict == "FAIL": 

1977 return False 

1978 if verdict == "PASS": 

1979 return True 

1980 passed = item.get("passed") 

1981 if isinstance(passed, bool): 

1982 return passed 

1983 return True 

1984 

1985 @staticmethod 

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

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

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

1989 return { 

1990 "results": item_results, 

1991 "passed": not failing, 

1992 "failing": failing, 

1993 "failed": bool(failing), 

1994 } 

1995 

1996 def _run_foreach_sequential( 

1997 self, 

1998 step_id: str, 

1999 step: dict, 

2000 state: PipelineState, 

2001 items: list, 

2002 body: list, 

2003 agent_name: str | None, 

2004 max_output: int, 

2005 working_set: ChannelWorkingSet | None = None, 

2006 ) -> PipelineState: 

2007 """Sequential foreach — default path when max_concurrent is absent or 1.""" 

2008 body_polku = bool(body) 

2009 declared_output = step.get("output") 

2010 results: list = [] 

2011 produced_outputs = 0 

2012 accumulated_errors: list = list(state.get("errors") or []) 

2013 accumulated_metrics: dict = {} 

2014 

2015 for item in items: 

2016 if max_output and produced_outputs >= max_output: 

2017 log.info("[foreach] %s max_output=%d reached — stopping", step_id, max_output) 

2018 break 

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

2020 

2021 if body_polku: 

2022 for sub_step in body: 

2023 iter_state = self._run_body_step(sub_step, iter_state, working_set) 

2024 # Propagate any budget errors from this iteration to accumulated list 

2025 accumulated_errors = self._merge_errors( 

2026 accumulated_errors, iter_state.get("errors") or [] 

2027 ) 

2028 publish_out = iter_state.get("publish") 

2029 results.append(self._body_item_result(iter_state, declared_output)) 

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

2031 produced_outputs += 1 

2032 elif agent_name: 

2033 resolved = self._resolver.resolve(agent_name) 

2034 iter_read_state = self._read_state(iter_state, working_set) 

2035 iter_ctx = self._make_ctx(iter_read_state) 

2036 resolved_step = self._resolve_step_runtime( 

2037 step, iter_read_state, iter_ctx 

2038 ) 

2039 resolved_step = self._attach_communication_context( 

2040 resolved, resolved_step, iter_read_state 

2041 ) 

2042 resolved_step = self._attach_usage_instructions(resolved_step) 

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

2044 results.append(iter_state.get(step_id)) 

2045 item_metrics_map = iter_state.get("step_context_metrics") 

2046 if isinstance(item_metrics_map, dict): 

2047 accumulated_metrics.update(item_metrics_map) 

2048 

2049 step_result: Any = self._aggregate_body_results(results) if body_polku else results 

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

2051 if accumulated_metrics: 

2052 final = { 

2053 **final, 

2054 "step_context_metrics": _merge_metrics( 

2055 state.get("step_context_metrics"), accumulated_metrics 

2056 ), 

2057 } 

2058 if accumulated_errors != list(state.get("errors") or []): 

2059 final = {**final, "errors": accumulated_errors} 

2060 return final 

2061 

2062 @staticmethod 

2063 def _merge_item_working_sets( 

2064 working_set: ChannelWorkingSet | None, 

2065 item_working_sets: list[ChannelWorkingSet | None], 

2066 ) -> None: 

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

2068 if working_set is None: 

2069 return 

2070 for item_working_set in item_working_sets: 

2071 if item_working_set is None: 

2072 continue 

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

2074 working_set.stage(name, value) 

2075 

2076 def _run_foreach_concurrent( 

2077 self, 

2078 step_id: str, 

2079 step: dict, 

2080 state: PipelineState, 

2081 items: list, 

2082 body: list, 

2083 agent_name: str | None, 

2084 max_output: int, 

2085 max_conc: int, 

2086 working_set: ChannelWorkingSet | None = None, 

2087 ) -> PipelineState: 

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

2089 log.info( 

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

2091 step_id, len(items), max_conc, 

2092 ) 

2093 

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

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

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

2097 body_polku = bool(body) 

2098 declared_output = step.get("output") 

2099 _SKIPPED = object() 

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

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

2102 lock = threading.Lock() 

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

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

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

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

2107 

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

2109 nonlocal item_errors 

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

2111 if max_output and stop_flag.is_set(): 

2112 return 

2113 

2114 item_working_set = ( 

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

2116 ) 

2117 item_working_sets[idx] = item_working_set 

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

2119 

2120 if body_polku: 

2121 for sub_step in body: 

2122 iter_state = self._run_body_step( 

2123 sub_step, iter_state, item_working_set 

2124 ) 

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

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

2127 if new_errs: 

2128 with lock: 

2129 item_errors = self._merge_errors(item_errors, new_errs) 

2130 publish_out = iter_state.get("publish") 

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

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

2133 with lock: 

2134 produced_outputs[0] += 1 

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

2136 stop_flag.set() 

2137 elif agent_name: 

2138 resolved = self._resolver.resolve(agent_name) 

2139 item_read_state = self._read_state(iter_state, item_working_set) 

2140 item_ctx = self._make_ctx(item_read_state) 

2141 resolved_step = self._resolve_step_runtime( 

2142 step, item_read_state, item_ctx 

2143 ) 

2144 resolved_step = self._attach_communication_context( 

2145 resolved, resolved_step, item_read_state 

2146 ) 

2147 resolved_step = self._attach_usage_instructions(resolved_step) 

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

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

2150 item_metrics_map = iter_state.get("step_context_metrics") 

2151 if isinstance(item_metrics_map, dict): 

2152 with lock: 

2153 accumulated_metrics.update(item_metrics_map) 

2154 

2155 with ThreadPoolExecutor(max_workers=max_conc) as pool: 

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

2157 for future in as_completed(futures): 

2158 try: 

2159 future.result() 

2160 except SkillLoadError: 

2161 raise 

2162 except RunHardFailure: 

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

2164 raise 

2165 except Exception as exc: # noqa: BLE001 

2166 idx = futures[future] 

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

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

2169 

2170 self._merge_item_working_sets(working_set, item_working_sets) 

2171 

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

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

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

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

2176 if accumulated_metrics: 

2177 final = { 

2178 **final, 

2179 "step_context_metrics": _merge_metrics( 

2180 state.get("step_context_metrics"), accumulated_metrics 

2181 ), 

2182 } 

2183 if item_errors: 

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

2185 return final 

2186 

2187 def _run_while( 

2188 self, 

2189 step: dict, 

2190 state: PipelineState, 

2191 working_set: ChannelWorkingSet | None = None, 

2192 ) -> PipelineState: 

2193 """ 

2194 While loop with exit_condition and optional entry condition. 

2195 

2196 Fields: 

2197 condition: "{{ triage.approved }}" — if false, skip entirely 

2198 input: initial value for loop.current 

2199 exit_condition: "{{ review.pass }}" — break when true 

2200 max_iterations: int or template 

2201 output: body step id whose result feeds the next round's 

2202 `loop.current` (envelope-unwrapped when dict with `output`) 

2203 body: list of sub-steps 

2204 

2205 Result shape (r.68): {passed, iterations, output, exhausted, skipped}. 

2206 The feed comes ONLY from the declared `output` body step — there is no 

2207 hardcoded `refine` feed; a body step named `refine` is not special. 

2208 

2209 The invocation working set is threaded into every body step and into 

2210 condition evaluation so reads resolve over the committed snapshot plus 

2211 this invocation's pending writes; the returned state is still built 

2212 from the raw ``state``. 

2213 """ 

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

2215 read_state = self._read_state(state, working_set) 

2216 ctx = self._make_ctx(read_state) 

2217 

2218 # Entry condition — skip is a first-class state (r.68): a skipped 

2219 # while writes a result with output = the resolved input so downstream 

2220 # `.output` reads are non-null on the skipped path. 

2221 entry_cond = step.get("condition") 

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

2223 input_spec = step.get("input") 

2224 skip_input = _resolve_input(input_spec, read_state, ctx) if input_spec is not None else None 

2225 return {**state, step_id: { 

2226 "passed": True, 

2227 "iterations": 0, 

2228 "output": skip_input, 

2229 "exhausted": False, 

2230 "skipped": True, 

2231 }} 

2232 

2233 # Initial current value from input 

2234 input_spec = step.get("input") 

2235 if input_spec is not None: 

2236 current = _resolve_input(input_spec, read_state, ctx) 

2237 else: 

2238 current = None 

2239 

2240 exit_condition = step.get("exit_condition") 

2241 max_iter = _resolve_int(step.get("max_iterations", 2), ctx, 2) 

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

2243 declared_output = step.get("output") 

2244 

2245 passed = False 

2246 iteration = 0 

2247 loop_state: PipelineState = dict(state) 

2248 

2249 while iteration < max_iter: 

2250 # Inject loop context 

2251 loop_state = { 

2252 **state, 

2253 "loop": {"current": current, "iteration": iteration}, 

2254 step_id: {"current": current, "passed": False}, 

2255 } 

2256 # Add current item context (from outer foreach if any) 

2257 if "item" in state: 

2258 loop_state["item"] = state["item"] 

2259 

2260 # Run body steps 

2261 for sub_step in body: 

2262 loop_state = self._run_body_step(sub_step, loop_state, working_set) 

2263 

2264 iteration += 1 

2265 

2266 # Feed: next round's loop.current comes from the declared body-output 

2267 # step (envelope-unwrapped); otherwise current is unchanged. 

2268 # Envelopes: a `{output: ...}` (r.68) and a `{results: [...]}` 

2269 # aggregate (parallel/foreach body-step) are unwrapped to their 

2270 # inner value so the next round's consumer receives the list, not 

2271 # the envelope. A raw value (plain list/dict) feeds as-is. 

2272 if declared_output: 

2273 feed_value = loop_state.get(declared_output) 

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

2275 current = feed_value["output"] 

2276 elif isinstance(feed_value, dict) and isinstance(feed_value.get("results"), list): 

2277 current = feed_value["results"] 

2278 else: 

2279 current = feed_value 

2280 

2281 # Check exit condition 

2282 if exit_condition: 

2283 exit_ctx = self._make_ctx( 

2284 self._read_state(loop_state, working_set) 

2285 ) 

2286 if _eval_condition(exit_condition, exit_ctx): 

2287 passed = True 

2288 break 

2289 else: 

2290 passed = True 

2291 break 

2292 

2293 skipped = False 

2294 exhausted = not passed 

2295 final: PipelineState = {**state, step_id: { 

2296 "passed": passed, 

2297 "iterations": iteration, 

2298 "output": current, 

2299 "exhausted": exhausted, 

2300 "skipped": skipped, 

2301 }} 

2302 # ENG-11: a while that ends exhausted (ran max_iterations without 

2303 # exit_condition passing, not skipped) and whose `fail_if_exhausted` 

2304 # evaluates true is a hard run failure — nothing downstream runs. A 

2305 # skipped or passed while must never raise. 

2306 fail_if_exhausted = step.get("fail_if_exhausted") 

2307 if ( 

2308 fail_if_exhausted is not None 

2309 and exhausted 

2310 and _eval_condition(fail_if_exhausted, self._make_ctx(read_state)) 

2311 ): 

2312 raise RunHardFailure( 

2313 "EXHAUSTED", 

2314 f"while step '{step_id}' exhausted max_iterations={max_iter} " 

2315 "without exit_condition passing (fail_if_exhausted)", 

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

2317 ) 

2318 # Propagate any errors accumulated in the loop body back to the outer state 

2319 loop_errors = loop_state.get("errors") or [] 

2320 if loop_errors: 

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

2322 return final 

2323 

2324 def _run_spawn( 

2325 self, 

2326 step: dict, 

2327 state: PipelineState, 

2328 working_set: ChannelWorkingSet | None = None, 

2329 ) -> PipelineState: 

2330 """Run a spawn step: a recursive node tree of agent calls (S2). 

2331 

2332 The `agent` runs once as director at the root and may emit a `children` 

2333 plan; every deeper node re-runs the `child` role (default = `agent`) 

2334 with that node's own `input`. Depth and fan are bounded by the 

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

2336 """ 

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

2338 read_state = self._read_state(state, working_set) 

2339 ctx = self._make_ctx(read_state) 

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

2341 

2342 max_depth_raw = policy.get("max_depth") 

2343 if max_depth_raw is None: 

2344 max_depth = _SPAWN_DEFAULT_MAX_DEPTH 

2345 else: 

2346 max_depth = _resolve_int(max_depth_raw, ctx, _SPAWN_DEFAULT_MAX_DEPTH) 

2347 

2348 max_spawn_raw = policy.get("max_spawn_per_node") 

2349 if max_spawn_raw is None: 

2350 max_spawn_per_node = _SPAWN_DEFAULT_MAX_SPAWN_PER_NODE 

2351 else: 

2352 max_spawn_per_node = _resolve_int(max_spawn_raw, ctx, _SPAWN_DEFAULT_MAX_SPAWN_PER_NODE) 

2353 

2354 max_calls_raw = policy.get("max_agent_calls") 

2355 if max_calls_raw is None: 

2356 budget = SpawnBudget(_SPAWN_DEFAULT_MAX_AGENT_CALLS) 

2357 else: 

2358 budget = SpawnBudget(_resolve_int(max_calls_raw, ctx, _SPAWN_DEFAULT_MAX_AGENT_CALLS)) 

2359 

2360 run_max_parallel = int((self._run_context.get("budget") or {}).get("max_parallel", DEFAULT_MAX_PARALLEL)) 

2361 max_concurrent_raw = policy.get("max_concurrent_children") 

2362 if max_concurrent_raw is None: 

2363 max_concurrent_children = run_max_parallel 

2364 else: 

2365 max_concurrent_children = _resolve_int(max_concurrent_raw, ctx, run_max_parallel) 

2366 

2367 director = step.get("agent") 

2368 if not director: 

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

2370 return state 

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

2372 

2373 envelope, metrics = self._spawn_node( 

2374 node_id=step_id, 

2375 role=director, 

2376 depth=0, 

2377 step=step, 

2378 input_override=_UNSET, 

2379 base_state=read_state, 

2380 base_ctx=ctx, 

2381 child_role=child_role, 

2382 budget=budget, 

2383 max_depth=max_depth, 

2384 max_spawn_per_node=max_spawn_per_node, 

2385 max_concurrent_children=max_concurrent_children, 

2386 working_set=working_set, 

2387 ) 

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

2389 if metrics: 

2390 final = { 

2391 **final, 

2392 "step_context_metrics": _merge_metrics( 

2393 state.get("step_context_metrics"), metrics 

2394 ), 

2395 } 

2396 return final 

2397 

2398 def _spawn_node( 

2399 self, 

2400 *, 

2401 node_id: str, 

2402 role: str, 

2403 depth: int, 

2404 step: dict, 

2405 input_override: Any, 

2406 base_state: PipelineState, 

2407 base_ctx: dict, 

2408 child_role: str, 

2409 budget: SpawnBudget, 

2410 max_depth: int, 

2411 max_spawn_per_node: int | None, 

2412 max_concurrent_children: int, 

2413 working_set: ChannelWorkingSet | None, 

2414 ) -> tuple[dict, dict]: 

2415 """Run one spawn-tree node; returns (node envelope, subtree metrics).""" 

2416 tracer = self._tracer 

2417 parent_obs = ( 

2418 tracer.open_parent_observation( 

2419 name=f"spawn:{node_id}", 

2420 input={"node_id": node_id, "role": role, "depth": depth}, 

2421 ) 

2422 if tracer 

2423 else None 

2424 ) 

2425 try: 

2426 resolved_role = self._resolver.resolve(role) 

2427 resolved_step = self._resolve_step_runtime( 

2428 step, base_state, base_ctx, input_override=input_override, 

2429 ) 

2430 resolved_step = self._attach_communication_context( 

2431 resolved_role, resolved_step, base_state 

2432 ) 

2433 resolved_step = self._attach_usage_instructions(resolved_step) 

2434 if not budget.consume(): 

2435 raise RunHardFailure( 

2436 "SPAWN_BUDGET_EXHAUSTED", 

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

2438 {"node_id": node_id, "depth": depth}, 

2439 ) 

2440 result_state = self._invoke_agent(resolved_role, resolved_step, base_state) 

2441 this_result = result_state.get(node_id) 

2442 metrics = dict(result_state.get("step_context_metrics") or {}) 

2443 self._stage_spawn_channel_writes( 

2444 resolved_role, this_result, node_id, working_set 

2445 ) 

2446 

2447 children = self._spawn_children_from_result(this_result) 

2448 envelope: dict = { 

2449 "result": this_result, 

2450 "children": {}, 

2451 "passed": not self._spawn_is_soft_fail(this_result), 

2452 "failing": [], 

2453 "exhausted": False, 

2454 } 

2455 

2456 if not children: 

2457 return envelope, metrics 

2458 if depth + 1 > max_depth: 

2459 envelope["exhausted"] = True 

2460 return envelope, metrics 

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

2462 raise RunHardFailure( 

2463 "SPAWN_OVER_FLIGHT", 

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

2465 f"exceeds max_spawn_per_node={max_spawn_per_node}", 

2466 { 

2467 "node_id": node_id, 

2468 "depth": depth, 

2469 "children": len(children), 

2470 "max_spawn_per_node": max_spawn_per_node, 

2471 }, 

2472 ) 

2473 

2474 pool_size = self._spawn_pool_size(max_spawn_per_node, max_concurrent_children) 

2475 if len(children) <= 1 or pool_size <= 1: 

2476 for idx, child in enumerate(children): 

2477 child_id, child_envelope, child_metrics = self._spawn_child( 

2478 node_id=node_id, 

2479 child_role=child_role, 

2480 depth=depth, 

2481 step=step, 

2482 base_state=base_state, 

2483 base_ctx=base_ctx, 

2484 budget=budget, 

2485 max_depth=max_depth, 

2486 max_spawn_per_node=max_spawn_per_node, 

2487 max_concurrent_children=max_concurrent_children, 

2488 working_set=working_set, 

2489 child=child, 

2490 idx=idx, 

2491 ) 

2492 metrics = _merge_metrics(metrics, child_metrics) 

2493 envelope["children"][child_id] = child_envelope 

2494 if not child_envelope["passed"]: 

2495 envelope["passed"] = False 

2496 envelope["failing"].append(child_id) 

2497 return envelope, metrics 

2498 

2499 pool = ThreadPoolExecutor(max_workers=pool_size) 

2500 cancel = threading.Event() 

2501 futures = [] 

2502 child_working_sets: dict[Any, ChannelWorkingSet] = {} 

2503 for idx, child in enumerate(children): 

2504 child_working_set = ( 

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

2506 ) 

2507 ctx = contextvars.copy_context() 

2508 future = pool.submit( 

2509 ctx.run, 

2510 self._spawn_child, 

2511 node_id=node_id, 

2512 child_role=child_role, 

2513 depth=depth, 

2514 step=step, 

2515 base_state=base_state, 

2516 base_ctx=base_ctx, 

2517 budget=budget, 

2518 max_depth=max_depth, 

2519 max_spawn_per_node=max_spawn_per_node, 

2520 max_concurrent_children=max_concurrent_children, 

2521 working_set=child_working_set, 

2522 child=child, 

2523 idx=idx, 

2524 ) 

2525 futures.append(future) 

2526 if child_working_set is not None: 

2527 child_working_sets[future] = child_working_set 

2528 hard_error: Exception | None = None 

2529 child_results: dict[Any, tuple[str, dict, dict]] = {} 

2530 try: 

2531 for fut in as_completed(futures): 

2532 try: 

2533 child_results[fut] = fut.result() 

2534 except (RunHardFailure, SkillLoadError) as exc: 

2535 hard_error = exc 

2536 cancel.set() 

2537 for pending in futures: 

2538 pending.cancel() 

2539 break 

2540 if hard_error is None: 

2541 for fut in futures: 

2542 child_id, child_envelope, child_metrics = child_results[fut] 

2543 self._merge_spawn_fork_writes( 

2544 working_set, child_working_sets.get(fut) 

2545 ) 

2546 metrics = _merge_metrics(metrics, child_metrics) 

2547 envelope["children"][child_id] = child_envelope 

2548 if not child_envelope["passed"]: 

2549 envelope["passed"] = False 

2550 envelope["failing"].append(child_id) 

2551 finally: 

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

2553 if hard_error is not None: 

2554 raise hard_error 

2555 

2556 return envelope, metrics 

2557 finally: 

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

2559 tracer.close_parent_observation(parent_obs) 

2560 

2561 def _stage_spawn_channel_writes( 

2562 self, 

2563 resolved_role: dict, 

2564 result: Any, 

2565 node_id: str, 

2566 working_set: ChannelWorkingSet | None, 

2567 ) -> None: 

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

2569 if working_set is None: 

2570 return 

2571 writes = _declared_output_channel_writes( 

2572 resolved_role, result, self._contracts, self._channel_names, node_id 

2573 ) 

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

2575 working_set.stage(name, value) 

2576 

2577 @staticmethod 

2578 def _merge_spawn_fork_writes( 

2579 working_set: ChannelWorkingSet | None, 

2580 fork: ChannelWorkingSet | None, 

2581 ) -> None: 

2582 """Fold a completed child's isolated fork writes into the parent set. 

2583 

2584 Runs on the main thread after the child future resolves, so the shared 

2585 working set is never mutated from a worker thread. 

2586 """ 

2587 if working_set is None or fork is None: 

2588 return 

2589 for name, value in fork.pending_writes(): 

2590 working_set.stage(name, value) 

2591 

2592 @staticmethod 

2593 def _spawn_pool_size(max_spawn_per_node: int | None, max_concurrent_children: int) -> int: 

2594 if max_spawn_per_node is None: 

2595 return max(1, max_concurrent_children) 

2596 return max(1, min(max_spawn_per_node, max_concurrent_children)) 

2597 

2598 def _spawn_child( 

2599 self, 

2600 *, 

2601 node_id: str, 

2602 child_role: str, 

2603 depth: int, 

2604 step: dict, 

2605 base_state: PipelineState, 

2606 base_ctx: dict, 

2607 budget: SpawnBudget, 

2608 max_depth: int, 

2609 max_spawn_per_node: int | None, 

2610 max_concurrent_children: int, 

2611 working_set: ChannelWorkingSet | None, 

2612 child: Any, 

2613 idx: int, 

2614 ) -> tuple[str, dict, dict]: 

2615 if not self._spawn_has_input(child): 

2616 raise RunHardFailure( 

2617 "INVALID_CHILD_PLAN", 

2618 f"spawn child #{idx} of node '{node_id}' must be a dict " 

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

2620 {"node_id": node_id, "child_index": idx, "child": repr(child)[:200]}, 

2621 ) 

2622 child_id = child["id"] if child.get("id") else f"{node_id}.{idx}" 

2623 child_input = child["input"] 

2624 resolved_child_input = _resolve_input(child_input, base_state, base_ctx) 

2625 child_envelope, child_metrics = self._spawn_node( 

2626 node_id=child_id, 

2627 role=child_role, 

2628 depth=depth + 1, 

2629 step={"id": child_id, "input": resolved_child_input}, 

2630 input_override=resolved_child_input, 

2631 base_state=base_state, 

2632 base_ctx=base_ctx, 

2633 child_role=child_role, 

2634 budget=budget, 

2635 max_depth=max_depth, 

2636 max_spawn_per_node=max_spawn_per_node, 

2637 max_concurrent_children=max_concurrent_children, 

2638 working_set=working_set, 

2639 ) 

2640 return child_id, child_envelope, child_metrics 

2641 

2642 @staticmethod 

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

2644 """The node's child plan: [] when absent or empty; raises on a malformed plan.""" 

2645 if not isinstance(result, dict): 

2646 return [] 

2647 raw = result.get("children") 

2648 if raw is None: 

2649 return [] 

2650 if not isinstance(raw, list): 

2651 raise RunHardFailure( 

2652 "INVALID_CHILD_PLAN", 

2653 "spawn agent 'children' must be a list", 

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

2655 ) 

2656 return raw 

2657 

2658 @staticmethod 

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

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

2661 if not isinstance(child, dict): 

2662 return False 

2663 input_val = child.get("input") 

2664 if input_val is None: 

2665 return False 

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

2667 return False 

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

2669 return False 

2670 return True 

2671 

2672 @staticmethod 

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

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

2675 

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

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

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

2679 """ 

2680 if not isinstance(result, dict): 

2681 return False 

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

2683 return False 

2684 if _SPAWN_SOFT_FAIL_KEYS & result.keys(): 

2685 return True 

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

2687 

2688 

2689# --------------------------------------------------------------------------- 

2690# GraphBuilder 

2691# --------------------------------------------------------------------------- 

2692 

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

2694 """ 

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

2696 """ 

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

2698 visited: set[str] = set() 

2699 order: list[dict] = [] 

2700 

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

2702 if step_id in visited: 

2703 return 

2704 visited.add(step_id) 

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

2706 if dep in id_to_step: 

2707 visit(dep) 

2708 order.append(id_to_step[step_id]) 

2709 

2710 for s in steps: 

2711 visit(s["id"]) 

2712 return order 

2713 

2714 

2715# --------------------------------------------------------------------------- 

2716# Call-step build-time expansion (ADR-0025 D3/D4) 

2717# --------------------------------------------------------------------------- 

2718# 

2719# A `type: call` step is expanded at build time into the function's inlined body 

2720# steps plus two synthesized op steps (`__call_bind` / `__call_emit`). The 

2721# runtime executes only this flat graph; LangGraph never sees a call step. The 

2722# caller reads the function outputs via the call result shape 

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

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

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

2726# 

2727# Isolation (ADR-0025 D5 / 00-functions § Isolation) and recursion (D7/D9): a 

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

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

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

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

2732 

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

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

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

2736 

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

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

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

2740 """ 

2741 if isinstance(value, str): 

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

2743 if isinstance(value, dict): 

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

2745 if isinstance(value, list): 

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

2747 return value 

2748 

2749 

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

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

2752 scoped: dict = dict(step) 

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

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

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

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

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

2758 return scoped 

2759 

2760 

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

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

2763 ids: set[str] = set() 

2764 for step in steps: 

2765 step_id = step.get("id") 

2766 if isinstance(step_id, str): 

2767 ids.add(step_id) 

2768 body = step.get("body") 

2769 if isinstance(body, list): 

2770 ids |= _collect_step_ids(body) 

2771 return ids 

2772 

2773 

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

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

2776 for step in steps: 

2777 yield step 

2778 body = step.get("body") 

2779 if isinstance(body, list): 

2780 yield from _iter_steps(body) 

2781 

2782 

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

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

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

2786 

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

2788 

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

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

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

2792 

2793 

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

2795 """Root identifiers read by templated strings. 

2796 

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

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

2799 literals are ignored. 

2800 """ 

2801 names: set[str] = set() 

2802 if isinstance(value, str): 

2803 for expr in _TEMPLATE_REF_RE.findall(value): 

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

2805 for match in _IDENTIFIER_RE.finditer(bare): 

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

2807 names.add(match.group()) 

2808 elif isinstance(value, dict): 

2809 for v in value.values(): 

2810 names |= _referenced_names(v) 

2811 elif isinstance(value, list): 

2812 for v in value: 

2813 names |= _referenced_names(v) 

2814 return names 

2815 

2816 

2817def _validate_function_purity( 

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

2819) -> None: 

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

2821 

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

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

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

2825 """ 

2826 local_ids = _collect_step_ids(body) 

2827 for step in _iter_steps(body): 

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

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

2830 if dep in visible_ids and dep not in local_ids: 

2831 raise ValueError( 

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

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

2834 "function bodies see only their own steps and params)" 

2835 ) 

2836 for field in _REFERENCE_FIELDS: 

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

2838 if ref in _RESERVED_NAMESPACES: 

2839 continue 

2840 if ref in visible_ids and ref not in local_ids: 

2841 raise ValueError( 

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

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

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

2845 "steps and params)" 

2846 ) 

2847 

2848 

2849def _expand_call( 

2850 step: dict, 

2851 registry: ManifestRegistry, 

2852 stack: list[str], 

2853 call_map: dict[str, str], 

2854 expanded_functions: dict[str, str], 

2855 visible_ids: set[str], 

2856) -> list[dict]: 

2857 """Expand a call step into bind + inlined body + emit steps.""" 

2858 fn_name = step["function"] 

2859 call_id = step["id"] 

2860 

2861 if fn_name in stack: 

2862 chain = " -> ".join(stack + [fn_name]) 

2863 raise ValueError( 

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

2865 "(ADR-0025 D7/D9 — recursion must be a bounded while loop or idempotent re-run)" 

2866 ) 

2867 

2868 # ADR-0025 D7: inlining the same function twice in one pipeline build 

2869 # collides its body step-ids (flat graph). This is a hard build error until 

2870 # id-prefixing lands; naming the function and both call sites pins the bug. 

2871 if fn_name in expanded_functions: 

2872 raise ValueError( 

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

2874 f"'{expanded_functions[fn_name]}' and '{call_id}' in one pipeline — " 

2875 "inlined body step ids collide (ADR-0025 D7)" 

2876 ) 

2877 expanded_functions[fn_name] = call_id 

2878 

2879 function = registry.get("Function", fn_name) 

2880 if function is None: 

2881 raise ValueError( 

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

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

2884 ) 

2885 

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

2887 _validate_function_purity(fn_name, body, visible_ids) 

2888 

2889 sub_stack = list(stack) + [fn_name] 

2890 nested_visible = visible_ids | _collect_step_ids(body) 

2891 outputs_spec = ((function.get("spec") or {}).get("interface") or {}).get("outputs") or [] 

2892 

2893 bind_id = f"_call__{call_id}__bind" 

2894 emit_id = f"_call__{call_id}__emit" 

2895 call_map[call_id] = emit_id 

2896 

2897 bind_step: dict = { 

2898 "id": bind_id, 

2899 "type": "builtin", 

2900 "component": "__call_bind", 

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

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

2903 } 

2904 

2905 expanded_body: list[dict] = [] 

2906 for body_step in body: 

2907 scoped = _scope_params(body_step, call_id) 

2908 for sub in _expand_step( 

2909 scoped, registry, sub_stack, call_map, expanded_functions, nested_visible 

2910 ): 

2911 dep = list(sub.get("depends_on") or []) 

2912 if bind_id not in dep: 

2913 sub["depends_on"] = [bind_id, *dep] 

2914 expanded_body.append(sub) 

2915 

2916 emit_step: dict = { 

2917 "id": emit_id, 

2918 "type": "builtin", 

2919 "component": "__call_emit", 

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

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

2922 } 

2923 

2924 return [bind_step, *expanded_body, emit_step] 

2925 

2926 

2927def _expand_step( 

2928 step: dict, 

2929 registry: ManifestRegistry, 

2930 stack: list[str], 

2931 call_map: dict[str, str], 

2932 expanded_functions: dict[str, str], 

2933 visible_ids: set[str], 

2934) -> list[dict]: 

2935 """Expand one step (recursing into while/foreach bodies).""" 

2936 if step.get("type") == "call": 

2937 return _expand_call( 

2938 step, registry, stack, call_map, expanded_functions, visible_ids 

2939 ) 

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

2941 expanded_body: list[dict] = [] 

2942 for sub in step["body"]: 

2943 expanded_body.extend( 

2944 _expand_step( 

2945 sub, registry, stack, call_map, expanded_functions, visible_ids 

2946 ) 

2947 ) 

2948 return [{**step, "body": expanded_body}] 

2949 return [step] 

2950 

2951 

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

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

2954 

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

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

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

2958 the function body ran. 

2959 """ 

2960 for step in steps: 

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

2962 if deps: 

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

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

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

2966 

2967 

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

2969 """Expand all `call` steps into the flat runtime graph (ADR-0025 D4). 

2970 

2971 Returns a new step list with each call replaced by its bind + inlined body + 

2972 emit steps; a call step never survives to the runtime graph. Cycles and 

2973 outward references in a function body are hard errors. 

2974 """ 

2975 call_map: dict[str, str] = {} 

2976 expanded_functions: dict[str, str] = {} 

2977 visible_ids = _collect_step_ids(steps) 

2978 expanded: list[dict] = [] 

2979 for step in steps: 

2980 expanded.extend( 

2981 _expand_step(step, registry, [], call_map, expanded_functions, visible_ids) 

2982 ) 

2983 _rewrite_call_deps(expanded, call_map) 

2984 return expanded 

2985 

2986 

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

2988 

2989 

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

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

2992 

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

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

2995 an error that identifies the offending entry. 

2996 """ 

2997 if not isinstance(channel, dict): 

2998 raise ValueError( 

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

3000 f"{type(channel).__name__}" 

3001 ) 

3002 name = channel.get("name") 

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

3004 raise ValueError( 

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

3006 "channel requires a non-empty string 'name'" 

3007 ) 

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

3009 raise ValueError( 

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

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

3012 ) 

3013 

3014 

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

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

3017 

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

3019 """ 

3020 if raw_channels is None: 

3021 return [] 

3022 if not isinstance(raw_channels, list): 

3023 raise ValueError( 

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

3025 f"{type(raw_channels).__name__}" 

3026 ) 

3027 for index, channel in enumerate(raw_channels): 

3028 _validate_channel_entry(index, channel) 

3029 return raw_channels 

3030 

3031 

3032def _index_data_contracts(raw_contracts: Any) -> dict[str, dict]: 

3033 """Index spec.data_contracts by name; absent/empty yields an empty map. 

3034 

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

3036 malformed entry is rejected here with an error that identifies it. 

3037 """ 

3038 if raw_contracts is None: 

3039 return {} 

3040 if not isinstance(raw_contracts, list): 

3041 raise ValueError( 

3042 f"Pipeline spec.data_contracts must be a list; got " 

3043 f"{type(raw_contracts).__name__}" 

3044 ) 

3045 contracts: dict[str, dict] = {} 

3046 for index, contract in enumerate(raw_contracts): 

3047 if not isinstance(contract, dict): 

3048 raise ValueError( 

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

3050 f"{type(contract).__name__}" 

3051 ) 

3052 name = contract.get("name") 

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

3054 raise ValueError( 

3055 f"Pipeline spec.data_contracts[{index}] is missing a valid " 

3056 "'name'; each data contract requires a non-empty string 'name'" 

3057 ) 

3058 contracts[name] = contract 

3059 return contracts 

3060 

3061 

3062def _validate_channel_bindings( 

3063 contracts: dict[str, dict], channel_names: frozenset[str] 

3064) -> None: 

3065 """Reject malformed or undeclared channel bindings at build time. 

3066 

3067 Every ``output.channels`` map inside ``spec.data_contracts`` must be a 

3068 mapping of non-empty output-field names to declared channel names. This 

3069 runs unconditionally, so a binding to a channel absent from 

3070 ``spec.channels`` fails the build even when no channels are declared. 

3071 """ 

3072 for contract_name, contract in contracts.items(): 

3073 output = contract.get("output") 

3074 if not isinstance(output, dict): 

3075 continue 

3076 bindings = output.get("channels") 

3077 if bindings is None: 

3078 continue 

3079 if not isinstance(bindings, dict): 

3080 raise RunHardFailure( 

3081 "CHANNEL_BINDING_ERROR", 

3082 f"Data contract '{contract_name}' output.channels must be a " 

3083 "mapping of output field name to channel name; got " 

3084 f"{type(bindings).__name__}", 

3085 ) 

3086 for field_name, channel_name in bindings.items(): 

3087 if not isinstance(field_name, str) or not field_name: 

3088 raise RunHardFailure( 

3089 "CHANNEL_BINDING_ERROR", 

3090 f"Data contract '{contract_name}' output.channels has an " 

3091 f"invalid output field key '{field_name}'; field names must " 

3092 "be non-empty strings", 

3093 ) 

3094 if not isinstance(channel_name, str) or not channel_name: 

3095 raise RunHardFailure( 

3096 "CHANNEL_BINDING_ERROR", 

3097 f"Data contract '{contract_name}' routes output field " 

3098 f"'{field_name}' to an invalid channel name " 

3099 f"'{channel_name}'; channel names must be non-empty strings", 

3100 ) 

3101 if channel_name not in channel_names: 

3102 raise RunHardFailure( 

3103 "CHANNEL_BINDING_ERROR", 

3104 f"Data contract '{contract_name}' routes output field " 

3105 f"'{field_name}' to undeclared channel '{channel_name}'; " 

3106 "the channel must be declared in spec.channels", 

3107 ) 

3108 

3109 

3110def _validate_channel_read_bindings( 

3111 contracts: dict[str, dict], channel_names: frozenset[str] 

3112) -> None: 

3113 """Reject malformed or undeclared read bindings at build time. 

3114 

3115 Every ``input.channels`` list inside ``spec.data_contracts`` must be an 

3116 ordered list of non-empty channel names declared in ``spec.channels``. 

3117 This runs unconditionally, so a declared read of a channel absent from 

3118 ``spec.channels`` fails the build even when no channels are declared. 

3119 """ 

3120 for contract_name, contract in contracts.items(): 

3121 inputs = contract.get("input") 

3122 if not isinstance(inputs, dict): 

3123 continue 

3124 reads = inputs.get("channels") 

3125 if reads is None: 

3126 continue 

3127 if not isinstance(reads, list): 

3128 raise RunHardFailure( 

3129 "CHANNEL_BINDING_ERROR", 

3130 f"Data contract '{contract_name}' input.channels must be a " 

3131 f"list of channel names; got {type(reads).__name__}", 

3132 ) 

3133 for channel_name in reads: 

3134 if not isinstance(channel_name, str) or not channel_name: 

3135 raise RunHardFailure( 

3136 "CHANNEL_BINDING_ERROR", 

3137 f"Data contract '{contract_name}' input.channels has an " 

3138 f"invalid channel name '{channel_name}'; channel names must " 

3139 "be non-empty strings", 

3140 ) 

3141 if channel_name not in channel_names: 

3142 raise RunHardFailure( 

3143 "CHANNEL_BINDING_ERROR", 

3144 f"Data contract '{contract_name}' reads undeclared channel " 

3145 f"'{channel_name}'; the channel must be declared in " 

3146 "spec.channels", 

3147 ) 

3148 

3149 

3150def _step_agent_refs(step: dict) -> list[str]: 

3151 """Agent references a step resolves: its agent, plus a spawn's child role.""" 

3152 refs: list[str] = [] 

3153 agent_name = step.get("agent") 

3154 if agent_name: 

3155 refs.append(agent_name) 

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

3157 child_name = step.get("child") 

3158 if child_name: 

3159 refs.append(child_name) 

3160 return refs 

3161 

3162 

3163def _resolve_agent_or_none(resolver: Any, agent_name: str) -> dict | None: 

3164 """Resolve an agent reference, or None when the name is unknown. 

3165 

3166 Unknown agents remain a runtime concern: this build-time validator inspects 

3167 only the declared data_contract of agents it can resolve, so an unknown 

3168 agent name never becomes a new build error. 

3169 """ 

3170 try: 

3171 return resolver.resolve(agent_name) 

3172 except KeyError: 

3173 return None 

3174 

3175 

3176def _validate_step_contract_bindings( 

3177 steps: list[dict], contracts: dict[str, dict], resolver: Any 

3178) -> None: 

3179 """Reject step agents whose declared data_contract is not in the spec. 

3180 

3181 Every agent-backed step — including nested loop bodies and both roles of a 

3182 spawn — must reference a contract declared in ``spec.data_contracts``. 

3183 """ 

3184 for step in _iter_steps(steps): 

3185 for agent_name in _step_agent_refs(step): 

3186 agent_spec = _resolve_agent_or_none(resolver, agent_name) 

3187 if agent_spec is None: 

3188 continue 

3189 contract_name = (agent_spec.get("spec") or {}).get("data_contract") 

3190 if contract_name and contract_name not in contracts: 

3191 raise RunHardFailure( 

3192 "CHANNEL_BINDING_ERROR", 

3193 f"Step '{step.get('id', '?')}' agent declares data_contract " 

3194 f"'{contract_name}' which is not declared in the pipeline " 

3195 "spec.data_contracts", 

3196 ) 

3197 

3198 

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

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

3201 forbidden = ( 

3202 {_CORE} 

3203 | set(_RESERVED_NAMESPACES) 

3204 | _CORE_STATE_KEYS 

3205 | _collect_step_ids(steps) 

3206 ) 

3207 seen: set[str] = set() 

3208 for channel in channels: 

3209 name = channel.get("name") 

3210 if name in seen: 

3211 raise ValueError( 

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

3213 "names must be unique" 

3214 ) 

3215 seen.add(name) 

3216 if name in forbidden: 

3217 raise ValueError( 

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

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

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

3221 "non-reserved" 

3222 ) 

3223 

3224 

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

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

3227 

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

3229 are not state reads and yield None. 

3230 """ 

3231 if "{{" in value: 

3232 return None 

3233 stripped = value.lstrip() 

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

3235 return None 

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

3237 

3238 

3239def _iter_input_roots(input_spec: Any): 

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

3241 if isinstance(input_spec, str): 

3242 root = _input_path_root(input_spec) 

3243 if root: 

3244 yield root 

3245 elif isinstance(input_spec, dict): 

3246 for value in input_spec.values(): 

3247 yield from _iter_input_roots(value) 

3248 elif isinstance(input_spec, list): 

3249 for value in input_spec: 

3250 yield from _iter_input_roots(value) 

3251 

3252 

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

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

3255 

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

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

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

3259 there is no silent fallback to a missing key. 

3260 """ 

3261 allowed = ( 

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

3263 | _collect_step_ids(steps) 

3264 | set(_RESERVED_NAMESPACES) 

3265 | _CORE_STATE_KEYS 

3266 ) 

3267 for step in _iter_steps(steps): 

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

3269 if root not in allowed: 

3270 raise ValueError( 

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

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

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

3274 "reserved namespace, or an engine state key" 

3275 ) 

3276 

3277 

3278def _resolve_allow_instruction( 

3279 entry: str, builtins: BuiltinExecutor, step_functions: StepFunctionExecutor | None 

3280) -> str | None: 

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

3282 

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

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

3285 StepFunction's ``agent_instructions``. 

3286 """ 

3287 return instruction_text(entry, builtins, step_functions) 

3288 

3289 

3290_AGENT_INVOKING_STEP_TYPES = frozenset({"sequential", "parallel", "spawn", "foreach"}) 

3291 

3292 

3293def _validate_allow_step_types(steps: list[dict]) -> None: 

3294 """Reject ``allow`` on a step that does not actually invoke an agent. 

3295 

3296 Only an agent-bearing step consumes its whitelist; on any other step the 

3297 declaration would be silently dropped — notably a ``call`` step, which 

3298 build-time expansion removes before the whitelist validator runs, and an 

3299 agent-less ``sequential``/``spawn``/``foreach``. A container step that 

3300 declares a ``body`` runs its body path, so a ``body`` wins over a declared 

3301 ``agent`` and the whitelist would be inert. Runs on the raw step list, 

3302 before call expansion. 

3303 """ 

3304 for step in _iter_steps(steps): 

3305 if not step.get("allow"): 

3306 continue 

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

3308 agent = step.get("agent") 

3309 invokes_agent = ( 

3310 step_type in _AGENT_INVOKING_STEP_TYPES 

3311 and agent 

3312 and not step.get("body") 

3313 ) 

3314 if invokes_agent: 

3315 continue 

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

3317 raise RunHardFailure( 

3318 "ALLOW_INVALID_STEP_TYPE", 

3319 f"Step '{step_id}' declares 'allow' but is not an agent-bearing " 

3320 f"step that invokes an agent: type '{step_type}' with agent " 

3321 f"'{agent}' does not actually invoke an agent", 

3322 {"step_id": step_id, "type": step_type, "agent": agent}, 

3323 ) 

3324 

3325 

3326def _validate_allow_instructions( 

3327 steps: list[dict], 

3328 builtins: BuiltinExecutor, 

3329 step_functions: StepFunctionExecutor | None, 

3330) -> None: 

3331 """Require a non-empty usage instruction for every ``allow`` entry. 

3332 

3333 Walks all steps — including nested loop/parallel bodies — and fails fast 

3334 when a whitelisted name resolves to no instruction: neither a whitelistable 

3335 builtin registry text nor a StepFunction ``agent_instructions``. 

3336 """ 

3337 for step in _iter_steps(steps): 

3338 for entry in step.get("allow") or []: 

3339 if _resolve_allow_instruction(entry, builtins, step_functions): 

3340 continue 

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

3342 raise RunHardFailure( 

3343 "AGENT_INSTRUCTION_MISSING", 

3344 f"Step '{step_id}' allow entry '{entry}' has no usage " 

3345 "instruction: it is neither a whitelistable builtin nor a " 

3346 "StepFunction with agent_instructions", 

3347 {"step_id": step_id, "allow": entry}, 

3348 ) 

3349 

3350 

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

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

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

3354 

3355 

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

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

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

3359 

3360 

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

3362 

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

3364 

3365 

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

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

3368 

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

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

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

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

3373 """ 

3374 if not isinstance(value, dict): 

3375 return True 

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

3377 return False 

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

3379 

3380 

3381def _declared_channel_bindings( 

3382 agent_spec: dict, 

3383 contracts: dict[str, dict], 

3384 channel_names: frozenset[str], 

3385 step_id: str, 

3386) -> dict[str, str]: 

3387 """Return the agent contract's {output_field: channel_name} bindings. 

3388 

3389 The mapping is the reserved top-level ``channels`` key inside the 

3390 contract's free-form ``output``. A reference to an undeclared contract or 

3391 an undeclared target channel is a hard error. 

3392 """ 

3393 contract_name = (agent_spec.get("spec") or {}).get("data_contract") 

3394 if not contract_name: 

3395 return {} 

3396 contract = contracts.get(contract_name) 

3397 if contract is None: 

3398 raise RunHardFailure( 

3399 "CHANNEL_BINDING_ERROR", 

3400 f"Step '{step_id}' agent declares data_contract '{contract_name}' " 

3401 "which is not declared in the pipeline spec.data_contracts", 

3402 ) 

3403 output = contract.get("output") 

3404 if not isinstance(output, dict): 

3405 return {} 

3406 channels = output.get("channels") 

3407 if not isinstance(channels, dict): 

3408 return {} 

3409 bindings: dict[str, str] = {} 

3410 for field_name, channel_name in channels.items(): 

3411 if channel_name not in channel_names: 

3412 raise RunHardFailure( 

3413 "CHANNEL_BINDING_ERROR", 

3414 f"Step '{step_id}' data_contract '{contract_name}' routes output " 

3415 f"field '{field_name}' to undeclared channel '{channel_name}'; " 

3416 "the channel must be declared in spec.channels", 

3417 ) 

3418 bindings[field_name] = channel_name 

3419 return bindings 

3420 

3421 

3422def _declared_output_channel_writes( 

3423 agent_spec: dict, 

3424 output: Any, 

3425 contracts: dict[str, dict], 

3426 channel_names: frozenset[str], 

3427 step_id: str, 

3428) -> dict[str, Any]: 

3429 """Route one agent output's declared channel-bound fields to their channels. 

3430 

3431 Returns ``{channel_name: value}`` for the caller to stage into the working 

3432 set. Failure envelopes are not routed; a successful dict missing a 

3433 declared field is a hard error. 

3434 """ 

3435 bindings = _declared_channel_bindings(agent_spec, contracts, channel_names, step_id) 

3436 if not bindings: 

3437 return {} 

3438 if _is_failure_envelope(output): 

3439 return {} 

3440 writes: dict[str, Any] = {} 

3441 for field_name, channel_name in bindings.items(): 

3442 if field_name not in output: 

3443 raise RunHardFailure( 

3444 "CHANNEL_BINDING_ERROR", 

3445 f"Step '{step_id}' output is missing declared channel-bound " 

3446 f"field '{field_name}' (data_contract routes it to channel " 

3447 f"'{channel_name}')", 

3448 ) 

3449 writes[channel_name] = output[field_name] 

3450 return writes 

3451 

3452 

3453def _route_declared_channels( 

3454 step: dict, 

3455 pre_state: dict, 

3456 core: dict, 

3457 step_runner: Any, 

3458 contracts: dict[str, dict], 

3459 channel_names: frozenset[str], 

3460) -> dict[str, Any]: 

3461 """Route a step's declared channel-bound output fields to their channels. 

3462 

3463 Returns ``{channel_name: value}`` for LangGraph to merge through each 

3464 channel's reducer. Only an actual agent invocation is routed: a 

3465 ``skip_llm_if`` passthrough and a parallel fan-out envelope are excluded. 

3466 Non-declared output fields are left untouched in ``core`` 

3467 (non-destructive routing); failure envelopes are not routed. A spawn step 

3468 is routed at the spawn seam from its node result, never here from its 

3469 envelope. 

3470 """ 

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

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

3473 return {} 

3474 if step_id not in core: 

3475 return {} 

3476 agent_spec = step_runner.resolve_agent(step) 

3477 if agent_spec is None: 

3478 return {} 

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

3480 return {} 

3481 return _declared_output_channel_writes( 

3482 agent_spec, core[step_id], contracts, channel_names, step_id 

3483 ) 

3484 

3485 

3486def _make_step_node( 

3487 step: dict, 

3488 step_runner: Any, 

3489 channel_names: frozenset[str], 

3490 contracts: dict[str, dict], 

3491 channel_types: dict[str, str], 

3492) -> Any: 

3493 if not channel_names: 

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

3495 return step_runner.run_step(step, state) 

3496 return node_fn 

3497 

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

3499 committed = _flatten_channel_state(state) 

3500 working_set = ChannelWorkingSet(committed, channel_types) 

3501 core = _strip_channel_keys( 

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

3503 ) 

3504 writes = _route_declared_channels( 

3505 step, committed, core, step_runner, contracts, channel_names 

3506 ) 

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

3508 working_set.stage(name, value) 

3509 channels = working_set.commit() 

3510 return {_CORE: {**core, **channels}} 

3511 return node_fn 

3512 

3513 

3514def _collect_parallel_channel_writes( 

3515 steps: list[dict], 

3516 pre_state: dict, 

3517 core: dict, 

3518 step_runner: Any, 

3519 contracts: dict[str, dict], 

3520 channel_names: frozenset[str], 

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

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

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

3524 

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

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

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

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

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

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

3531 """ 

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

3533 for step in steps: 

3534 if branch_working_sets is not None: 

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

3536 if branch_working_set is not None: 

3537 writes.extend(branch_working_set.pending_writes()) 

3538 branch = _route_declared_channels( 

3539 step, pre_state, core, step_runner, contracts, channel_names 

3540 ) 

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

3542 writes.append((channel_name, value)) 

3543 return writes 

3544 

3545 

3546def _make_parallel_node( 

3547 group_steps: list[dict], 

3548 step_runner: Any, 

3549 channel_names: frozenset[str], 

3550 contracts: dict[str, dict], 

3551 channel_types: dict[str, str], 

3552) -> Any: 

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

3554 base = ( 

3555 _flatten_channel_state(state) 

3556 if channel_names else state 

3557 ) 

3558 working_set = ( 

3559 ChannelWorkingSet(base, channel_types) if channel_names else None 

3560 ) 

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

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

3563 branch_working_sets = ( 

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

3565 if working_set is not None else None 

3566 ) 

3567 

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

3569 if branch_working_sets is None: 

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

3571 return pool.submit( 

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

3573 ) 

3574 

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

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

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

3578 for future in as_completed(futures): 

3579 sid = futures[future] 

3580 try: 

3581 out = future.result() 

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

3583 except SkillLoadError: 

3584 raise 

3585 except RunHardFailure: 

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

3587 raise 

3588 except Exception as exc: # noqa: BLE001 

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

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

3591 if channel_names: 

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

3593 writes = _collect_parallel_channel_writes( 

3594 group_steps, base, merged, step_runner, contracts, channel_names, 

3595 branch_working_sets, 

3596 ) 

3597 for name, value in writes: 

3598 working_set.stage(name, value) 

3599 channels = working_set.commit() 

3600 return {_CORE: {**merged, **channels}} 

3601 return {**state, **results} 

3602 return node_fn 

3603 

3604 

3605class _ChannelGraphShim: 

3606 """Flat-dict boundary over a Core-owned channel-schema compiled graph. 

3607 

3608 Preserves the invoke contract used by main.py and tests: a flat dict in, 

3609 a flat dict out. The flat input is seeded through ``seed_committed`` and 

3610 nested under ``__core__`` for the compiled graph; the committed snapshot is 

3611 unwrapped back out. Streaming and state surfaces are not exposed because 

3612 they would leak or accept the internal ``__core__`` shape; read-only 

3613 introspection delegates to the compiled graph. 

3614 """ 

3615 

3616 def __init__(self, compiled: Any, specs: list[ChannelSpec]) -> None: 

3617 self._compiled = compiled 

3618 self._specs = specs 

3619 

3620 def invoke(self, flat_state: dict, *args: Any, **kwargs: Any) -> dict: 

3621 seeded = seed_committed(self._specs, flat_state) 

3622 result = self._compiled.invoke(wrap(seeded), *args, **kwargs) 

3623 return unwrap(result) 

3624 

3625 async def ainvoke(self, flat_state: dict, *args: Any, **kwargs: Any) -> dict: 

3626 seeded = seed_committed(self._specs, flat_state) 

3627 result = await self._compiled.ainvoke(wrap(seeded), *args, **kwargs) 

3628 return unwrap(result) 

3629 

3630 def stream(self, *args: Any, **kwargs: Any) -> Any: 

3631 self._reject_internal_shape("stream") 

3632 

3633 def astream(self, *args: Any, **kwargs: Any) -> Any: 

3634 self._reject_internal_shape("astream") 

3635 

3636 def update_state(self, *args: Any, **kwargs: Any) -> Any: 

3637 self._reject_internal_shape("update_state") 

3638 

3639 def aupdate_state(self, *args: Any, **kwargs: Any) -> Any: 

3640 self._reject_internal_shape("aupdate_state") 

3641 

3642 def get_state(self, *args: Any, **kwargs: Any) -> Any: 

3643 self._reject_internal_shape("get_state") 

3644 

3645 def aget_state(self, *args: Any, **kwargs: Any) -> Any: 

3646 self._reject_internal_shape("aget_state") 

3647 

3648 def _reject_internal_shape(self, surface: str) -> None: 

3649 raise NotImplementedError( 

3650 f"_ChannelGraphShim.{surface}() is not supported: only the flat " 

3651 ".invoke()/.ainvoke() contract is exposed through the channel " 

3652 "boundary shim" 

3653 ) 

3654 

3655 def __getattr__(self, name: str) -> Any: 

3656 return getattr(self._compiled, name) 

3657 

3658 

3659class GraphBuilder: 

3660 """ 

3661 Rakentaa LangGraph StateGraphin Pipeline-manifestista. 

3662 

3663 run_context: template-kontekstidata (budget, config jne.) — ei tallennu stateen, 

3664 käytetään vain {{ }} -templatejen evaluointiin. 

3665 """ 

3666 

3667 def __init__( 

3668 self, 

3669 registry: ManifestRegistry, 

3670 builtins: BuiltinExecutor | None = None, 

3671 invoke_agent_fn: Any | None = None, 

3672 run_context: dict | None = None, 

3673 tracer: Any | None = None, 

3674 ) -> None: 

3675 self._registry = registry 

3676 self._resolver = AgentResolver(registry) 

3677 self._invoke_agent_fn = invoke_agent_fn 

3678 self._builtins = builtins or _DEFAULT_BUILTINS 

3679 if invoke_agent_fn is not None: 

3680 self._builtins.invoke_agent_fn = invoke_agent_fn 

3681 self._step_functions = StepFunctionExecutor(registry) 

3682 self._step_runner = StepRunner( 

3683 self._resolver, self._builtins, invoke_agent_fn, run_context, 

3684 step_functions=self._step_functions, 

3685 tracer=tracer, 

3686 ) 

3687 

3688 def build(self, pipeline: dict): # -> CompiledGraph 

3689 """Rakentaa ja palauttaa compilatun LangGraph StateGraphin.""" 

3690 try: 

3691 from langgraph.graph import StateGraph, END 

3692 except ImportError as exc: 

3693 raise ImportError( 

3694 "langgraph is required to build a workflow. " 

3695 "Install it with: pip install langgraph" 

3696 ) from exc 

3697 

3698 steps: list[dict] = (pipeline.get("spec") or {}).get("steps") or [] 

3699 # Validate `allow` step types on the raw graph: call-expansion removes 

3700 # call steps (and their dropped `allow`) before the whitelist validator. 

3701 _validate_allow_step_types(steps) 

3702 # Build-time call-step expansion (ADR-0025 D4): the runtime executes only 

3703 # the flat expanded graph. LangGraph never sees a call step. 

3704 steps = expand_calls(steps, self._registry) 

3705 ordered = _topological_order(steps) 

3706 

3707 channels: list[dict] = _validated_channels( 

3708 (pipeline.get("spec") or {}).get("channels") 

3709 ) 

3710 if channels: 

3711 _validate_channel_names(channels, steps) 

3712 _validate_channel_inputs(channels, steps) 

3713 specs = parse_channel_specs( 

3714 [{"name": ch["name"], "type": ch.get("type", LAST_VALUE)} for ch in channels] 

3715 ) 

3716 channel_types = {spec.name: spec.type for spec in specs} 

3717 channel_names = frozenset(channel_types) 

3718 contracts = _index_data_contracts( 

3719 (pipeline.get("spec") or {}).get("data_contracts") 

3720 ) 

3721 _validate_channel_bindings(contracts, channel_names) 

3722 _validate_channel_read_bindings(contracts, channel_names) 

3723 _validate_step_contract_bindings(steps, contracts, self._resolver) 

3724 _validate_allow_instructions(steps, self._builtins, self._step_functions) 

3725 if self._invoke_agent_fn is not None and hasattr( 

3726 self._invoke_agent_fn, "set_data_contracts" 

3727 ): 

3728 self._invoke_agent_fn.set_data_contracts(contracts) 

3729 

3730 # Validate the pipeline's env map (spec.envs must be a map — ADR-0012). 

3731 envs = (pipeline.get("spec") or {}).get("envs") 

3732 if envs is not None and not isinstance(envs, dict): 

3733 raise ValueError( 

3734 f"Pipeline '{((pipeline.get('metadata') or {}).get('name') or '?')}': " 

3735 "spec.envs must be a map of env name → image ref (ADR-0012)" 

3736 ) 

3737 

3738 # Group steps into LangGraph nodes: 

3739 # Steps with the same depends_on set AND type==parallel → one node (LangGraph-level parallel) 

3740 nodes: list[list[dict]] = [] 

3741 for step in ordered: 

3742 dep_sig = frozenset(step.get("depends_on") or []) 

3743 if nodes: 

3744 last_group_sig = frozenset( 

3745 dep 

3746 for s in nodes[-1] 

3747 for dep in (s.get("depends_on") or []) 

3748 ) 

3749 last_types = {s.get("type") for s in nodes[-1]} 

3750 if ( 

3751 dep_sig == last_group_sig 

3752 and step.get("type") == "parallel" 

3753 and last_types == {"parallel"} 

3754 ): 

3755 nodes[-1].append(step) 

3756 continue 

3757 nodes.append([step]) 

3758 

3759 state_schema = _build_state_schema() if channels else PipelineState 

3760 graph: StateGraph = StateGraph(state_schema) 

3761 step_runner = self._step_runner 

3762 step_runner.set_channel_routing(contracts, channel_names) 

3763 

3764 prev_node_id: str | None = None 

3765 for group in nodes: 

3766 if len(group) == 1: 

3767 node_id = group[0]["id"] 

3768 else: 

3769 node_id = "parallel_" + "_".join(s["id"] for s in group) 

3770 

3771 captured_group = list(group) 

3772 

3773 if len(captured_group) == 1: 

3774 fn = _make_step_node( 

3775 captured_group[0], step_runner, channel_names, contracts, 

3776 channel_types, 

3777 ) 

3778 else: 

3779 fn = _make_parallel_node( 

3780 captured_group, 

3781 step_runner, 

3782 channel_names, 

3783 contracts, 

3784 channel_types, 

3785 ) 

3786 

3787 graph.add_node(node_id, fn) 

3788 

3789 if prev_node_id is None: 

3790 graph.set_entry_point(node_id) 

3791 else: 

3792 graph.add_edge(prev_node_id, node_id) 

3793 

3794 prev_node_id = node_id 

3795 

3796 if prev_node_id: 

3797 graph.add_edge(prev_node_id, END) 

3798 else: 

3799 graph.add_node("noop", lambda s: s) 

3800 graph.set_entry_point("noop") 

3801 graph.add_edge("noop", END) 

3802 

3803 compiled = graph.compile() 

3804 if channel_names: 

3805 return _ChannelGraphShim(compiled, specs) 

3806 return compiled 

3807 

3808 

3809# --------------------------------------------------------------------------- 

3810# Julkinen API 

3811# --------------------------------------------------------------------------- 

3812 

3813_registry_cache: ManifestRegistry | None = None 

3814 

3815 

3816def _get_registry(dirs: list[Path] | None = None) -> ManifestRegistry: 

3817 global _registry_cache 

3818 if _registry_cache is None or dirs is not None: 

3819 loader = ManifestLoader(dirs) 

3820 manifests = loader.load_all() 

3821 _registry_cache = ManifestRegistry(manifests) 

3822 return _registry_cache 

3823 

3824 

3825def build_workflow( 

3826 pipeline_name: str, 

3827 *, 

3828 dirs: list[Path] | None = None, 

3829 builtins: BuiltinExecutor | None = None, 

3830 invoke_agent_fn: Any | None = None, 

3831 run_context: dict | None = None, 

3832 tracer: Any | None = None, 

3833): 

3834 """ 

3835 Lataa Pipeline-manifesti nimeltä *pipeline_name* ja rakentaa 

3836 LangGraph-workflown. 

3837 

3838 Args: 

3839 pipeline_name: Pipeline metadata.name -arvo. 

3840 dirs: Manifestihakemistot (oletus: MANIFEST_DIRS). 

3841 builtins: Oma BuiltinExecutor (oletus: _DEFAULT_BUILTINS). 

3842 invoke_agent_fn: fn(resolved_spec, step, state) → state. 

3843 run_context: Template-kontekstidata, esim. {"budget": {...}, "config": {...}}. 

3844 

3845 Returns: 

3846 Compilattu LangGraph CompiledGraph. 

3847 """ 

3848 registry = _get_registry(dirs) 

3849 pipeline = registry.get("Pipeline", pipeline_name) 

3850 if pipeline is None: 

3851 available = [ 

3852 (m.get("metadata") or {}).get("name") 

3853 for m in registry.all_of_kind("Pipeline") 

3854 ] 

3855 raise KeyError( 

3856 f"Pipeline '{pipeline_name}' not found. Available: {available}" 

3857 ) 

3858 builder = GraphBuilder(registry, builtins, invoke_agent_fn, run_context, tracer=tracer) 

3859 return builder.build(pipeline)