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

1773 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-20 03:43 +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 AGENT_INSTRUCTION_MISSING, 

52 USAGE_INSTRUCTIONS_KEY, 

53 ToolRegistryError, 

54 instruction_text, 

55 resolve_tool_entries, 

56 ) 

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

58 from langgraph._instructions import ( 

59 AGENT_INSTRUCTION_MISSING, 

60 USAGE_INSTRUCTIONS_KEY, 

61 ToolRegistryError, 

62 instruction_text, 

63 resolve_tool_entries, 

64 ) 

65 

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

67# dual-import pattern as _llm. 

68try: 

69 from _data_structure import ( 

70 COMMUNICATION_CONTEXT_KEY, 

71 resolve_communication_context, 

72 ) 

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

74 from langgraph._data_structure import ( 

75 COMMUNICATION_CONTEXT_KEY, 

76 resolve_communication_context, 

77 ) 

78 

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

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

81# native LangGraph channels. 

82try: 

83 from _channels import ( 

84 CORE_KEY, 

85 LAST_VALUE, 

86 ChannelSpec, 

87 ChannelWorkingSet, 

88 parse_channel_specs, 

89 seed_committed, 

90 unwrap, 

91 wrap, 

92 ) 

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

94 from langgraph._channels import ( 

95 CORE_KEY, 

96 LAST_VALUE, 

97 ChannelSpec, 

98 ChannelWorkingSet, 

99 parse_channel_specs, 

100 seed_committed, 

101 unwrap, 

102 wrap, 

103 ) 

104 

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

106try: 

107 from _envelope import ENVELOPE_ERROR_CODE, ENVELOPE_STATUSES, is_error_envelope 

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

109 from langgraph._envelope import ( # noqa: F401 

110 ENVELOPE_ERROR_CODE, 

111 ENVELOPE_STATUSES, 

112 is_error_envelope, 

113 ) 

114 

115log = logging.getLogger(__name__) 

116 

117 

118class RunHardFailure(Exception): 

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

120 

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

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

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

124 ENVELOPE_ERROR_CODE) tai kontekstiylityksen signatuurin kantava 

125 VALIDATION_ERROR (assembled_tokens + context_budget, 

126 _llm._context_overflow_result). 

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

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

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

130 """ 

131 

132 HARD_ERROR_CODES: frozenset[str] = frozenset( 

133 {"TRUNCATION_ERROR", "TOOL_FAILURE", ENVELOPE_ERROR_CODE} 

134 ) 

135 

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

137 super().__init__(message) 

138 self.code = code 

139 self.message = message 

140 self.details = details 

141 

142 

143class SpawnBudget: 

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

145 

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

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

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

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

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

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

152 the run's `max_llm_calls`, not by 

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

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

155 """ 

156 

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

158 self._remaining: int | None = ( 

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

160 ) 

161 self._lock = threading.Lock() 

162 

163 def consume(self) -> bool: 

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

165 with self._lock: 

166 if self._remaining is None: 

167 return True 

168 if self._remaining <= 0: 

169 return False 

170 self._remaining -= 1 

171 return True 

172 

173 

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

175 

176_SPAWN_DEFAULT_MAX_AGENT_CALLS: int = 64 

177_SPAWN_DEFAULT_MAX_DEPTH: int = 1 

178_SPAWN_DEFAULT_MAX_SPAWN_PER_NODE: int = 16 

179 

180# --------------------------------------------------------------------------- 

181# Polut 

182# --------------------------------------------------------------------------- 

183 

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

185MANIFEST_DIRS = [ 

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

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

188] 

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

190 

191 

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

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

194 

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

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

197 """ 

198 return [ 

199 d for d in ( 

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

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

202 ) 

203 if d.is_dir() 

204 ] 

205 

206# --------------------------------------------------------------------------- 

207# Template-resoluutio 

208# --------------------------------------------------------------------------- 

209 

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

211 

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

213_UNRESOLVED = object() 

214 

215 

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

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

218 

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

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

221 """ 

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

223 base = m.group(1) 

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

225 return base, indices 

226 

227 

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

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

230 for idx in indices: 

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

232 return _UNRESOLVED 

233 obj = obj[idx] 

234 return obj 

235 

236 

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

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

239 

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

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

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

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

244 """ 

245 base, indices = _split_key_indices(part) 

246 if isinstance(obj, dict): 

247 if base: 

248 if base not in obj: 

249 return _UNRESOLVED 

250 obj = obj[base] 

251 return _apply_indices(obj, indices) 

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

253 if indices: 

254 return _apply_indices(obj, indices) 

255 if base and base.isdigit(): 

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

257 return _UNRESOLVED 

258 return _UNRESOLVED 

259 

260 

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

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

263 

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

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

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

267 """ 

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

269 obj = _dot_get_segment(obj, part) 

270 if obj is _UNRESOLVED: 

271 return None 

272 return obj 

273 

274 

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

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

277 

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

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

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

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

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

283 """ 

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

285 if obj is None: 

286 raise ValueError( 

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

288 ) 

289 obj = _dot_get_segment(obj, part) 

290 if obj is _UNRESOLVED: 

291 raise ValueError( 

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

293 ) 

294 return obj 

295 

296 

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

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

299 

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

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

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

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

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

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

306 """ 

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

308 if not out_spec: 

309 return None 

310 source = out_spec.get("source") 

311 if not source: 

312 return None 

313 return _dot_get(final_state, source) 

314 

315 

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

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

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

319 merged = dict(base or {}) 

320 merged.update(additions) 

321 return merged 

322 

323 

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

325_SINGLE_OPS = '+-<>' 

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

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

328 

329 

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

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

332 

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

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

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

336 """ 

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

338 i, n = 0, len(expr) 

339 while i < n: 

340 ch = expr[i] 

341 if ch.isspace(): 

342 i += 1 

343 continue 

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

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

346 i += 1 

347 continue 

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

349 two = expr[i:i + 2] 

350 if two in _MULTI_OPS: 

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

352 i += 2 

353 continue 

354 if ch in _SINGLE_OPS: 

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

356 i += 1 

357 continue 

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

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

360 j = i + 1 

361 buf: list[str] = [] 

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

363 buf.append(expr[j]) 

364 j += 1 

365 if j >= n: 

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

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

368 i = j + 1 

369 continue 

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

371 j = i 

372 if ch == '.': 

373 j += 1 

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

375 j += 1 

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

377 else: 

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

379 j += 1 

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

381 j += 1 

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

383 j += 1 

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

385 else: 

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

387 i = j 

388 continue 

389 j = i 

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

391 j += 1 

392 if j == i: 

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

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

395 i = j 

396 return tokens 

397 

398 

399# AST node shapes: 

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

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

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

403# ('neg', node) unary minus 

404# ('not', node) not X 

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

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

407 

408 

409class _ExprParser: 

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

411 

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

413 Grammar: 

414 expr := or_expr 

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

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

417 not_expr := 'not' not_expr | cmp_expr 

418 cmp_expr := add_expr (cmp_op add_expr)? 

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

420 unary_expr:= '-' unary_expr | primary 

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

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

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

424 unresolved read there never raises. 

425 """ 

426 

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

428 self._tokens = tokens 

429 self._i = 0 

430 

431 def parse(self) -> tuple: 

432 node = self._or_expr() 

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

434 tok = self._tokens[self._i] 

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

436 return node 

437 

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

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

440 

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

442 tok = self._tokens[self._i] 

443 self._i += 1 

444 return tok 

445 

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

447 tok = self._peek() 

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

449 

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

451 tok = self._peek() 

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

453 

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

455 tok = self._next() 

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

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

458 

459 def _or_expr(self) -> tuple: 

460 node = self._and_expr() 

461 while self._peek_name('or'): 

462 self._next() 

463 right = self._and_expr() 

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

465 return node 

466 

467 def _and_expr(self) -> tuple: 

468 node = self._not_expr() 

469 while self._peek_name('and'): 

470 self._next() 

471 right = self._not_expr() 

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

473 return node 

474 

475 def _not_expr(self) -> tuple: 

476 if self._peek_name('not'): 

477 self._next() 

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

479 return self._cmp_expr() 

480 

481 def _cmp_expr(self) -> tuple: 

482 node = self._add_expr() 

483 tok = self._peek() 

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

485 self._next() 

486 right = self._add_expr() 

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

488 return node 

489 

490 def _add_expr(self) -> tuple: 

491 node = self._unary_expr() 

492 while self._peek_op('+'): 

493 self._next() 

494 right = self._unary_expr() 

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

496 return node 

497 

498 def _unary_expr(self) -> tuple: 

499 if self._peek_op('-'): 

500 self._next() 

501 node = self._unary_expr() 

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

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

504 return ('neg', node) 

505 return self._primary() 

506 

507 def _primary(self) -> tuple: 

508 tok = self._peek() 

509 if tok is None: 

510 raise ValueError("Unexpected end of expression") 

511 kind, value = tok 

512 if kind == 'num': 

513 self._next() 

514 return ('lit', value) 

515 if kind == 'str': 

516 self._next() 

517 return ('lit', value) 

518 if kind == 'punct': 

519 if value == '(': 

520 self._next() 

521 node = self._or_expr() 

522 self._expect_punct(')') 

523 return node 

524 if value == '[': 

525 self._next() 

526 items: list[tuple] = [] 

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

528 while True: 

529 items.append(self._or_expr()) 

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

531 self._next() 

532 continue 

533 break 

534 self._expect_punct(']') 

535 return ('list', items) 

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

537 if kind == 'name': 

538 self._next() 

539 lowered = value.lower() 

540 if lowered == 'true': 

541 return ('lit', True) 

542 if lowered == 'false': 

543 return ('lit', False) 

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

545 return ('lit', None) 

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

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

548 return ('ref', value) 

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

550 

551 

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

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

554 

555 

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

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

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

559 return left + right 

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

561 return left + right 

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

563 return left + right 

564 raise ValueError( 

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

566 ) 

567 

568 

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

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

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

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

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

574 return node[1] 

575 return _eval_ast(node, ctx, strict) 

576 

577 

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

579 left = _eval_ast(left_node, ctx, strict) 

580 right = _eval_comparison_rhs(right_node, ctx, strict) 

581 try: 

582 if op == '==': 

583 return left == right 

584 if op == '!=': 

585 return left != right 

586 if op == '<=': 

587 return left <= right 

588 if op == '>=': 

589 return left >= right 

590 if op == '<': 

591 return left < right 

592 if op == '>': 

593 return left > right 

594 except TypeError: 

595 return False 

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

597 

598 

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

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

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

602 kind = node[0] 

603 if kind == 'lit': 

604 return node[1] 

605 if kind == 'ref': 

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

607 if kind == 'list': 

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

609 if kind == 'neg': 

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

611 if kind == 'not': 

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

613 if kind == 'cmp': 

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

615 if kind == 'binop': 

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

617 if op == 'and': 

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

619 return False 

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

621 if op == 'or': 

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

623 return True 

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

625 if op == '+': 

626 left = _eval_ast(left_node, ctx, strict) 

627 right = _eval_ast(right_node, ctx, strict) 

628 return _add_values(left, right) 

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

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

631 

632 

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

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

635 

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

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

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

639 side already determines the result. 

640 """ 

641 expr = expr.strip() 

642 tokens = _tokenize_expr(expr) 

643 if not tokens: 

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

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

646 

647 

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

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

650 if isinstance(value, str): 

651 stripped = value.strip() 

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

653 m = _TMPL_RE.fullmatch(stripped) 

654 if m: 

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

656 # Inline substitution → always returns string 

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

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

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

660 return _TMPL_RE.sub(_replace, value) 

661 if isinstance(value, dict): 

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

663 if isinstance(value, list): 

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

665 return value 

666 

667 

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

669 """ 

670 Resolve step input spec to a concrete value: 

671 "{{ item }}" → template 

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

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

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

675 None → None 

676 """ 

677 if input_spec is None: 

678 return None 

679 if isinstance(input_spec, str): 

680 if '{{' in input_spec: 

681 return _resolve_template(input_spec, ctx) 

682 stripped = input_spec.lstrip() 

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

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

685 # builtin verbatim so it resolves the placeholder itself. 

686 if stripped.startswith('['): 

687 return input_spec 

688 # Dot-path into state (no braces) 

689 return _dot_get(state, stripped) 

690 if isinstance(input_spec, dict): 

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

692 if isinstance(input_spec, list): 

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

694 return input_spec 

695 

696 

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

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

699 

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

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

702 raises, never silently evaluates to falsy. 

703 """ 

704 if cond is None: 

705 return True 

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

707 if isinstance(resolved, bool): 

708 return resolved 

709 if isinstance(resolved, str): 

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

711 return bool(resolved) 

712 

713 

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

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

716 try: 

717 return int(v) 

718 except (TypeError, ValueError): 

719 return default 

720 

721 

722# --------------------------------------------------------------------------- 

723# ManifestLoader 

724# --------------------------------------------------------------------------- 

725 

726 

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

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

729 try: 

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

731 except OSError as exc: 

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

733 return [] 

734 

735 results: list[dict] = [] 

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

737 raw_clean = "\n".join( 

738 line for line in raw.splitlines() 

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

740 ) 

741 try: 

742 doc = yaml.safe_load(raw_clean) 

743 except yaml.YAMLError as exc: 

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

745 continue 

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

747 results.append(doc) 

748 return results 

749 

750 

751class ManifestLoader: 

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

753 

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

755 self._dirs = dirs or MANIFEST_DIRS 

756 

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

758 manifests: list[dict] = [] 

759 for d in self._dirs: 

760 if not d.exists(): 

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

762 continue 

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

764 docs = _parse_manifest_file(path) 

765 manifests.extend(docs) 

766 if docs: 

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

768 return manifests 

769 

770 

771# --------------------------------------------------------------------------- 

772# ManifestRegistry 

773# --------------------------------------------------------------------------- 

774 

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

776 

777 

778class ManifestRegistry: 

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

780 

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

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

783 for doc in manifests: 

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

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

786 if kind and name: 

787 key: ResourceKey = (kind, name) 

788 if key in self._store: 

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

790 self._store[key] = doc 

791 

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

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

794 

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

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

797 

798 

799# --------------------------------------------------------------------------- 

800# AgentResolver 

801# --------------------------------------------------------------------------- 

802 

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

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

805 result = dict(base) 

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

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

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

809 else: 

810 result[k] = v 

811 return result 

812 

813 

814class AgentResolver: 

815 """ 

816 Palauttaa fully-resolved agent spec:n instanssille. 

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

818 """ 

819 

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

821 self._registry = registry 

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

823 

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

825 if instance_name in self._cache: 

826 return self._cache[instance_name] 

827 

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

829 if instance is None: 

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

831 if agent is None: 

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

833 resolved = agent 

834 else: 

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

836 if based_on: 

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

838 if base_agent is None: 

839 raise KeyError( 

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

841 ) 

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

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

844 if k != "based_on"} 

845 merged_spec = _deep_merge(base_spec, inst_spec) 

846 else: 

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

848 if k != "based_on"} 

849 

850 resolved = { 

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

852 "kind": "Agent", 

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

854 "spec": merged_spec, 

855 } 

856 

857 self._cache[instance_name] = resolved 

858 return resolved 

859 

860 

861# --------------------------------------------------------------------------- 

862# State type 

863# --------------------------------------------------------------------------- 

864 

865PipelineState = dict[str, Any] 

866 

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

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

869_CORE = CORE_KEY 

870 

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

872_CORE_STATE_KEYS = frozenset({ 

873 "errors", "stats", "run_status", "project_root", "run_id", 

874 "_stats", "step_context_metrics", 

875}) 

876 

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

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

879# never shadow them. 

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

881 

882 

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

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

885 

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

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

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

889 bounded, filename-safe length. 

890 """ 

891 for key in _ENGINE_LOCAL_ITERATION_KEYS: 

892 if key in state: 

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

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

895 return None 

896 

897 

898 

899def _build_state_schema() -> Any: 

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

901 

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

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

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

905 """ 

906 from typing import TypedDict 

907 

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

909 

910 

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

912_UNSET: Any = object() 

913 

914# --------------------------------------------------------------------------- 

915# BuiltinExecutor 

916# --------------------------------------------------------------------------- 

917 

918class BuiltinExecutor: 

919 """ 

920 Ajaa builtin-komponentit. 

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

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

923 """ 

924 

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

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

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

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

929 

930 def __init__(self) -> None: 

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

932 "passthrough": self._passthrough, 

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

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

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

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

937 "__call_bind": self._call_bind, 

938 "__call_emit": self._call_emit, 

939 } 

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

941 # GraphBuilder. Profile builtins that make LLM decisions 

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

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

944 # executes (binding happens at build_workflow time). 

945 self.invoke_agent_fn: Any | None = None 

946 self._lazy_loaded = False 

947 

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

949 self._handlers[name] = fn 

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

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

952 if normalised != name: 

953 self._handlers[normalised] = fn 

954 

955 def _load_lazy_modules(self) -> None: 

956 if self._lazy_loaded: 

957 return 

958 self._lazy_loaded = True 

959 import importlib 

960 for module_name in self._LAZY_MODULES: 

961 try: 

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

963 except ImportError: 

964 continue 

965 mod.register_all(self) 

966 

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

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

969 self._load_lazy_modules() 

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

971 

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

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

974 if fn is not None: 

975 return fn 

976 self._load_lazy_modules() 

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

978 if fn is None: 

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

980 return fn 

981 

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

983 fn = self.get(component) 

984 return fn(step, state) 

985 

986 # --- default builtins --- 

987 

988 @staticmethod 

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

990 return state 

991 

992 @staticmethod 

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

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

995 

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

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

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

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

1000 """ 

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

1002 if not call_id: 

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

1004 bound = { 

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

1006 } 

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

1008 

1009 @staticmethod 

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

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

1012 

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

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

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

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

1017 

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

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

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

1021 """ 

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

1023 call_id = params.get("__call_id") 

1024 if not call_id: 

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

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

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

1028 for spec in outputs_spec: 

1029 if not isinstance(spec, dict): 

1030 continue 

1031 name = spec.get("name") 

1032 source = spec.get("source") 

1033 if name is None: 

1034 continue 

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

1036 if source is None: 

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

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

1039 if optional: 

1040 continue 

1041 raise RunHardFailure( 

1042 "UNRESOLVED_OUTPUT", 

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

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

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

1046 ) 

1047 try: 

1048 value = _dot_get_strict(state, source) 

1049 except ValueError: 

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

1051 if optional: 

1052 continue 

1053 raise RunHardFailure( 

1054 "UNRESOLVED_OUTPUT", 

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

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

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

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

1059 ) from None 

1060 outputs[name] = value 

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

1062 

1063 

1064_DEFAULT_BUILTINS = BuiltinExecutor() 

1065 

1066 

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

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

1069 

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

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

1072 """ 

1073 try: 

1074 from langgraph import _builtins # type: ignore 

1075 except ImportError: 

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

1077 import importlib.util 

1078 spec = importlib.util.spec_from_file_location( 

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

1080 ) 

1081 if spec and spec.loader: 

1082 _builtins = importlib.util.module_from_spec(spec) 

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

1084 else: 

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

1086 return 

1087 _builtins.register_all(executor) 

1088 _register_function_builtins(executor) 

1089 

1090 

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

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

1093 

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

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

1096 """ 

1097 try: 

1098 from langgraph import _builtins_functions # type: ignore 

1099 except ImportError: 

1100 import importlib.util 

1101 spec = importlib.util.spec_from_file_location( 

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

1103 ) 

1104 if spec and spec.loader: 

1105 _builtins_functions = importlib.util.module_from_spec(spec) 

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

1107 else: 

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

1109 return 

1110 _builtins_functions.register_all(executor) 

1111 

1112 

1113_register_domain_builtins(_DEFAULT_BUILTINS) 

1114 

1115# --------------------------------------------------------------------------- 

1116# StepRunner 

1117# --------------------------------------------------------------------------- 

1118 

1119class StepRunner: 

1120 """ 

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

1122 

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

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

1125 """ 

1126 

1127 def __init__( 

1128 self, 

1129 resolver: AgentResolver, 

1130 builtins: BuiltinExecutor | None = None, 

1131 invoke_agent_fn: Any | None = None, 

1132 run_context: dict | None = None, 

1133 *, 

1134 step_functions: StepFunctionExecutor | None = None, 

1135 tracer: Any | None = None, 

1136 ) -> None: 

1137 self._resolver = resolver 

1138 self._builtins = builtins or _DEFAULT_BUILTINS 

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

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

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

1142 self._invoke_agent = self._wrap_calls_gate( 

1143 self._wrap_hard_failure_gate( 

1144 invoke_agent_fn or self._dummy_invoke 

1145 ) 

1146 ) 

1147 self._run_context: dict = run_context or {} 

1148 self._step_functions = step_functions 

1149 self._tracer = tracer 

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

1151 

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

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

1154 self._channel_names = channel_names 

1155 

1156 @staticmethod 

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

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

1159 

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

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

1162 truthy expression skips the LLM. 

1163 """ 

1164 raw = step.get("skip_llm_if") 

1165 if raw is None: 

1166 return False 

1167 resolved = _resolve_template(raw, ctx) 

1168 if isinstance(resolved, bool): 

1169 return resolved 

1170 if isinstance(resolved, str): 

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

1172 return bool(resolved) 

1173 

1174 @staticmethod 

1175 def _skip_llm_passthrough( 

1176 step: dict, 

1177 state: PipelineState, 

1178 ctx: dict, 

1179 read_state: PipelineState, 

1180 ) -> PipelineState: 

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

1182 

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

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

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

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

1187 """ 

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

1189 input_spec = step.get("input") 

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

1191 if isinstance(resolved, list): 

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

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

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

1195 return {**state, step_id: resolved} 

1196 

1197 @staticmethod 

1198 def _parallel_fanout_items( 

1199 step: dict, state: PipelineState, ctx: dict 

1200 ) -> list | None: 

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

1202 

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

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

1205 single agent output. 

1206 """ 

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

1208 return None 

1209 input_spec = step.get("input") 

1210 if input_spec is None: 

1211 return None 

1212 input_val = _resolve_input(input_spec, state, ctx) 

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

1214 

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

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

1217 

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

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

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

1221 are excluded from inverse projection. 

1222 """ 

1223 if self.resolve_agent(step) is None: 

1224 return False 

1225 ctx = self._make_ctx(state) 

1226 if self._should_skip_llm(step, ctx): 

1227 return False 

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

1229 

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

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

1232 ctx: dict = {} 

1233 ctx.update(state) 

1234 ctx.update(self._run_context) 

1235 ctx.update(extras) 

1236 return ctx 

1237 

1238 @staticmethod 

1239 def _read_state( 

1240 state: PipelineState, working_set: ChannelWorkingSet | None 

1241 ) -> PipelineState: 

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

1243 

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

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

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

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

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

1249 declared channel with the same name cannot shadow them. 

1250 """ 

1251 if working_set is None: 

1252 return state 

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

1254 for key in _ENGINE_LOCAL_ITERATION_KEYS: 

1255 if key in state: 

1256 resolved[key] = state[key] 

1257 return resolved 

1258 

1259 def _run_body_step( 

1260 self, 

1261 step: dict, 

1262 state: PipelineState, 

1263 working_set: ChannelWorkingSet | None, 

1264 ) -> PipelineState: 

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

1266 

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

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

1269 """ 

1270 if working_set is None: 

1271 return self.run_step(step, state) 

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

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

1274 return result 

1275 

1276 def _stage_body_channel_writes( 

1277 self, 

1278 step: dict, 

1279 pre_state: PipelineState, 

1280 result_state: PipelineState, 

1281 working_set: ChannelWorkingSet, 

1282 ) -> None: 

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

1284 

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

1286 a step carrying an agent routes its declared writes. 

1287 """ 

1288 if not self._channel_names: 

1289 return 

1290 writes = _route_declared_channels( 

1291 step, 

1292 self._read_state(pre_state, working_set), 

1293 result_state, 

1294 self, 

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_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 step's declared input channels — never whole state. A step 

1344 without a read declaration is returned unchanged. 

1345 """ 

1346 communication = resolve_communication_context(resolved_step, read_state) 

1347 if not communication: 

1348 return resolved_step 

1349 return {**resolved_step, COMMUNICATION_CONTEXT_KEY: communication} 

1350 

1351 def _attach_usage_instructions( 

1352 self, resolved_step: dict, resolved_agent: dict 

1353 ) -> dict: 

1354 """Attach the resolved usage-instruction entries for the agent's `tools`. 

1355 

1356 The calling agent's declared `tools` are resolved through the tool 

1357 registry into native-function-calling ready entries and carried to the 

1358 invoker (ADR-0036 D1 element 5). An agent without tools is returned 

1359 unchanged. A step declares topology only; its `allow` whitelist is not 

1360 read here. 

1361 """ 

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

1363 if not tools: 

1364 return resolved_step 

1365 entries = self._resolve_agent_tool_entries(resolved_agent, tools) 

1366 return {**resolved_step, USAGE_INSTRUCTIONS_KEY: entries} 

1367 

1368 def _resolve_agent_tool_entries( 

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

1370 ) -> list[dict]: 

1371 """Resolve an agent's `tools` into registry entries, failing fast. 

1372 

1373 An unknown name or a deterministic step without instruction text is 

1374 translated from the registry's ``ToolRegistryError`` into the engine's 

1375 build-time hard failure, reusing ``AGENT_INSTRUCTION_MISSING``. 

1376 """ 

1377 try: 

1378 return resolve_tool_entries( 

1379 tools, self._builtins, self._step_functions 

1380 ) 

1381 except ToolRegistryError as exc: 

1382 agent_name = (resolved_agent.get("metadata") or {}).get("name", "?") 

1383 raise RunHardFailure( 

1384 AGENT_INSTRUCTION_MISSING, 

1385 f"Agent '{agent_name}' declares tool '{exc.name}' with no " 

1386 "usage instruction: it is neither a file-access tool, a " 

1387 "whitelistable builtin, nor a StepFunction with " 

1388 "agent_instructions", 

1389 {"agent": agent_name, "tool": exc.name}, 

1390 ) from exc 

1391 

1392 @staticmethod 

1393 def _dummy_invoke( 

1394 resolved_spec: dict, step: dict, state: PipelineState 

1395 ) -> PipelineState: 

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

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

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

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

1400 

1401 @staticmethod 

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

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

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

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

1406 if not additions: 

1407 return base 

1408 merged = list(base) 

1409 for err in additions: 

1410 if err not in merged: 

1411 merged.append(err) 

1412 return merged 

1413 

1414 @staticmethod 

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

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

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

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

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

1420 def invoke_with_gate( 

1421 resolved_spec: dict, step: dict, state: PipelineState 

1422 ) -> PipelineState: 

1423 result = invoke_fn(resolved_spec, step, state) 

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

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

1426 return result 

1427 return invoke_with_gate 

1428 

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

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

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

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

1433 def invoke_with_calls( 

1434 resolved_spec: dict, step: dict, state: PipelineState 

1435 ) -> PipelineState: 

1436 result = invoke_fn(resolved_spec, step, state) 

1437 self._execute_requested_calls(step, result, state) 

1438 return result 

1439 return invoke_with_calls 

1440 

1441 def _execute_requested_calls( 

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

1443 ) -> None: 

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

1445 

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

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

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

1449 """ 

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

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

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

1453 if not calls: 

1454 return 

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

1456 for index, call in enumerate(calls): 

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

1458 

1459 def _execute_requested_call( 

1460 self, 

1461 step_id: str, 

1462 allow: list, 

1463 index: int, 

1464 call: Any, 

1465 state: PipelineState, 

1466 ) -> None: 

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

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

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

1470 raise RunHardFailure( 

1471 "CALL_NOT_ALLOWED", 

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

1473 "is not in its allow whitelist", 

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

1475 ) 

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

1477 self._validate_call_params(name, params, step_id) 

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

1479 try: 

1480 self._dispatch_call_step(name, call_step, state) 

1481 except RunHardFailure as exc: 

1482 if exc.code == "BUILTIN_NAME_COLLISION": 

1483 raise 

1484 raise RunHardFailure( 

1485 "CALL_FAILED", 

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

1487 { 

1488 "step_id": step_id, 

1489 "requested_step": name, 

1490 "error_code": exc.code, 

1491 }, 

1492 ) from exc 

1493 except Exception as exc: # noqa: BLE001 

1494 raise RunHardFailure( 

1495 "CALL_FAILED", 

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

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

1498 ) from exc 

1499 

1500 @staticmethod 

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

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

1503 params = call.get("params") 

1504 if params is None: 

1505 return {} 

1506 if not isinstance(params, dict): 

1507 raise RunHardFailure( 

1508 "CALL_INVALID_PARAMS", 

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

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

1511 ) 

1512 return params 

1513 

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

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

1516 

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

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

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

1520 koskee vain calls-polkua. 

1521 """ 

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

1523 return 

1524 declared = self._step_functions.params_for(name) 

1525 if declared is None: 

1526 return 

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

1528 missing = [ 

1529 entry["name"] 

1530 for entry in declared 

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

1532 ] 

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

1534 if not missing and not unknown: 

1535 return 

1536 raise RunHardFailure( 

1537 "CALL_INVALID_PARAMS", 

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

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

1540 { 

1541 "step_id": step_id, 

1542 "requested_step": name, 

1543 "missing": missing, 

1544 "unknown": unknown, 

1545 }, 

1546 ) 

1547 

1548 @staticmethod 

1549 def _synthesize_call_step( 

1550 parent_step_id: str, 

1551 index: int, 

1552 name: str, 

1553 params: dict, 

1554 state: PipelineState, 

1555 ) -> dict: 

1556 """Rakentaa deterministisen synteettisen stepin yhdelle kutsulle. 

1557 

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

1559 discriminator is included when present so concurrent foreach/parallel 

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

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

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

1563 """ 

1564 discriminator = _iteration_discriminator(state) 

1565 token = uuid.uuid4().hex 

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

1567 return { 

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

1569 "type": "builtin", 

1570 "component": name, 

1571 "params": params, 

1572 } 

1573 

1574 def _dispatch_call_step( 

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

1576 ) -> None: 

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

1578 

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

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

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

1582 """ 

1583 is_builtin = self._builtins.has(name) 

1584 is_step_function = ( 

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

1586 ) 

1587 if is_builtin and is_step_function: 

1588 raise RunHardFailure( 

1589 "BUILTIN_NAME_COLLISION", 

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

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

1592 {"component": name}, 

1593 ) 

1594 if is_step_function: 

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

1596 return 

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

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

1599 

1600 @staticmethod 

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

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

1603 (TRUNCATION_ERROR, TOOL_FAILURE, AGENT_ERROR) tai VALIDATION_ERROR 

1604 kantaa kontekstiylityksen signatuurin (assembled_tokens + 

1605 context_budget — _llm._context_overflow_result). 

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

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

1608 code = details.get("error_code") 

1609 if code in RunHardFailure.HARD_ERROR_CODES: 

1610 return True 

1611 if code == "VALIDATION_ERROR": 

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

1613 return False 

1614 

1615 @staticmethod 

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

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

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

1619 kulkeutuu poikkeuksen mukana.""" 

1620 if not isinstance(step_result, dict): 

1621 return 

1622 if is_error_envelope(step_result): 

1623 StepRunner._raise_on_error_envelope(step_result, step_id) 

1624 return 

1625 details = step_result.get("details") 

1626 if not isinstance(details, dict): 

1627 return 

1628 if not StepRunner._is_hard_fail(details): 

1629 return 

1630 code = details.get("error_code") 

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

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

1633 raise RunHardFailure(code, message, details) 

1634 

1635 @staticmethod 

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

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

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

1639 envelope_error = step_result.get("error") 

1640 if isinstance(envelope_error, dict): 

1641 details = envelope_error 

1642 message = envelope_error.get("message") 

1643 else: 

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

1645 message = None 

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

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

1648 raise RunHardFailure(ENVELOPE_ERROR_CODE, message, details) 

1649 

1650 @staticmethod 

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

1652 """ 

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

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

1655 epäonnistuneeksi eikä hiljennä virhettä. 

1656 

1657 Kaikki virhetyypit propagoidaan: 

1658 - budget:max_llm_calls / budget:run_timeout → budjettiloki 

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

1660 """ 

1661 step_result = result.get(step_id) 

1662 if not isinstance(step_result, dict): 

1663 return result 

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

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

1666 return result 

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

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

1669 if message not in errors: 

1670 errors.append(message) 

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

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

1673 else: 

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

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

1676 

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

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

1679 agent_name = step.get("agent") 

1680 if not agent_name: 

1681 return None 

1682 return self._resolver.resolve(agent_name) 

1683 

1684 def run_step( 

1685 self, 

1686 step: dict, 

1687 state: PipelineState, 

1688 working_set: ChannelWorkingSet | None = None, 

1689 ) -> PipelineState: 

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

1691 read_state = self._read_state(state, working_set) 

1692 ctx = self._make_ctx(read_state) 

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

1694 

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

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

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

1698 cond = step.get("condition") 

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

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

1701 return state 

1702 

1703 if step_type == "builtin": 

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

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

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

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

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

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

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

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

1712 is_builtin = self._builtins.has(component) 

1713 is_step_function = ( 

1714 self._step_functions is not None 

1715 and self._step_functions.has(component) 

1716 ) 

1717 if is_builtin and is_step_function: 

1718 raise RunHardFailure( 

1719 "BUILTIN_NAME_COLLISION", 

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

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

1722 "fail-fast)", 

1723 {"component": component}, 

1724 ) 

1725 if is_step_function: 

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

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

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

1729 return builtin_state 

1730 

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

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

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

1734 if ( 

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

1736 and self._should_skip_llm(step, ctx) 

1737 ): 

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

1739 

1740 if step_type == "parallel": 

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

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

1743 if items is not None: 

1744 return self._run_parallel_fanout( 

1745 step, state, items, ctx, working_set 

1746 ) 

1747 # Fallback: single agent call 

1748 agent_name = step.get("agent") 

1749 if not agent_name: 

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

1751 return state 

1752 resolved = self._resolver.resolve(agent_name) 

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

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

1755 resolved_step = self._attach_communication_context( 

1756 resolved_step, read_state 

1757 ) 

1758 resolved_step = self._attach_usage_instructions(resolved_step, resolved) 

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

1760 return self._propagate_step_errors(result, step_id) 

1761 

1762 if step_type == "sequential": 

1763 agent_name = step.get("agent") 

1764 if not agent_name: 

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

1766 return state 

1767 resolved = self._resolver.resolve(agent_name) 

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

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

1770 resolved_step = self._attach_communication_context( 

1771 resolved_step, read_state 

1772 ) 

1773 resolved_step = self._attach_usage_instructions(resolved_step, resolved) 

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

1775 return self._propagate_step_errors(result, step_id) 

1776 

1777 if step_type == "foreach": 

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

1779 

1780 if step_type == "while": 

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

1782 

1783 if step_type == "call": 

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

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

1786 raise ValueError( 

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

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

1789 ) 

1790 

1791 if step_type == "spawn": 

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

1793 

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

1795 

1796 def _run_parallel_fanout( 

1797 self, 

1798 step: dict, 

1799 state: PipelineState, 

1800 items: list, 

1801 ctx: dict, 

1802 working_set: ChannelWorkingSet | None = None, 

1803 ) -> PipelineState: 

1804 """ 

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

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

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

1808 """ 

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

1810 agent_name = step.get("agent") 

1811 if not agent_name: 

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

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

1814 

1815 resolved = self._resolver.resolve(agent_name) 

1816 

1817 # Apply max_items cap 

1818 max_items_raw = step.get("max_items") 

1819 if max_items_raw is not None: 

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

1821 items = items[:max_items] 

1822 

1823 # Concurrency limit 

1824 max_conc_raw = step.get("max_concurrent") 

1825 if max_conc_raw is not None: 

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

1827 else: 

1828 max_conc = len(items) or 1 

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

1830 

1831 log.debug( 

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

1833 step_id, len(items), max_conc, 

1834 ) 

1835 

1836 read_state = self._read_state(state, working_set) 

1837 

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

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

1840 item_ctx = self._make_ctx(item_state) 

1841 resolved_step = self._resolve_step_runtime( 

1842 step, item_state, item_ctx, 

1843 input_override=item, 

1844 ) 

1845 resolved_step = self._attach_communication_context( 

1846 resolved_step, item_state 

1847 ) 

1848 resolved_step = self._attach_usage_instructions(resolved_step, resolved) 

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

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

1851 

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

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

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

1855 item_metrics: dict = {} 

1856 with ThreadPoolExecutor(max_workers=max_conc) as pool: 

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

1858 for future in as_completed(futures): 

1859 idx = futures[future] 

1860 try: 

1861 item_result, item_metrics_map = future.result() 

1862 results[idx] = item_result 

1863 if isinstance(item_metrics_map, dict): 

1864 item_metrics.update(item_metrics_map) 

1865 except SkillLoadError: 

1866 raise 

1867 except RunHardFailure: 

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

1869 raise 

1870 except Exception as exc: # noqa: BLE001 

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

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

1873 

1874 self._stage_fanout_channel_writes(step, results, working_set) 

1875 

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

1877 if item_metrics: 

1878 final = { 

1879 **final, 

1880 "step_context_metrics": _merge_metrics( 

1881 state.get("step_context_metrics"), item_metrics 

1882 ), 

1883 } 

1884 return final 

1885 

1886 def _stage_fanout_channel_writes( 

1887 self, 

1888 step: dict, 

1889 item_outputs: list, 

1890 working_set: ChannelWorkingSet | None, 

1891 ) -> None: 

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

1893 

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

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

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

1897 """ 

1898 if working_set is None or not self._channel_names: 

1899 return 

1900 for output in item_outputs: 

1901 writes = _declared_output_channel_writes( 

1902 step, output, self._channel_names 

1903 ) 

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

1905 working_set.stage(name, value) 

1906 

1907 def _run_foreach( 

1908 self, 

1909 step: dict, 

1910 state: PipelineState, 

1911 working_set: ChannelWorkingSet | None = None, 

1912 ) -> PipelineState: 

1913 """ 

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

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

1916 

1917 When max_concurrent > 1, body iterations run concurrently via 

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

1919 max_concurrent value sets the thread pool size (mirrors parallel 

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

1921 

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

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

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

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

1926 sequential early-exit guarantee cannot be reproduced without sacrificing 

1927 true concurrency. 

1928 

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

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

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

1932 """ 

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

1934 read_state = self._read_state(state, working_set) 

1935 ctx = self._make_ctx(read_state) 

1936 

1937 # Determine items 

1938 input_spec = step.get("input") 

1939 over_key = step.get("over") 

1940 

1941 if input_spec is not None: 

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

1943 if not isinstance(items, list): 

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

1945 elif over_key: 

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

1947 if not isinstance(items, list): 

1948 items = [items] 

1949 else: 

1950 items = [None] 

1951 

1952 # max_iterations cap 

1953 max_iter_raw = step.get("max_iterations") 

1954 if max_iter_raw is not None: 

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

1956 items = items[:max_iter] 

1957 

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

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

1960 max_output_raw = step.get("max_output") 

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

1962 

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

1964 max_conc_raw = step.get("max_concurrent") 

1965 if max_conc_raw is not None: 

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

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

1968 else: 

1969 max_conc = 1 

1970 

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

1972 agent_name = step.get("agent") 

1973 

1974 if max_conc <= 1: 

1975 return self._run_foreach_sequential( 

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

1977 working_set, 

1978 ) 

1979 return self._run_foreach_concurrent( 

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

1981 working_set, 

1982 ) 

1983 

1984 @staticmethod 

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

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

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

1988 if declared_output: 

1989 return iter_state.get(declared_output) 

1990 return iter_state.get("publish") 

1991 

1992 @staticmethod 

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

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

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

1996 if isinstance(item, dict): 

1997 verdict = item.get("verdict") 

1998 if verdict == "FAIL": 

1999 return False 

2000 if verdict == "PASS": 

2001 return True 

2002 passed = item.get("passed") 

2003 if isinstance(passed, bool): 

2004 return passed 

2005 return True 

2006 

2007 @staticmethod 

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

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

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

2011 return { 

2012 "results": item_results, 

2013 "passed": not failing, 

2014 "failing": failing, 

2015 "failed": bool(failing), 

2016 } 

2017 

2018 def _run_foreach_sequential( 

2019 self, 

2020 step_id: str, 

2021 step: dict, 

2022 state: PipelineState, 

2023 items: list, 

2024 body: list, 

2025 agent_name: str | None, 

2026 max_output: int, 

2027 working_set: ChannelWorkingSet | None = None, 

2028 ) -> PipelineState: 

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

2030 body_polku = bool(body) 

2031 declared_output = step.get("output") 

2032 results: list = [] 

2033 produced_outputs = 0 

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

2035 accumulated_metrics: dict = {} 

2036 

2037 for item in items: 

2038 if max_output and produced_outputs >= max_output: 

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

2040 break 

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

2042 

2043 if body_polku: 

2044 for sub_step in body: 

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

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

2047 accumulated_errors = self._merge_errors( 

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

2049 ) 

2050 publish_out = iter_state.get("publish") 

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

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

2053 produced_outputs += 1 

2054 elif agent_name: 

2055 resolved = self._resolver.resolve(agent_name) 

2056 iter_read_state = self._read_state(iter_state, working_set) 

2057 iter_ctx = self._make_ctx(iter_read_state) 

2058 resolved_step = self._resolve_step_runtime( 

2059 step, iter_read_state, iter_ctx 

2060 ) 

2061 resolved_step = self._attach_communication_context( 

2062 resolved_step, iter_read_state 

2063 ) 

2064 resolved_step = self._attach_usage_instructions(resolved_step, resolved) 

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

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

2067 item_metrics_map = iter_state.get("step_context_metrics") 

2068 if isinstance(item_metrics_map, dict): 

2069 accumulated_metrics.update(item_metrics_map) 

2070 

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

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

2073 if accumulated_metrics: 

2074 final = { 

2075 **final, 

2076 "step_context_metrics": _merge_metrics( 

2077 state.get("step_context_metrics"), accumulated_metrics 

2078 ), 

2079 } 

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

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

2082 return final 

2083 

2084 @staticmethod 

2085 def _merge_item_working_sets( 

2086 working_set: ChannelWorkingSet | None, 

2087 item_working_sets: list[ChannelWorkingSet | None], 

2088 ) -> None: 

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

2090 if working_set is None: 

2091 return 

2092 for item_working_set in item_working_sets: 

2093 if item_working_set is None: 

2094 continue 

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

2096 working_set.stage(name, value) 

2097 

2098 def _run_foreach_concurrent( 

2099 self, 

2100 step_id: str, 

2101 step: dict, 

2102 state: PipelineState, 

2103 items: list, 

2104 body: list, 

2105 agent_name: str | None, 

2106 max_output: int, 

2107 max_conc: int, 

2108 working_set: ChannelWorkingSet | None = None, 

2109 ) -> PipelineState: 

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

2111 log.info( 

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

2113 step_id, len(items), max_conc, 

2114 ) 

2115 

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

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

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

2119 body_polku = bool(body) 

2120 declared_output = step.get("output") 

2121 _SKIPPED = object() 

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

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

2124 lock = threading.Lock() 

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

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

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

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

2129 

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

2131 nonlocal item_errors 

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

2133 if max_output and stop_flag.is_set(): 

2134 return 

2135 

2136 item_working_set = ( 

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

2138 ) 

2139 item_working_sets[idx] = item_working_set 

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

2141 

2142 if body_polku: 

2143 for sub_step in body: 

2144 iter_state = self._run_body_step( 

2145 sub_step, iter_state, item_working_set 

2146 ) 

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

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

2149 if new_errs: 

2150 with lock: 

2151 item_errors = self._merge_errors(item_errors, new_errs) 

2152 publish_out = iter_state.get("publish") 

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

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

2155 with lock: 

2156 produced_outputs[0] += 1 

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

2158 stop_flag.set() 

2159 elif agent_name: 

2160 resolved = self._resolver.resolve(agent_name) 

2161 item_read_state = self._read_state(iter_state, item_working_set) 

2162 item_ctx = self._make_ctx(item_read_state) 

2163 resolved_step = self._resolve_step_runtime( 

2164 step, item_read_state, item_ctx 

2165 ) 

2166 resolved_step = self._attach_communication_context( 

2167 resolved_step, item_read_state 

2168 ) 

2169 resolved_step = self._attach_usage_instructions(resolved_step, resolved) 

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

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

2172 item_metrics_map = iter_state.get("step_context_metrics") 

2173 if isinstance(item_metrics_map, dict): 

2174 with lock: 

2175 accumulated_metrics.update(item_metrics_map) 

2176 

2177 with ThreadPoolExecutor(max_workers=max_conc) as pool: 

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

2179 for future in as_completed(futures): 

2180 try: 

2181 future.result() 

2182 except SkillLoadError: 

2183 raise 

2184 except RunHardFailure: 

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

2186 raise 

2187 except Exception as exc: # noqa: BLE001 

2188 idx = futures[future] 

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

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

2191 

2192 self._merge_item_working_sets(working_set, item_working_sets) 

2193 

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

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

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

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

2198 if accumulated_metrics: 

2199 final = { 

2200 **final, 

2201 "step_context_metrics": _merge_metrics( 

2202 state.get("step_context_metrics"), accumulated_metrics 

2203 ), 

2204 } 

2205 if item_errors: 

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

2207 return final 

2208 

2209 def _run_while( 

2210 self, 

2211 step: dict, 

2212 state: PipelineState, 

2213 working_set: ChannelWorkingSet | None = None, 

2214 ) -> PipelineState: 

2215 """ 

2216 While loop with exit_condition and optional entry condition. 

2217 

2218 Fields: 

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

2220 input: initial value for loop.current 

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

2222 max_iterations: int or template 

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

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

2225 body: list of sub-steps 

2226 

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

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

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

2230 

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

2232 condition evaluation so reads resolve over the committed snapshot plus 

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

2234 from the raw ``state``. 

2235 """ 

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

2237 read_state = self._read_state(state, working_set) 

2238 ctx = self._make_ctx(read_state) 

2239 

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

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

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

2243 entry_cond = step.get("condition") 

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

2245 input_spec = step.get("input") 

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

2247 return {**state, step_id: { 

2248 "passed": True, 

2249 "iterations": 0, 

2250 "output": skip_input, 

2251 "exhausted": False, 

2252 "skipped": True, 

2253 }} 

2254 

2255 # Initial current value from input 

2256 input_spec = step.get("input") 

2257 if input_spec is not None: 

2258 current = _resolve_input(input_spec, read_state, ctx) 

2259 else: 

2260 current = None 

2261 

2262 exit_condition = step.get("exit_condition") 

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

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

2265 declared_output = step.get("output") 

2266 

2267 passed = False 

2268 iteration = 0 

2269 loop_state: PipelineState = dict(state) 

2270 

2271 while iteration < max_iter: 

2272 # Inject loop context 

2273 loop_state = { 

2274 **state, 

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

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

2277 } 

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

2279 if "item" in state: 

2280 loop_state["item"] = state["item"] 

2281 

2282 # Run body steps 

2283 for sub_step in body: 

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

2285 

2286 iteration += 1 

2287 

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

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

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

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

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

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

2294 if declared_output: 

2295 feed_value = loop_state.get(declared_output) 

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

2297 current = feed_value["output"] 

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

2299 current = feed_value["results"] 

2300 else: 

2301 current = feed_value 

2302 

2303 # Check exit condition 

2304 if exit_condition: 

2305 exit_ctx = self._make_ctx( 

2306 self._read_state(loop_state, working_set) 

2307 ) 

2308 if _eval_condition(exit_condition, exit_ctx): 

2309 passed = True 

2310 break 

2311 else: 

2312 passed = True 

2313 break 

2314 

2315 skipped = False 

2316 exhausted = not passed 

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

2318 "passed": passed, 

2319 "iterations": iteration, 

2320 "output": current, 

2321 "exhausted": exhausted, 

2322 "skipped": skipped, 

2323 }} 

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

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

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

2327 # skipped or passed while must never raise. 

2328 fail_if_exhausted = step.get("fail_if_exhausted") 

2329 if ( 

2330 fail_if_exhausted is not None 

2331 and exhausted 

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

2333 ): 

2334 raise RunHardFailure( 

2335 "EXHAUSTED", 

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

2337 "without exit_condition passing (fail_if_exhausted)", 

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

2339 ) 

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

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

2342 if loop_errors: 

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

2344 return final 

2345 

2346 def _run_spawn( 

2347 self, 

2348 step: dict, 

2349 state: PipelineState, 

2350 working_set: ChannelWorkingSet | None = None, 

2351 ) -> PipelineState: 

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

2353 

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

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

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

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

2358 """ 

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

2360 read_state = self._read_state(state, working_set) 

2361 ctx = self._make_ctx(read_state) 

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

2363 

2364 max_depth_raw = policy.get("max_depth") 

2365 if max_depth_raw is None: 

2366 max_depth = _SPAWN_DEFAULT_MAX_DEPTH 

2367 else: 

2368 max_depth = _resolve_int(max_depth_raw, ctx, _SPAWN_DEFAULT_MAX_DEPTH) 

2369 

2370 max_spawn_raw = policy.get("max_spawn_per_node") 

2371 if max_spawn_raw is None: 

2372 max_spawn_per_node = _SPAWN_DEFAULT_MAX_SPAWN_PER_NODE 

2373 else: 

2374 max_spawn_per_node = _resolve_int(max_spawn_raw, ctx, _SPAWN_DEFAULT_MAX_SPAWN_PER_NODE) 

2375 

2376 max_calls_raw = policy.get("max_agent_calls") 

2377 if max_calls_raw is None: 

2378 budget = SpawnBudget(_SPAWN_DEFAULT_MAX_AGENT_CALLS) 

2379 else: 

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

2381 

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

2383 max_concurrent_raw = policy.get("max_concurrent_children") 

2384 if max_concurrent_raw is None: 

2385 max_concurrent_children = run_max_parallel 

2386 else: 

2387 max_concurrent_children = _resolve_int(max_concurrent_raw, ctx, run_max_parallel) 

2388 

2389 director = step.get("agent") 

2390 if not director: 

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

2392 return state 

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

2394 

2395 envelope, metrics = self._spawn_node( 

2396 node_id=step_id, 

2397 role=director, 

2398 depth=0, 

2399 step=step, 

2400 input_override=_UNSET, 

2401 base_state=read_state, 

2402 base_ctx=ctx, 

2403 child_role=child_role, 

2404 budget=budget, 

2405 max_depth=max_depth, 

2406 max_spawn_per_node=max_spawn_per_node, 

2407 max_concurrent_children=max_concurrent_children, 

2408 working_set=working_set, 

2409 ) 

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

2411 if metrics: 

2412 final = { 

2413 **final, 

2414 "step_context_metrics": _merge_metrics( 

2415 state.get("step_context_metrics"), metrics 

2416 ), 

2417 } 

2418 return final 

2419 

2420 def _spawn_node( 

2421 self, 

2422 *, 

2423 node_id: str, 

2424 role: str, 

2425 depth: int, 

2426 step: dict, 

2427 input_override: Any, 

2428 base_state: PipelineState, 

2429 base_ctx: dict, 

2430 child_role: str, 

2431 budget: SpawnBudget, 

2432 max_depth: int, 

2433 max_spawn_per_node: int | None, 

2434 max_concurrent_children: int, 

2435 working_set: ChannelWorkingSet | None, 

2436 ) -> tuple[dict, dict]: 

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

2438 tracer = self._tracer 

2439 parent_obs = ( 

2440 tracer.open_parent_observation( 

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

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

2443 ) 

2444 if tracer 

2445 else None 

2446 ) 

2447 try: 

2448 resolved_role = self._resolver.resolve(role) 

2449 resolved_step = self._resolve_step_runtime( 

2450 step, base_state, base_ctx, input_override=input_override, 

2451 ) 

2452 resolved_step = self._attach_communication_context( 

2453 resolved_step, base_state 

2454 ) 

2455 resolved_step = self._attach_usage_instructions( 

2456 resolved_step, resolved_role 

2457 ) 

2458 if not budget.consume(): 

2459 raise RunHardFailure( 

2460 "SPAWN_BUDGET_EXHAUSTED", 

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

2462 {"node_id": node_id, "depth": depth}, 

2463 ) 

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

2465 this_result = result_state.get(node_id) 

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

2467 self._stage_spawn_channel_writes( 

2468 step, this_result, working_set 

2469 ) 

2470 

2471 children = self._spawn_children_from_result(this_result) 

2472 envelope: dict = { 

2473 "result": this_result, 

2474 "children": {}, 

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

2476 "failing": [], 

2477 "exhausted": False, 

2478 } 

2479 

2480 if not children: 

2481 return envelope, metrics 

2482 if depth + 1 > max_depth: 

2483 envelope["exhausted"] = True 

2484 return envelope, metrics 

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

2486 raise RunHardFailure( 

2487 "SPAWN_OVER_FLIGHT", 

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

2489 f"exceeds max_spawn_per_node={max_spawn_per_node}", 

2490 { 

2491 "node_id": node_id, 

2492 "depth": depth, 

2493 "children": len(children), 

2494 "max_spawn_per_node": max_spawn_per_node, 

2495 }, 

2496 ) 

2497 

2498 pool_size = self._spawn_pool_size(max_spawn_per_node, max_concurrent_children) 

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

2500 for idx, child in enumerate(children): 

2501 child_id, child_envelope, child_metrics = self._spawn_child( 

2502 node_id=node_id, 

2503 child_role=child_role, 

2504 depth=depth, 

2505 step=step, 

2506 base_state=base_state, 

2507 base_ctx=base_ctx, 

2508 budget=budget, 

2509 max_depth=max_depth, 

2510 max_spawn_per_node=max_spawn_per_node, 

2511 max_concurrent_children=max_concurrent_children, 

2512 working_set=working_set, 

2513 child=child, 

2514 idx=idx, 

2515 ) 

2516 metrics = _merge_metrics(metrics, child_metrics) 

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

2518 if not child_envelope["passed"]: 

2519 envelope["passed"] = False 

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

2521 return envelope, metrics 

2522 

2523 pool = ThreadPoolExecutor(max_workers=pool_size) 

2524 cancel = threading.Event() 

2525 futures = [] 

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

2527 for idx, child in enumerate(children): 

2528 child_working_set = ( 

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

2530 ) 

2531 ctx = contextvars.copy_context() 

2532 future = pool.submit( 

2533 ctx.run, 

2534 self._spawn_child, 

2535 node_id=node_id, 

2536 child_role=child_role, 

2537 depth=depth, 

2538 step=step, 

2539 base_state=base_state, 

2540 base_ctx=base_ctx, 

2541 budget=budget, 

2542 max_depth=max_depth, 

2543 max_spawn_per_node=max_spawn_per_node, 

2544 max_concurrent_children=max_concurrent_children, 

2545 working_set=child_working_set, 

2546 child=child, 

2547 idx=idx, 

2548 ) 

2549 futures.append(future) 

2550 if child_working_set is not None: 

2551 child_working_sets[future] = child_working_set 

2552 hard_error: Exception | None = None 

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

2554 try: 

2555 for fut in as_completed(futures): 

2556 try: 

2557 child_results[fut] = fut.result() 

2558 except (RunHardFailure, SkillLoadError) as exc: 

2559 hard_error = exc 

2560 cancel.set() 

2561 for pending in futures: 

2562 pending.cancel() 

2563 break 

2564 if hard_error is None: 

2565 for fut in futures: 

2566 child_id, child_envelope, child_metrics = child_results[fut] 

2567 self._merge_spawn_fork_writes( 

2568 working_set, child_working_sets.get(fut) 

2569 ) 

2570 metrics = _merge_metrics(metrics, child_metrics) 

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

2572 if not child_envelope["passed"]: 

2573 envelope["passed"] = False 

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

2575 finally: 

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

2577 if hard_error is not None: 

2578 raise hard_error 

2579 

2580 return envelope, metrics 

2581 finally: 

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

2583 tracer.close_parent_observation(parent_obs) 

2584 

2585 def _stage_spawn_channel_writes( 

2586 self, 

2587 step: dict, 

2588 result: Any, 

2589 working_set: ChannelWorkingSet | None, 

2590 ) -> None: 

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

2592 if working_set is None: 

2593 return 

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

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

2596 working_set.stage(name, value) 

2597 

2598 @staticmethod 

2599 def _merge_spawn_fork_writes( 

2600 working_set: ChannelWorkingSet | None, 

2601 fork: ChannelWorkingSet | None, 

2602 ) -> None: 

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

2604 

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

2606 working set is never mutated from a worker thread. 

2607 """ 

2608 if working_set is None or fork is None: 

2609 return 

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

2611 working_set.stage(name, value) 

2612 

2613 @staticmethod 

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

2615 if max_spawn_per_node is None: 

2616 return max(1, max_concurrent_children) 

2617 return max(1, min(max_spawn_per_node, max_concurrent_children)) 

2618 

2619 def _spawn_child( 

2620 self, 

2621 *, 

2622 node_id: str, 

2623 child_role: str, 

2624 depth: int, 

2625 step: dict, 

2626 base_state: PipelineState, 

2627 base_ctx: dict, 

2628 budget: SpawnBudget, 

2629 max_depth: int, 

2630 max_spawn_per_node: int | None, 

2631 max_concurrent_children: int, 

2632 working_set: ChannelWorkingSet | None, 

2633 child: Any, 

2634 idx: int, 

2635 ) -> tuple[str, dict, dict]: 

2636 if not self._spawn_has_input(child): 

2637 raise RunHardFailure( 

2638 "INVALID_CHILD_PLAN", 

2639 f"spawn child #{idx} of node '{node_id}' must be a dict " 

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

2641 {"node_id": node_id, "child_index": idx, "child": repr(child)[:200]}, 

2642 ) 

2643 child_id = child["id"] if child.get("id") else f"{node_id}.{idx}" 

2644 child_input = child["input"] 

2645 resolved_child_input = _resolve_input(child_input, base_state, base_ctx) 

2646 child_envelope, child_metrics = self._spawn_node( 

2647 node_id=child_id, 

2648 role=child_role, 

2649 depth=depth + 1, 

2650 step={"id": child_id, "input": resolved_child_input}, 

2651 input_override=resolved_child_input, 

2652 base_state=base_state, 

2653 base_ctx=base_ctx, 

2654 child_role=child_role, 

2655 budget=budget, 

2656 max_depth=max_depth, 

2657 max_spawn_per_node=max_spawn_per_node, 

2658 max_concurrent_children=max_concurrent_children, 

2659 working_set=working_set, 

2660 ) 

2661 return child_id, child_envelope, child_metrics 

2662 

2663 @staticmethod 

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

2665 """The node's child plan: [] when absent or empty; raises on a malformed plan.""" 

2666 if not isinstance(result, dict): 

2667 return [] 

2668 raw = result.get("children") 

2669 if raw is None: 

2670 return [] 

2671 if not isinstance(raw, list): 

2672 raise RunHardFailure( 

2673 "INVALID_CHILD_PLAN", 

2674 "spawn agent 'children' must be a list", 

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

2676 ) 

2677 return raw 

2678 

2679 @staticmethod 

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

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

2682 if not isinstance(child, dict): 

2683 return False 

2684 input_val = child.get("input") 

2685 if input_val is None: 

2686 return False 

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

2688 return False 

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

2690 return False 

2691 return True 

2692 

2693 @staticmethod 

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

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

2696 

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

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

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

2700 """ 

2701 if not isinstance(result, dict): 

2702 return False 

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

2704 return False 

2705 if _SPAWN_SOFT_FAIL_KEYS & result.keys(): 

2706 return True 

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

2708 

2709 

2710# --------------------------------------------------------------------------- 

2711# GraphBuilder 

2712# --------------------------------------------------------------------------- 

2713 

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

2715 """ 

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

2717 """ 

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

2719 visited: set[str] = set() 

2720 order: list[dict] = [] 

2721 

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

2723 if step_id in visited: 

2724 return 

2725 visited.add(step_id) 

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

2727 if dep in id_to_step: 

2728 visit(dep) 

2729 order.append(id_to_step[step_id]) 

2730 

2731 for s in steps: 

2732 visit(s["id"]) 

2733 return order 

2734 

2735 

2736# --------------------------------------------------------------------------- 

2737# Call-step build-time expansion (ADR-0025 D3/D4) 

2738# --------------------------------------------------------------------------- 

2739# 

2740# A `type: call` step is expanded at build time into the function's inlined body 

2741# steps plus two synthesized op steps (`__call_bind` / `__call_emit`). The 

2742# runtime executes only this flat graph; LangGraph never sees a call step. The 

2743# caller reads the function outputs via the call result shape 

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

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

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

2747# 

2748# Isolation (ADR-0025 D5 / 00-functions § Isolation) and recursion (D7/D9): a 

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

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

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

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

2753 

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

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

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

2757 

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

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

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

2761 """ 

2762 if isinstance(value, str): 

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

2764 if isinstance(value, dict): 

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

2766 if isinstance(value, list): 

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

2768 return value 

2769 

2770 

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

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

2773 scoped: dict = dict(step) 

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

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

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

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

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

2779 return scoped 

2780 

2781 

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

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

2784 ids: set[str] = set() 

2785 for step in steps: 

2786 step_id = step.get("id") 

2787 if isinstance(step_id, str): 

2788 ids.add(step_id) 

2789 body = step.get("body") 

2790 if isinstance(body, list): 

2791 ids |= _collect_step_ids(body) 

2792 return ids 

2793 

2794 

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

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

2797 for step in steps: 

2798 yield step 

2799 body = step.get("body") 

2800 if isinstance(body, list): 

2801 yield from _iter_steps(body) 

2802 

2803 

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

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

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

2807 

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

2809 

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

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

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

2813 

2814 

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

2816 """Root identifiers read by templated strings. 

2817 

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

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

2820 literals are ignored. 

2821 """ 

2822 names: set[str] = set() 

2823 if isinstance(value, str): 

2824 for expr in _TEMPLATE_REF_RE.findall(value): 

2825 bare = _STRING_LITERAL_RE.sub("", expr) 

2826 for match in _IDENTIFIER_RE.finditer(bare): 

2827 if match.start() == 0 or bare[match.start() - 1] != ".": 

2828 names.add(match.group()) 

2829 elif isinstance(value, dict): 

2830 for v in value.values(): 

2831 names |= _referenced_names(v) 

2832 elif isinstance(value, list): 

2833 for v in value: 

2834 names |= _referenced_names(v) 

2835 return names 

2836 

2837 

2838def _validate_function_purity( 

2839 fn_name: str, body: list[dict], visible_ids: set[str] 

2840) -> None: 

2841 """One-way isolation: a function body must not reference a caller step-id. 

2842 

2843 A body step may read its own body step-ids and the params namespace; a 

2844 reference to a step-id that exists outside the body (``visible_ids`` but 

2845 not declared in the body) is an outward dependency and a hard build error. 

2846 """ 

2847 local_ids = _collect_step_ids(body) 

2848 for step in _iter_steps(body): 

2849 step_id = step.get("id", "?") 

2850 for dep in step.get("depends_on") or []: 

2851 if dep in visible_ids and dep not in local_ids: 

2852 raise ValueError( 

2853 f"function '{fn_name}' step '{step_id}': depends_on " 

2854 f"'{dep}' is outside the function body (isolation — " 

2855 "function bodies see only their own steps and params)" 

2856 ) 

2857 for field in _REFERENCE_FIELDS: 

2858 for ref in _referenced_names(step.get(field)): 

2859 if ref in _RESERVED_NAMESPACES: 

2860 continue 

2861 if ref in visible_ids and ref not in local_ids: 

2862 raise ValueError( 

2863 f"function '{fn_name}' step '{step_id}': '{field}' " 

2864 f"references '{ref}', a step-id outside the function " 

2865 "body (isolation — function bodies see only their own " 

2866 "steps and params)" 

2867 ) 

2868 

2869 

2870def _expand_call( 

2871 step: dict, 

2872 registry: ManifestRegistry, 

2873 stack: list[str], 

2874 call_map: dict[str, str], 

2875 expanded_functions: dict[str, str], 

2876 visible_ids: set[str], 

2877) -> list[dict]: 

2878 """Expand a call step into bind + inlined body + emit steps.""" 

2879 fn_name = step["function"] 

2880 call_id = step["id"] 

2881 

2882 if fn_name in stack: 

2883 chain = " -> ".join(stack + [fn_name]) 

2884 raise ValueError( 

2885 f"cycle detected in function expansion: {chain} " 

2886 "(ADR-0025 D7/D9 — recursion must be a bounded while loop or idempotent re-run)" 

2887 ) 

2888 

2889 # ADR-0025 D7: inlining the same function twice in one pipeline build 

2890 # collides its body step-ids (flat graph). This is a hard build error until 

2891 # id-prefixing lands; naming the function and both call sites pins the bug. 

2892 if fn_name in expanded_functions: 

2893 raise ValueError( 

2894 f"duplicate call expansion: function '{fn_name}' called at both " 

2895 f"'{expanded_functions[fn_name]}' and '{call_id}' in one pipeline — " 

2896 "inlined body step ids collide (ADR-0025 D7)" 

2897 ) 

2898 expanded_functions[fn_name] = call_id 

2899 

2900 function = registry.get("Function", fn_name) 

2901 if function is None: 

2902 raise ValueError( 

2903 f"call step '{call_id}' references unknown function '{fn_name}' " 

2904 "(no kind: Function manifest in the workflows tree)" 

2905 ) 

2906 

2907 body = (function.get("spec") or {}).get("steps") or [] 

2908 _validate_function_purity(fn_name, body, visible_ids) 

2909 

2910 sub_stack = list(stack) + [fn_name] 

2911 nested_visible = visible_ids | _collect_step_ids(body) 

2912 outputs_spec = ((function.get("spec") or {}).get("interface") or {}).get("outputs") or [] 

2913 

2914 bind_id = f"_call__{call_id}__bind" 

2915 emit_id = f"_call__{call_id}__emit" 

2916 call_map[call_id] = emit_id 

2917 

2918 bind_step: dict = { 

2919 "id": bind_id, 

2920 "type": "builtin", 

2921 "component": "__call_bind", 

2922 "params": {**(step.get("params") or {}), "__call_id": call_id}, 

2923 "depends_on": list(step.get("depends_on") or []), 

2924 } 

2925 

2926 expanded_body: list[dict] = [] 

2927 for body_step in body: 

2928 scoped = _scope_params(body_step, call_id) 

2929 for sub in _expand_step( 

2930 scoped, registry, sub_stack, call_map, expanded_functions, nested_visible 

2931 ): 

2932 dep = list(sub.get("depends_on") or []) 

2933 if bind_id not in dep: 

2934 sub["depends_on"] = [bind_id, *dep] 

2935 expanded_body.append(sub) 

2936 

2937 emit_step: dict = { 

2938 "id": emit_id, 

2939 "type": "builtin", 

2940 "component": "__call_emit", 

2941 "params": {"__call_id": call_id, "__outputs": outputs_spec}, 

2942 "depends_on": [bind_id] + [s["id"] for s in expanded_body], 

2943 } 

2944 

2945 return [bind_step, *expanded_body, emit_step] 

2946 

2947 

2948def _expand_step( 

2949 step: dict, 

2950 registry: ManifestRegistry, 

2951 stack: list[str], 

2952 call_map: dict[str, str], 

2953 expanded_functions: dict[str, str], 

2954 visible_ids: set[str], 

2955) -> list[dict]: 

2956 """Expand one step (recursing into while/foreach bodies).""" 

2957 if step.get("type") == "call": 

2958 return _expand_call( 

2959 step, registry, stack, call_map, expanded_functions, visible_ids 

2960 ) 

2961 if "body" in step and isinstance(step["body"], list): 

2962 expanded_body: list[dict] = [] 

2963 for sub in step["body"]: 

2964 expanded_body.extend( 

2965 _expand_step( 

2966 sub, registry, stack, call_map, expanded_functions, visible_ids 

2967 ) 

2968 ) 

2969 return [{**step, "body": expanded_body}] 

2970 return [step] 

2971 

2972 

2973def _rewrite_call_deps(steps: list[dict], call_map: dict[str, str]) -> None: 

2974 """Rewrite `depends_on` references to call ids → the corresponding emit id. 

2975 

2976 After a call is expanded away, a downstream step that depends_on the call id 

2977 must instead wait on the emit step (which writes state[<call_id>]). This 

2978 guarantees the caller's `{{ <call_id>.outputs.<name> }}` reads resolve after 

2979 the function body ran. 

2980 """ 

2981 for step in steps: 

2982 deps = step.get("depends_on") or [] 

2983 if deps: 

2984 step["depends_on"] = [call_map.get(d, d) for d in deps] 

2985 if "body" in step and isinstance(step["body"], list): 

2986 _rewrite_call_deps(step["body"], call_map) 

2987 

2988 

2989def expand_calls(steps: list[dict], registry: ManifestRegistry) -> list[dict]: 

2990 """Expand all `call` steps into the flat runtime graph (ADR-0025 D4). 

2991 

2992 Returns a new step list with each call replaced by its bind + inlined body + 

2993 emit steps; a call step never survives to the runtime graph. Cycles and 

2994 outward references in a function body are hard errors. 

2995 """ 

2996 call_map: dict[str, str] = {} 

2997 expanded_functions: dict[str, str] = {} 

2998 visible_ids = _collect_step_ids(steps) 

2999 expanded: list[dict] = [] 

3000 for step in steps: 

3001 expanded.extend( 

3002 _expand_step(step, registry, [], call_map, expanded_functions, visible_ids) 

3003 ) 

3004 _rewrite_call_deps(expanded, call_map) 

3005 return expanded 

3006 

3007 

3008_CHANNEL_TYPES = frozenset({"LastValue", "Topic", "BinaryOperatorAggregate"}) 

3009 

3010 

3011def _validate_channel_entry(index: int, channel: Any) -> None: 

3012 """Validate one spec.channels entry against the frozen ChannelSpec shape. 

3013 

3014 The engine build path does not run manifest-schema validation, so a 

3015 malformed entry is rejected here — before any ``["name"]`` indexing — with 

3016 an error that identifies the offending entry. 

3017 """ 

3018 if not isinstance(channel, dict): 

3019 raise ValueError( 

3020 f"Pipeline spec.channels[{index}] must be a mapping; got " 

3021 f"{type(channel).__name__}" 

3022 ) 

3023 name = channel.get("name") 

3024 if not isinstance(name, str) or not name: 

3025 raise ValueError( 

3026 f"Pipeline spec.channels[{index}] is missing a valid 'name'; each " 

3027 "channel requires a non-empty string 'name'" 

3028 ) 

3029 if "type" in channel and channel["type"] not in _CHANNEL_TYPES: 

3030 raise ValueError( 

3031 f"Pipeline spec.channels[{index}] ('{name}') has invalid type " 

3032 f"'{channel['type']}'; expected one of {sorted(_CHANNEL_TYPES)}" 

3033 ) 

3034 

3035 

3036def _validated_channels(raw_channels: Any) -> list[dict]: 

3037 """Return spec.channels as a validated list, or [] when absent. 

3038 

3039 ``None`` (absent) and an empty list are both valid no-channel pipelines. 

3040 """ 

3041 if raw_channels is None: 

3042 return [] 

3043 if not isinstance(raw_channels, list): 

3044 raise ValueError( 

3045 f"Pipeline spec.channels must be a list; got " 

3046 f"{type(raw_channels).__name__}" 

3047 ) 

3048 for index, channel in enumerate(raw_channels): 

3049 _validate_channel_entry(index, channel) 

3050 return raw_channels 

3051 

3052 

3053def _validate_step_channel_bindings( 

3054 steps: list[dict], channel_names: frozenset[str] 

3055) -> None: 

3056 """Reject malformed or undeclared step-level channel bindings at build time. 

3057 

3058 Every step's declared ``output_channel`` must be a non-empty declared 

3059 channel name, and every declared ``input_channels`` entry must be a 

3060 non-empty declared channel name. This runs unconditionally, so a binding 

3061 to a channel absent from ``spec.channels`` fails the build even when no 

3062 channels are declared. 

3063 """ 

3064 for step in _iter_steps(steps): 

3065 _validate_step_output_channel(step, channel_names) 

3066 _validate_step_input_channels(step, channel_names) 

3067 

3068 

3069def _validate_step_output_channel(step: dict, channel_names: frozenset[str]) -> None: 

3070 channel = step.get("output_channel") 

3071 if channel is None: 

3072 return 

3073 if not isinstance(channel, str) or not channel: 

3074 raise RunHardFailure( 

3075 "CHANNEL_BINDING_ERROR", 

3076 f"Step '{step.get('id', '?')}' output_channel must be a non-empty " 

3077 f"string; got {channel!r}", 

3078 ) 

3079 if channel not in channel_names: 

3080 raise RunHardFailure( 

3081 "CHANNEL_BINDING_ERROR", 

3082 f"Step '{step.get('id', '?')}' declares output_channel '{channel}' " 

3083 "which is not declared in spec.channels", 

3084 ) 

3085 

3086 

3087def _validate_step_input_channels(step: dict, channel_names: frozenset[str]) -> None: 

3088 channels = step.get("input_channels") 

3089 if channels is None: 

3090 return 

3091 if not isinstance(channels, list): 

3092 raise RunHardFailure( 

3093 "CHANNEL_BINDING_ERROR", 

3094 f"Step '{step.get('id', '?')}' input_channels must be a list of " 

3095 f"channel names; got {type(channels).__name__}", 

3096 ) 

3097 for channel in channels: 

3098 if not isinstance(channel, str) or not channel: 

3099 raise RunHardFailure( 

3100 "CHANNEL_BINDING_ERROR", 

3101 f"Step '{step.get('id', '?')}' input_channels has an invalid " 

3102 f"channel name '{channel}'; channel names must be non-empty " 

3103 "strings", 

3104 ) 

3105 if channel not in channel_names: 

3106 raise RunHardFailure( 

3107 "CHANNEL_BINDING_ERROR", 

3108 f"Step '{step.get('id', '?')}' reads undeclared channel " 

3109 f"'{channel}'; the channel must be declared in spec.channels", 

3110 ) 

3111 

3112 

3113def _validate_channel_names(channels: list[dict], steps: list[dict]) -> None: 

3114 """Reject duplicate and engine/step-reserved channel names.""" 

3115 forbidden = ( 

3116 {_CORE} 

3117 | set(_RESERVED_NAMESPACES) 

3118 | _CORE_STATE_KEYS 

3119 | _collect_step_ids(steps) 

3120 ) 

3121 seen: set[str] = set() 

3122 for channel in channels: 

3123 name = channel.get("name") 

3124 if name in seen: 

3125 raise ValueError( 

3126 f"Pipeline declares channel '{name}' more than once; channel " 

3127 "names must be unique" 

3128 ) 

3129 seen.add(name) 

3130 if name in forbidden: 

3131 raise ValueError( 

3132 f"Pipeline channel '{name}' collides with an engine-reserved " 

3133 "name (a step id, engine state key, reserved namespace, or the " 

3134 "core state channel); channel names must be unique and " 

3135 "non-reserved" 

3136 ) 

3137 

3138 

3139def _input_path_root(value: str) -> str | None: 

3140 """Leading state key of a bare dot-path input string, or None. 

3141 

3142 Template strings, bracket-prefixed verbatim literals, and blank strings 

3143 are not state reads and yield None. 

3144 """ 

3145 if "{{" in value: 

3146 return None 

3147 stripped = value.lstrip() 

3148 if not stripped or stripped.startswith("["): 

3149 return None 

3150 return re.split(r"[.\[]", stripped, maxsplit=1)[0] 

3151 

3152 

3153def _iter_input_roots(input_spec: Any): 

3154 """Yield every bare dot-path root in an input spec, recursing into containers.""" 

3155 if isinstance(input_spec, str): 

3156 root = _input_path_root(input_spec) 

3157 if root: 

3158 yield root 

3159 elif isinstance(input_spec, dict): 

3160 for value in input_spec.values(): 

3161 yield from _iter_input_roots(value) 

3162 elif isinstance(input_spec, list): 

3163 for value in input_spec: 

3164 yield from _iter_input_roots(value) 

3165 

3166 

3167def _validate_channel_inputs(channels: list[dict], steps: list[dict]) -> None: 

3168 """Reject channel-pipeline input roots that name nothing the state exposes. 

3169 

3170 The channel state is the declared channels plus the engine state, so a bare 

3171 dot-path input root must be a declared channel, a known step id, a reserved 

3172 namespace, or an engine state key. Anything else is a hard build error — 

3173 there is no silent fallback to a missing key. 

3174 """ 

3175 allowed = ( 

3176 {ch["name"] for ch in channels} 

3177 | _collect_step_ids(steps) 

3178 | set(_RESERVED_NAMESPACES) 

3179 | _CORE_STATE_KEYS 

3180 ) 

3181 for step in _iter_steps(steps): 

3182 for root in _iter_input_roots(step.get("input")): 

3183 if root not in allowed: 

3184 raise ValueError( 

3185 f"Pipeline channel step '{step.get('id', '?')}' input " 

3186 f"references unknown root '{root}'; in a channel pipeline " 

3187 "an input root must be a declared channel, a step id, a " 

3188 "reserved namespace, or an engine state key" 

3189 ) 

3190 

3191 

3192def _resolve_allow_instruction( 

3193 entry: str, builtins: BuiltinExecutor, step_functions: StepFunctionExecutor | None 

3194) -> str | None: 

3195 """Resolve one whitelist entry's usage instruction, builtin-first. 

3196 

3197 Mirrors the runtime dispatch order (ADR-0037 D2): a name in the builtin 

3198 register resolves to the Core-owned registry text, otherwise to the 

3199 StepFunction's ``agent_instructions``. 

3200 """ 

3201 return instruction_text(entry, builtins, step_functions) 

3202 

3203 

3204_AGENT_INVOKING_STEP_TYPES = frozenset({"sequential", "parallel", "spawn", "foreach"}) 

3205 

3206 

3207def _validate_allow_step_types(steps: list[dict]) -> None: 

3208 """Reject ``allow`` on a step that does not actually invoke an agent. 

3209 

3210 Only an agent-bearing step consumes its whitelist; on any other step the 

3211 declaration would be silently dropped — notably a ``call`` step, which 

3212 build-time expansion removes before the whitelist validator runs, and an 

3213 agent-less ``sequential``/``spawn``/``foreach``. A container step that 

3214 declares a ``body`` runs its body path, so a ``body`` wins over a declared 

3215 ``agent`` and the whitelist would be inert. Runs on the raw step list, 

3216 before call expansion. 

3217 """ 

3218 for step in _iter_steps(steps): 

3219 if not step.get("allow"): 

3220 continue 

3221 step_type = step.get("type", "sequential") 

3222 agent = step.get("agent") 

3223 invokes_agent = ( 

3224 step_type in _AGENT_INVOKING_STEP_TYPES 

3225 and agent 

3226 and not step.get("body") 

3227 ) 

3228 if invokes_agent: 

3229 continue 

3230 step_id = step.get("id", "?") 

3231 raise RunHardFailure( 

3232 "ALLOW_INVALID_STEP_TYPE", 

3233 f"Step '{step_id}' declares 'allow' but is not an agent-bearing " 

3234 f"step that invokes an agent: type '{step_type}' with agent " 

3235 f"'{agent}' does not actually invoke an agent", 

3236 {"step_id": step_id, "type": step_type, "agent": agent}, 

3237 ) 

3238 

3239 

3240def _validate_allow_instructions( 

3241 steps: list[dict], 

3242 builtins: BuiltinExecutor, 

3243 step_functions: StepFunctionExecutor | None, 

3244) -> None: 

3245 """Require a non-empty usage instruction for every ``allow`` entry. 

3246 

3247 Walks all steps — including nested loop/parallel bodies — and fails fast 

3248 when a whitelisted name resolves to no instruction: neither a whitelistable 

3249 builtin registry text nor a StepFunction ``agent_instructions``. 

3250 """ 

3251 for step in _iter_steps(steps): 

3252 for entry in step.get("allow") or []: 

3253 if _resolve_allow_instruction(entry, builtins, step_functions): 

3254 continue 

3255 step_id = step.get("id", "?") 

3256 raise RunHardFailure( 

3257 "AGENT_INSTRUCTION_MISSING", 

3258 f"Step '{step_id}' allow entry '{entry}' has no usage " 

3259 "instruction: it is neither a whitelistable builtin nor a " 

3260 "StepFunction with agent_instructions", 

3261 {"step_id": step_id, "allow": entry}, 

3262 ) 

3263 

3264 

3265def _flatten_channel_state(state: dict) -> dict: 

3266 """Read the flat committed snapshot nested under the Core namespace.""" 

3267 return dict(state.get(_CORE) or {}) 

3268 

3269 

3270def _strip_channel_keys(flat: dict, channel_names: frozenset[str]) -> dict: 

3271 """Drop channel-named keys so a node emits only the core state.""" 

3272 return {k: v for k, v in flat.items() if k not in channel_names} 

3273 

3274 

3275_FAILURE_ENVELOPE_KEYS = frozenset({"error", "parse_error"}) 

3276 

3277_NON_ERROR_ENVELOPE_STATUSES: frozenset[str] = ENVELOPE_STATUSES - {"error"} 

3278 

3279 

3280def _is_failure_envelope(value: Any) -> bool: 

3281 """True when a step output is not a successfully parsed dict. 

3282 

3283 Missing output, non-dict values, and the ``error``/``parse_error`` 

3284 envelopes written by the LLM failure paths are not routed. A valid 

3285 non-error envelope (``ok``/``partial``/``no_success``) is routed even when 

3286 it carries an explicit ``"error": null``. 

3287 """ 

3288 if not isinstance(value, dict): 

3289 return True 

3290 if value.get("status") in _NON_ERROR_ENVELOPE_STATUSES: 

3291 return False 

3292 return bool(_FAILURE_ENVELOPE_KEYS & value.keys()) 

3293 

3294 

3295def _declared_output_channel_writes( 

3296 step: dict, output: Any, channel_names: frozenset[str] 

3297) -> dict[str, Any]: 

3298 """Route one step output's content to its declared output channel. 

3299 

3300 Returns ``{channel_name: output["content"]}`` for the caller to stage into 

3301 the working set. Failure envelopes and steps without an ``output_channel`` 

3302 are not routed; an undeclared target channel is a hard error. 

3303 """ 

3304 if _is_failure_envelope(output): 

3305 return {} 

3306 channel = step.get("output_channel") 

3307 if not channel: 

3308 return {} 

3309 if channel not in channel_names: 

3310 raise RunHardFailure( 

3311 "CHANNEL_BINDING_ERROR", 

3312 f"Step '{step.get('id', '?')}' declares output_channel '{channel}' " 

3313 "which is not declared in spec.channels", 

3314 ) 

3315 return {channel: output.get("content")} 

3316 

3317 

3318def _route_declared_channels( 

3319 step: dict, 

3320 pre_state: dict, 

3321 core: dict, 

3322 step_runner: Any, 

3323 channel_names: frozenset[str], 

3324) -> dict[str, Any]: 

3325 """Route a step's declared output channel write to its channel. 

3326 

3327 Returns ``{channel_name: value}`` for LangGraph to merge through the 

3328 channel's reducer, where ``channel_name`` is the step's single declared 

3329 ``output_channel`` and ``value`` is the agent envelope's ``content``. Only 

3330 an actual agent invocation is routed: a ``skip_llm_if`` passthrough and a 

3331 parallel fan-out envelope are excluded. A step without an ``output_channel`` 

3332 routes nothing (its output stays in ``core`` untouched), and failure 

3333 envelopes are not routed. A spawn step is routed at the spawn seam from its 

3334 node result, never here from its envelope. 

3335 """ 

3336 step_id = step.get("id", "?") 

3337 if step.get("type") == "spawn": 

3338 return {} 

3339 if step_id not in core: 

3340 return {} 

3341 agent_spec = step_runner.resolve_agent(step) 

3342 if agent_spec is None: 

3343 return {} 

3344 if not step_runner.ran_agent(step, pre_state): 

3345 return {} 

3346 return _declared_output_channel_writes(step, core[step_id], channel_names) 

3347 

3348 

3349def _make_step_node( 

3350 step: dict, 

3351 step_runner: Any, 

3352 channel_names: frozenset[str], 

3353 channel_types: dict[str, str], 

3354) -> Any: 

3355 if not channel_names: 

3356 def node_fn(state: PipelineState) -> PipelineState: 

3357 return step_runner.run_step(step, state) 

3358 return node_fn 

3359 

3360 def node_fn(state: PipelineState) -> PipelineState: 

3361 committed = _flatten_channel_state(state) 

3362 working_set = ChannelWorkingSet(committed, channel_types) 

3363 core = _strip_channel_keys( 

3364 step_runner.run_step(step, committed, working_set), channel_names 

3365 ) 

3366 writes = _route_declared_channels( 

3367 step, committed, core, step_runner, channel_names 

3368 ) 

3369 for name, value in writes.items(): 

3370 working_set.stage(name, value) 

3371 channels = working_set.commit() 

3372 return {_CORE: {**core, **channels}} 

3373 return node_fn 

3374 

3375 

3376def _collect_parallel_channel_writes( 

3377 steps: list[dict], 

3378 pre_state: dict, 

3379 core: dict, 

3380 step_runner: Any, 

3381 channel_names: frozenset[str], 

3382 branch_working_sets: dict[str, ChannelWorkingSet] | None = None, 

3383) -> list[tuple[str, Any]]: 

3384 """Collect one parallel node's declared writes in branch order. 

3385 

3386 Every branch write is emitted, so fan-in feeds the channel-type reducer 

3387 every write in deterministic declaration/branch order: Topic appends each 

3388 write, LastValue keeps the last, and BinaryOperatorAggregate combines them 

3389 per its accumulated shape (list concat, collection-map union, dict merge, 

3390 scalar last). A branch's isolated body writes are emitted before its own 

3391 declared write, mirroring the top-level node's route-after-run order. 

3392 """ 

3393 writes: list[tuple[str, Any]] = [] 

3394 for step in steps: 

3395 if branch_working_sets is not None: 

3396 branch_working_set = branch_working_sets.get(step["id"]) 

3397 if branch_working_set is not None: 

3398 writes.extend(branch_working_set.pending_writes()) 

3399 branch = _route_declared_channels( 

3400 step, pre_state, core, step_runner, channel_names 

3401 ) 

3402 for channel_name, value in branch.items(): 

3403 writes.append((channel_name, value)) 

3404 return writes 

3405 

3406 

3407def _make_parallel_node( 

3408 group_steps: list[dict], 

3409 step_runner: Any, 

3410 channel_names: frozenset[str], 

3411 channel_types: dict[str, str], 

3412) -> Any: 

3413 def node_fn(state: PipelineState) -> PipelineState: 

3414 base = ( 

3415 _flatten_channel_state(state) 

3416 if channel_names else state 

3417 ) 

3418 working_set = ( 

3419 ChannelWorkingSet(base, channel_types) if channel_names else None 

3420 ) 

3421 # Each branch writes into its own fork; the shared set is advanced only 

3422 # at fan-in, so branch-internal body writes never race. 

3423 branch_working_sets = ( 

3424 {s["id"]: working_set.fork() for s in group_steps} 

3425 if working_set is not None else None 

3426 ) 

3427 

3428 def submit_branch(pool: Any, step: dict) -> Any: 

3429 if branch_working_sets is None: 

3430 return pool.submit(step_runner.run_step, step, base) 

3431 return pool.submit( 

3432 step_runner.run_step, step, base, branch_working_sets[step["id"]] 

3433 ) 

3434 

3435 results: dict[str, Any] = {} 

3436 with ThreadPoolExecutor(max_workers=len(group_steps)) as pool: 

3437 futures = {submit_branch(pool, s): s["id"] for s in group_steps} 

3438 for future in as_completed(futures): 

3439 sid = futures[future] 

3440 try: 

3441 out = future.result() 

3442 results[sid] = out.get(sid) 

3443 except SkillLoadError: 

3444 raise 

3445 except RunHardFailure: 

3446 pool.shutdown(wait=False, cancel_futures=True) 

3447 raise 

3448 except Exception as exc: # noqa: BLE001 

3449 log.error("Step '%s' failed: %s", sid, exc) 

3450 results[sid] = {"error": str(exc)} 

3451 if channel_names: 

3452 merged = _strip_channel_keys({**base, **results}, channel_names) 

3453 writes = _collect_parallel_channel_writes( 

3454 group_steps, base, merged, step_runner, channel_names, 

3455 branch_working_sets, 

3456 ) 

3457 for name, value in writes: 

3458 working_set.stage(name, value) 

3459 channels = working_set.commit() 

3460 return {_CORE: {**merged, **channels}} 

3461 return {**state, **results} 

3462 return node_fn 

3463 

3464 

3465class _ChannelGraphShim: 

3466 """Flat-dict boundary over a Core-owned channel-schema compiled graph. 

3467 

3468 Preserves the invoke contract used by main.py and tests: a flat dict in, 

3469 a flat dict out. The flat input is seeded through ``seed_committed`` and 

3470 nested under ``__core__`` for the compiled graph; the committed snapshot is 

3471 unwrapped back out. Streaming and state surfaces are not exposed because 

3472 they would leak or accept the internal ``__core__`` shape; read-only 

3473 introspection delegates to the compiled graph. 

3474 """ 

3475 

3476 def __init__(self, compiled: Any, specs: list[ChannelSpec]) -> None: 

3477 self._compiled = compiled 

3478 self._specs = specs 

3479 

3480 def invoke(self, flat_state: dict, *args: Any, **kwargs: Any) -> dict: 

3481 seeded = seed_committed(self._specs, flat_state) 

3482 result = self._compiled.invoke(wrap(seeded), *args, **kwargs) 

3483 return unwrap(result) 

3484 

3485 async def ainvoke(self, flat_state: dict, *args: Any, **kwargs: Any) -> dict: 

3486 seeded = seed_committed(self._specs, flat_state) 

3487 result = await self._compiled.ainvoke(wrap(seeded), *args, **kwargs) 

3488 return unwrap(result) 

3489 

3490 def stream(self, *args: Any, **kwargs: Any) -> Any: 

3491 self._reject_internal_shape("stream") 

3492 

3493 def astream(self, *args: Any, **kwargs: Any) -> Any: 

3494 self._reject_internal_shape("astream") 

3495 

3496 def update_state(self, *args: Any, **kwargs: Any) -> Any: 

3497 self._reject_internal_shape("update_state") 

3498 

3499 def aupdate_state(self, *args: Any, **kwargs: Any) -> Any: 

3500 self._reject_internal_shape("aupdate_state") 

3501 

3502 def get_state(self, *args: Any, **kwargs: Any) -> Any: 

3503 self._reject_internal_shape("get_state") 

3504 

3505 def aget_state(self, *args: Any, **kwargs: Any) -> Any: 

3506 self._reject_internal_shape("aget_state") 

3507 

3508 def _reject_internal_shape(self, surface: str) -> None: 

3509 raise NotImplementedError( 

3510 f"_ChannelGraphShim.{surface}() is not supported: only the flat " 

3511 ".invoke()/.ainvoke() contract is exposed through the channel " 

3512 "boundary shim" 

3513 ) 

3514 

3515 def __getattr__(self, name: str) -> Any: 

3516 return getattr(self._compiled, name) 

3517 

3518 

3519class GraphBuilder: 

3520 """ 

3521 Rakentaa LangGraph StateGraphin Pipeline-manifestista. 

3522 

3523 run_context: template-kontekstidata (budget, config jne.) — ei tallennu stateen, 

3524 käytetään vain {{ }} -templatejen evaluointiin. 

3525 """ 

3526 

3527 def __init__( 

3528 self, 

3529 registry: ManifestRegistry, 

3530 builtins: BuiltinExecutor | None = None, 

3531 invoke_agent_fn: Any | None = None, 

3532 run_context: dict | None = None, 

3533 tracer: Any | None = None, 

3534 ) -> None: 

3535 self._registry = registry 

3536 self._resolver = AgentResolver(registry) 

3537 self._invoke_agent_fn = invoke_agent_fn 

3538 self._builtins = builtins or _DEFAULT_BUILTINS 

3539 if invoke_agent_fn is not None: 

3540 self._builtins.invoke_agent_fn = invoke_agent_fn 

3541 self._step_functions = StepFunctionExecutor(registry) 

3542 self._step_runner = StepRunner( 

3543 self._resolver, self._builtins, invoke_agent_fn, run_context, 

3544 step_functions=self._step_functions, 

3545 tracer=tracer, 

3546 ) 

3547 

3548 def build(self, pipeline: dict): # -> CompiledGraph 

3549 """Rakentaa ja palauttaa compilatun LangGraph StateGraphin.""" 

3550 try: 

3551 from langgraph.graph import StateGraph, END 

3552 except ImportError as exc: 

3553 raise ImportError( 

3554 "langgraph is required to build a workflow. " 

3555 "Install it with: pip install langgraph" 

3556 ) from exc 

3557 

3558 steps: list[dict] = (pipeline.get("spec") or {}).get("steps") or [] 

3559 # Validate `allow` step types on the raw graph: call-expansion removes 

3560 # call steps (and their dropped `allow`) before the whitelist validator. 

3561 _validate_allow_step_types(steps) 

3562 # Build-time call-step expansion (ADR-0025 D4): the runtime executes only 

3563 # the flat expanded graph. LangGraph never sees a call step. 

3564 steps = expand_calls(steps, self._registry) 

3565 ordered = _topological_order(steps) 

3566 

3567 channels: list[dict] = _validated_channels( 

3568 (pipeline.get("spec") or {}).get("channels") 

3569 ) 

3570 if channels: 

3571 _validate_channel_names(channels, steps) 

3572 _validate_channel_inputs(channels, steps) 

3573 specs = parse_channel_specs( 

3574 [{"name": ch["name"], "type": ch.get("type", LAST_VALUE)} for ch in channels] 

3575 ) 

3576 channel_types = {spec.name: spec.type for spec in specs} 

3577 channel_names = frozenset(channel_types) 

3578 _validate_step_channel_bindings(steps, channel_names) 

3579 _validate_allow_instructions(steps, self._builtins, self._step_functions) 

3580 

3581 # Validate the pipeline's env map (spec.envs must be a map — ADR-0012). 

3582 envs = (pipeline.get("spec") or {}).get("envs") 

3583 if envs is not None and not isinstance(envs, dict): 

3584 raise ValueError( 

3585 f"Pipeline '{((pipeline.get('metadata') or {}).get('name') or '?')}': " 

3586 "spec.envs must be a map of env name → image ref (ADR-0012)" 

3587 ) 

3588 

3589 # Group steps into LangGraph nodes: 

3590 # Steps with the same depends_on set AND type==parallel → one node (LangGraph-level parallel) 

3591 nodes: list[list[dict]] = [] 

3592 for step in ordered: 

3593 dep_sig = frozenset(step.get("depends_on") or []) 

3594 if nodes: 

3595 last_group_sig = frozenset( 

3596 dep 

3597 for s in nodes[-1] 

3598 for dep in (s.get("depends_on") or []) 

3599 ) 

3600 last_types = {s.get("type") for s in nodes[-1]} 

3601 if ( 

3602 dep_sig == last_group_sig 

3603 and step.get("type") == "parallel" 

3604 and last_types == {"parallel"} 

3605 ): 

3606 nodes[-1].append(step) 

3607 continue 

3608 nodes.append([step]) 

3609 

3610 state_schema = _build_state_schema() if channels else PipelineState 

3611 graph: StateGraph = StateGraph(state_schema) 

3612 step_runner = self._step_runner 

3613 step_runner.set_channel_routing(channel_names) 

3614 

3615 prev_node_id: str | None = None 

3616 for group in nodes: 

3617 if len(group) == 1: 

3618 node_id = group[0]["id"] 

3619 else: 

3620 node_id = "parallel_" + "_".join(s["id"] for s in group) 

3621 

3622 captured_group = list(group) 

3623 

3624 if len(captured_group) == 1: 

3625 fn = _make_step_node( 

3626 captured_group[0], step_runner, channel_names, 

3627 channel_types, 

3628 ) 

3629 else: 

3630 fn = _make_parallel_node( 

3631 captured_group, 

3632 step_runner, 

3633 channel_names, 

3634 channel_types, 

3635 ) 

3636 

3637 graph.add_node(node_id, fn) 

3638 

3639 if prev_node_id is None: 

3640 graph.set_entry_point(node_id) 

3641 else: 

3642 graph.add_edge(prev_node_id, node_id) 

3643 

3644 prev_node_id = node_id 

3645 

3646 if prev_node_id: 

3647 graph.add_edge(prev_node_id, END) 

3648 else: 

3649 graph.add_node("noop", lambda s: s) 

3650 graph.set_entry_point("noop") 

3651 graph.add_edge("noop", END) 

3652 

3653 compiled = graph.compile() 

3654 if channel_names: 

3655 return _ChannelGraphShim(compiled, specs) 

3656 return compiled 

3657 

3658 

3659# --------------------------------------------------------------------------- 

3660# Julkinen API 

3661# --------------------------------------------------------------------------- 

3662 

3663_registry_cache: ManifestRegistry | None = None 

3664 

3665 

3666def _get_registry(dirs: list[Path] | None = None) -> ManifestRegistry: 

3667 global _registry_cache 

3668 if _registry_cache is None or dirs is not None: 

3669 loader = ManifestLoader(dirs) 

3670 manifests = loader.load_all() 

3671 _registry_cache = ManifestRegistry(manifests) 

3672 return _registry_cache 

3673 

3674 

3675def build_workflow( 

3676 pipeline_name: str, 

3677 *, 

3678 dirs: list[Path] | None = None, 

3679 builtins: BuiltinExecutor | None = None, 

3680 invoke_agent_fn: Any | None = None, 

3681 run_context: dict | None = None, 

3682 tracer: Any | None = None, 

3683): 

3684 """ 

3685 Lataa Pipeline-manifesti nimeltä *pipeline_name* ja rakentaa 

3686 LangGraph-workflown. 

3687 

3688 Args: 

3689 pipeline_name: Pipeline metadata.name -arvo. 

3690 dirs: Manifestihakemistot (oletus: MANIFEST_DIRS). 

3691 builtins: Oma BuiltinExecutor (oletus: _DEFAULT_BUILTINS). 

3692 invoke_agent_fn: fn(resolved_spec, step, state) → state. 

3693 run_context: Template-kontekstidata, esim. {"budget": {...}, "config": {...}}. 

3694 

3695 Returns: 

3696 Compilattu LangGraph CompiledGraph. 

3697 """ 

3698 registry = _get_registry(dirs) 

3699 pipeline = registry.get("Pipeline", pipeline_name) 

3700 if pipeline is None: 

3701 available = [ 

3702 (m.get("metadata") or {}).get("name") 

3703 for m in registry.all_of_kind("Pipeline") 

3704 ] 

3705 raise KeyError( 

3706 f"Pipeline '{pipeline_name}' not found. Available: {available}" 

3707 ) 

3708 builder = GraphBuilder(registry, builtins, invoke_agent_fn, run_context, tracer=tracer) 

3709 return builder.build(pipeline)