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

1766 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-20 02:14 +0000

1""" 

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

3 

4Arkkitehtuuri: 

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

6 ManifestRegistry → indeksoi Agent / AgentInstance / Pipeline -resurssit 

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

8 GraphBuilder → rakentaa LangGraph StateGraphin Pipeline-manifestista 

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

10 

11Käyttö: 

12 from langgraph.runner import build_workflow 

13 workflow = build_workflow("review-pr") 

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

15""" 

16from __future__ import annotations 

17 

18import contextvars 

19import hashlib 

20import logging 

21import os 

22import re 

23import threading 

24import uuid 

25from concurrent.futures import ThreadPoolExecutor, as_completed 

26from pathlib import Path 

27from typing import Any 

28 

29import yaml 

30 

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

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

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

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

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

36try: 

37 from _llm import DEFAULT_MAX_PARALLEL, SkillLoadError # noqa: F401 

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

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

40 

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

42try: 

43 from _step_functions import StepFunctionExecutor # noqa: F401 

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

45 from langgraph._step_functions import StepFunctionExecutor # noqa: F401 

46 

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

48# pattern as _llm. 

49try: 

50 from _instructions import ( 

51 USAGE_INSTRUCTIONS_KEY, 

52 instruction_text, 

53 resolve_usage_instructions, 

54 ) 

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

56 from langgraph._instructions import ( 

57 USAGE_INSTRUCTIONS_KEY, 

58 instruction_text, 

59 resolve_usage_instructions, 

60 ) 

61 

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

63# dual-import pattern as _llm. 

64try: 

65 from _data_structure import ( 

66 COMMUNICATION_CONTEXT_KEY, 

67 resolve_communication_context, 

68 ) 

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

70 from langgraph._data_structure import ( 

71 COMMUNICATION_CONTEXT_KEY, 

72 resolve_communication_context, 

73 ) 

74 

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

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

77# native LangGraph channels. 

78try: 

79 from _channels import ( 

80 CORE_KEY, 

81 LAST_VALUE, 

82 ChannelSpec, 

83 ChannelWorkingSet, 

84 parse_channel_specs, 

85 seed_committed, 

86 unwrap, 

87 wrap, 

88 ) 

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

90 from langgraph._channels import ( 

91 CORE_KEY, 

92 LAST_VALUE, 

93 ChannelSpec, 

94 ChannelWorkingSet, 

95 parse_channel_specs, 

96 seed_committed, 

97 unwrap, 

98 wrap, 

99 ) 

100 

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

102try: 

103 from _envelope import ENVELOPE_ERROR_CODE, ENVELOPE_STATUSES, is_error_envelope 

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

105 from langgraph._envelope import ( # noqa: F401 

106 ENVELOPE_ERROR_CODE, 

107 ENVELOPE_STATUSES, 

108 is_error_envelope, 

109 ) 

110 

111log = logging.getLogger(__name__) 

112 

113 

114class RunHardFailure(Exception): 

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

116 

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

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

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

120 ENVELOPE_ERROR_CODE) tai kontekstiylityksen signatuurin kantava 

121 VALIDATION_ERROR (assembled_tokens + context_budget, 

122 _llm._context_overflow_result). 

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

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

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

126 """ 

127 

128 HARD_ERROR_CODES: frozenset[str] = frozenset( 

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

130 ) 

131 

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

133 super().__init__(message) 

134 self.code = code 

135 self.message = message 

136 self.details = details 

137 

138 

139class SpawnBudget: 

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

141 

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

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

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

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

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

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

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

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

150 """ 

151 

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

153 self._remaining: int | None = ( 

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

155 ) 

156 self._lock = threading.Lock() 

157 

158 def consume(self) -> bool: 

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

160 with self._lock: 

161 if self._remaining is None: 

162 return True 

163 if self._remaining <= 0: 

164 return False 

165 self._remaining -= 1 

166 return True 

167 

168 

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

170 

171_SPAWN_DEFAULT_MAX_AGENT_CALLS: int = 64 

172_SPAWN_DEFAULT_MAX_DEPTH: int = 1 

173_SPAWN_DEFAULT_MAX_SPAWN_PER_NODE: int = 16 

174 

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

176# Polut 

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

178 

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

180MANIFEST_DIRS = [ 

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

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

183] 

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

185 

186 

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

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

189 

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

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

192 """ 

193 return [ 

194 d for d in ( 

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

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

197 ) 

198 if d.is_dir() 

199 ] 

200 

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

202# Template-resoluutio 

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

204 

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

206 

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

208_UNRESOLVED = object() 

209 

210 

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

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

213 

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

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

216 """ 

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

218 base = m.group(1) 

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

220 return base, indices 

221 

222 

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

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

225 for idx in indices: 

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

227 return _UNRESOLVED 

228 obj = obj[idx] 

229 return obj 

230 

231 

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

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

234 

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

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

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

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

239 """ 

240 base, indices = _split_key_indices(part) 

241 if isinstance(obj, dict): 

242 if base: 

243 if base not in obj: 

244 return _UNRESOLVED 

245 obj = obj[base] 

246 return _apply_indices(obj, indices) 

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

248 if indices: 

249 return _apply_indices(obj, indices) 

250 if base and base.isdigit(): 

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

252 return _UNRESOLVED 

253 return _UNRESOLVED 

254 

255 

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

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

258 

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

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

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

262 """ 

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

264 obj = _dot_get_segment(obj, part) 

265 if obj is _UNRESOLVED: 

266 return None 

267 return obj 

268 

269 

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

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

272 

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

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

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

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

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

278 """ 

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

280 if obj is None: 

281 raise ValueError( 

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

283 ) 

284 obj = _dot_get_segment(obj, part) 

285 if obj is _UNRESOLVED: 

286 raise ValueError( 

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

288 ) 

289 return obj 

290 

291 

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

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

294 

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

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

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

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

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

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

301 """ 

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

303 if not out_spec: 

304 return None 

305 source = out_spec.get("source") 

306 if not source: 

307 return None 

308 return _dot_get(final_state, source) 

309 

310 

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

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

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

314 merged = dict(base or {}) 

315 merged.update(additions) 

316 return merged 

317 

318 

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

320_SINGLE_OPS = '+-<>' 

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

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

323 

324 

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

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

327 

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

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

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

331 """ 

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

333 i, n = 0, len(expr) 

334 while i < n: 

335 ch = expr[i] 

336 if ch.isspace(): 

337 i += 1 

338 continue 

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

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

341 i += 1 

342 continue 

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

344 two = expr[i:i + 2] 

345 if two in _MULTI_OPS: 

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

347 i += 2 

348 continue 

349 if ch in _SINGLE_OPS: 

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

351 i += 1 

352 continue 

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

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

355 j = i + 1 

356 buf: list[str] = [] 

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

358 buf.append(expr[j]) 

359 j += 1 

360 if j >= n: 

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

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

363 i = j + 1 

364 continue 

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

366 j = i 

367 if ch == '.': 

368 j += 1 

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

370 j += 1 

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

372 else: 

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

374 j += 1 

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

376 j += 1 

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

378 j += 1 

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

380 else: 

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

382 i = j 

383 continue 

384 j = i 

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

386 j += 1 

387 if j == i: 

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

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

390 i = j 

391 return tokens 

392 

393 

394# AST node shapes: 

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

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

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

398# ('neg', node) unary minus 

399# ('not', node) not X 

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

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

402 

403 

404class _ExprParser: 

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

406 

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

408 Grammar: 

409 expr := or_expr 

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

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

412 not_expr := 'not' not_expr | cmp_expr 

413 cmp_expr := add_expr (cmp_op add_expr)? 

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

415 unary_expr:= '-' unary_expr | primary 

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

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

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

419 unresolved read there never raises. 

420 """ 

421 

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

423 self._tokens = tokens 

424 self._i = 0 

425 

426 def parse(self) -> tuple: 

427 node = self._or_expr() 

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

429 tok = self._tokens[self._i] 

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

431 return node 

432 

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

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

435 

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

437 tok = self._tokens[self._i] 

438 self._i += 1 

439 return tok 

440 

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

442 tok = self._peek() 

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

444 

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

446 tok = self._peek() 

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

448 

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

450 tok = self._next() 

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

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

453 

454 def _or_expr(self) -> tuple: 

455 node = self._and_expr() 

456 while self._peek_name('or'): 

457 self._next() 

458 right = self._and_expr() 

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

460 return node 

461 

462 def _and_expr(self) -> tuple: 

463 node = self._not_expr() 

464 while self._peek_name('and'): 

465 self._next() 

466 right = self._not_expr() 

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

468 return node 

469 

470 def _not_expr(self) -> tuple: 

471 if self._peek_name('not'): 

472 self._next() 

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

474 return self._cmp_expr() 

475 

476 def _cmp_expr(self) -> tuple: 

477 node = self._add_expr() 

478 tok = self._peek() 

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

480 self._next() 

481 right = self._add_expr() 

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

483 return node 

484 

485 def _add_expr(self) -> tuple: 

486 node = self._unary_expr() 

487 while self._peek_op('+'): 

488 self._next() 

489 right = self._unary_expr() 

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

491 return node 

492 

493 def _unary_expr(self) -> tuple: 

494 if self._peek_op('-'): 

495 self._next() 

496 node = self._unary_expr() 

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

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

499 return ('neg', node) 

500 return self._primary() 

501 

502 def _primary(self) -> tuple: 

503 tok = self._peek() 

504 if tok is None: 

505 raise ValueError("Unexpected end of expression") 

506 kind, value = tok 

507 if kind == 'num': 

508 self._next() 

509 return ('lit', value) 

510 if kind == 'str': 

511 self._next() 

512 return ('lit', value) 

513 if kind == 'punct': 

514 if value == '(': 

515 self._next() 

516 node = self._or_expr() 

517 self._expect_punct(')') 

518 return node 

519 if value == '[': 

520 self._next() 

521 items: list[tuple] = [] 

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

523 while True: 

524 items.append(self._or_expr()) 

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

526 self._next() 

527 continue 

528 break 

529 self._expect_punct(']') 

530 return ('list', items) 

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

532 if kind == 'name': 

533 self._next() 

534 lowered = value.lower() 

535 if lowered == 'true': 

536 return ('lit', True) 

537 if lowered == 'false': 

538 return ('lit', False) 

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

540 return ('lit', None) 

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

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

543 return ('ref', value) 

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

545 

546 

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

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

549 

550 

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

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

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

554 return left + right 

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

556 return left + right 

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

558 return left + right 

559 raise ValueError( 

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

561 ) 

562 

563 

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

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

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

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

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

569 return node[1] 

570 return _eval_ast(node, ctx, strict) 

571 

572 

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

574 left = _eval_ast(left_node, ctx, strict) 

575 right = _eval_comparison_rhs(right_node, ctx, strict) 

576 try: 

577 if op == '==': 

578 return left == right 

579 if op == '!=': 

580 return left != right 

581 if op == '<=': 

582 return left <= right 

583 if op == '>=': 

584 return left >= right 

585 if op == '<': 

586 return left < right 

587 if op == '>': 

588 return left > right 

589 except TypeError: 

590 return False 

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

592 

593 

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

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

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

597 kind = node[0] 

598 if kind == 'lit': 

599 return node[1] 

600 if kind == 'ref': 

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

602 if kind == 'list': 

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

604 if kind == 'neg': 

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

606 if kind == 'not': 

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

608 if kind == 'cmp': 

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

610 if kind == 'binop': 

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

612 if op == 'and': 

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

614 return False 

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

616 if op == 'or': 

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

618 return True 

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

620 if op == '+': 

621 left = _eval_ast(left_node, ctx, strict) 

622 right = _eval_ast(right_node, ctx, strict) 

623 return _add_values(left, right) 

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

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

626 

627 

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

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

630 

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

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

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

634 side already determines the result. 

635 """ 

636 expr = expr.strip() 

637 tokens = _tokenize_expr(expr) 

638 if not tokens: 

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

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

641 

642 

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

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

645 if isinstance(value, str): 

646 stripped = value.strip() 

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

648 m = _TMPL_RE.fullmatch(stripped) 

649 if m: 

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

651 # Inline substitution → always returns string 

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

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

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

655 return _TMPL_RE.sub(_replace, value) 

656 if isinstance(value, dict): 

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

658 if isinstance(value, list): 

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

660 return value 

661 

662 

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

664 """ 

665 Resolve step input spec to a concrete value: 

666 "{{ item }}" → template 

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

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

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

670 None → None 

671 """ 

672 if input_spec is None: 

673 return None 

674 if isinstance(input_spec, str): 

675 if '{{' in input_spec: 

676 return _resolve_template(input_spec, ctx) 

677 stripped = input_spec.lstrip() 

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

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

680 # builtin verbatim so it resolves the placeholder itself. 

681 if stripped.startswith('['): 

682 return input_spec 

683 # Dot-path into state (no braces) 

684 return _dot_get(state, stripped) 

685 if isinstance(input_spec, dict): 

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

687 if isinstance(input_spec, list): 

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

689 return input_spec 

690 

691 

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

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

694 

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

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

697 raises, never silently evaluates to falsy. 

698 """ 

699 if cond is None: 

700 return True 

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

702 if isinstance(resolved, bool): 

703 return resolved 

704 if isinstance(resolved, str): 

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

706 return bool(resolved) 

707 

708 

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

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

711 try: 

712 return int(v) 

713 except (TypeError, ValueError): 

714 return default 

715 

716 

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

718# ManifestLoader 

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

720 

721 

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

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

724 try: 

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

726 except OSError as exc: 

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

728 return [] 

729 

730 results: list[dict] = [] 

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

732 raw_clean = "\n".join( 

733 line for line in raw.splitlines() 

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

735 ) 

736 try: 

737 doc = yaml.safe_load(raw_clean) 

738 except yaml.YAMLError as exc: 

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

740 continue 

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

742 results.append(doc) 

743 return results 

744 

745 

746class ManifestLoader: 

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

748 

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

750 self._dirs = dirs or MANIFEST_DIRS 

751 

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

753 manifests: list[dict] = [] 

754 for d in self._dirs: 

755 if not d.exists(): 

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

757 continue 

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

759 docs = _parse_manifest_file(path) 

760 manifests.extend(docs) 

761 if docs: 

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

763 return manifests 

764 

765 

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

767# ManifestRegistry 

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

769 

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

771 

772 

773class ManifestRegistry: 

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

775 

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

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

778 for doc in manifests: 

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

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

781 if kind and name: 

782 key: ResourceKey = (kind, name) 

783 if key in self._store: 

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

785 self._store[key] = doc 

786 

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

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

789 

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

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

792 

793 

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

795# AgentResolver 

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

797 

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

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

800 result = dict(base) 

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

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

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

804 else: 

805 result[k] = v 

806 return result 

807 

808 

809class AgentResolver: 

810 """ 

811 Palauttaa fully-resolved agent spec:n instanssille. 

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

813 """ 

814 

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

816 self._registry = registry 

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

818 

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

820 if instance_name in self._cache: 

821 return self._cache[instance_name] 

822 

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

824 if instance is None: 

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

826 if agent is None: 

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

828 resolved = agent 

829 else: 

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

831 if based_on: 

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

833 if base_agent is None: 

834 raise KeyError( 

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

836 ) 

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

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

839 if k != "based_on"} 

840 merged_spec = _deep_merge(base_spec, inst_spec) 

841 else: 

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

843 if k != "based_on"} 

844 

845 resolved = { 

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

847 "kind": "Agent", 

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

849 "spec": merged_spec, 

850 } 

851 

852 self._cache[instance_name] = resolved 

853 return resolved 

854 

855 

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

857# State type 

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

859 

860PipelineState = dict[str, Any] 

861 

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

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

864_CORE = CORE_KEY 

865 

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

867_CORE_STATE_KEYS = frozenset({ 

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

869 "_stats", "step_context_metrics", 

870}) 

871 

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

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

874# never shadow them. 

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

876 

877 

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

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

880 

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

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

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

884 bounded, filename-safe length. 

885 """ 

886 for key in _ENGINE_LOCAL_ITERATION_KEYS: 

887 if key in state: 

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

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

890 return None 

891 

892 

893 

894def _build_state_schema() -> Any: 

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

896 

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

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

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

900 """ 

901 from typing import TypedDict 

902 

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

904 

905 

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

907_UNSET: Any = object() 

908 

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

910# BuiltinExecutor 

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

912 

913class BuiltinExecutor: 

914 """ 

915 Ajaa builtin-komponentit. 

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

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

918 """ 

919 

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

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

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

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

924 

925 def __init__(self) -> None: 

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

927 "passthrough": self._passthrough, 

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

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

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

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

932 "__call_bind": self._call_bind, 

933 "__call_emit": self._call_emit, 

934 } 

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

936 # GraphBuilder. Profile builtins that make LLM decisions 

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

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

939 # executes (binding happens at build_workflow time). 

940 self.invoke_agent_fn: Any | None = None 

941 self._lazy_loaded = False 

942 

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

944 self._handlers[name] = fn 

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

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

947 if normalised != name: 

948 self._handlers[normalised] = fn 

949 

950 def _load_lazy_modules(self) -> None: 

951 if self._lazy_loaded: 

952 return 

953 self._lazy_loaded = True 

954 import importlib 

955 for module_name in self._LAZY_MODULES: 

956 try: 

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

958 except ImportError: 

959 continue 

960 mod.register_all(self) 

961 

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

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

964 self._load_lazy_modules() 

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

966 

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

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

969 if fn is not None: 

970 return fn 

971 self._load_lazy_modules() 

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

973 if fn is None: 

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

975 return fn 

976 

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

978 fn = self.get(component) 

979 return fn(step, state) 

980 

981 # --- default builtins --- 

982 

983 @staticmethod 

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

985 return state 

986 

987 @staticmethod 

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

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

990 

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

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

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

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

995 """ 

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

997 if not call_id: 

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

999 bound = { 

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

1001 } 

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

1003 

1004 @staticmethod 

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

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

1007 

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

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

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

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

1012 

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

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

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

1016 """ 

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

1018 call_id = params.get("__call_id") 

1019 if not call_id: 

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

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

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

1023 for spec in outputs_spec: 

1024 if not isinstance(spec, dict): 

1025 continue 

1026 name = spec.get("name") 

1027 source = spec.get("source") 

1028 if name is None: 

1029 continue 

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

1031 if source is None: 

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

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

1034 if optional: 

1035 continue 

1036 raise RunHardFailure( 

1037 "UNRESOLVED_OUTPUT", 

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

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

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

1041 ) 

1042 try: 

1043 value = _dot_get_strict(state, source) 

1044 except ValueError: 

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

1046 if optional: 

1047 continue 

1048 raise RunHardFailure( 

1049 "UNRESOLVED_OUTPUT", 

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

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

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

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

1054 ) from None 

1055 outputs[name] = value 

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

1057 

1058 

1059_DEFAULT_BUILTINS = BuiltinExecutor() 

1060 

1061 

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

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

1064 

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

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

1067 """ 

1068 try: 

1069 from langgraph import _builtins # type: ignore 

1070 except ImportError: 

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

1072 import importlib.util 

1073 spec = importlib.util.spec_from_file_location( 

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

1075 ) 

1076 if spec and spec.loader: 

1077 _builtins = importlib.util.module_from_spec(spec) 

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

1079 else: 

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

1081 return 

1082 _builtins.register_all(executor) 

1083 _register_function_builtins(executor) 

1084 

1085 

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

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

1088 

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

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

1091 """ 

1092 try: 

1093 from langgraph import _builtins_functions # type: ignore 

1094 except ImportError: 

1095 import importlib.util 

1096 spec = importlib.util.spec_from_file_location( 

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

1098 ) 

1099 if spec and spec.loader: 

1100 _builtins_functions = importlib.util.module_from_spec(spec) 

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

1102 else: 

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

1104 return 

1105 _builtins_functions.register_all(executor) 

1106 

1107 

1108_register_domain_builtins(_DEFAULT_BUILTINS) 

1109 

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

1111# StepRunner 

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

1113 

1114class StepRunner: 

1115 """ 

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

1117 

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

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

1120 """ 

1121 

1122 def __init__( 

1123 self, 

1124 resolver: AgentResolver, 

1125 builtins: BuiltinExecutor | None = None, 

1126 invoke_agent_fn: Any | None = None, 

1127 run_context: dict | None = None, 

1128 *, 

1129 step_functions: StepFunctionExecutor | None = None, 

1130 tracer: Any | None = None, 

1131 ) -> None: 

1132 self._resolver = resolver 

1133 self._builtins = builtins or _DEFAULT_BUILTINS 

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

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

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

1137 self._invoke_agent = self._wrap_calls_gate( 

1138 self._wrap_hard_failure_gate( 

1139 invoke_agent_fn or self._dummy_invoke 

1140 ) 

1141 ) 

1142 self._run_context: dict = run_context or {} 

1143 self._step_functions = step_functions 

1144 self._tracer = tracer 

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

1146 

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

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

1149 self._channel_names = channel_names 

1150 

1151 @staticmethod 

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

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

1154 

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

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

1157 truthy expression skips the LLM. 

1158 """ 

1159 raw = step.get("skip_llm_if") 

1160 if raw is None: 

1161 return False 

1162 resolved = _resolve_template(raw, ctx) 

1163 if isinstance(resolved, bool): 

1164 return resolved 

1165 if isinstance(resolved, str): 

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

1167 return bool(resolved) 

1168 

1169 @staticmethod 

1170 def _skip_llm_passthrough( 

1171 step: dict, 

1172 state: PipelineState, 

1173 ctx: dict, 

1174 read_state: PipelineState, 

1175 ) -> PipelineState: 

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

1177 

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

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

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

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

1182 """ 

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

1184 input_spec = step.get("input") 

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

1186 if isinstance(resolved, list): 

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

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

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

1190 return {**state, step_id: resolved} 

1191 

1192 @staticmethod 

1193 def _parallel_fanout_items( 

1194 step: dict, state: PipelineState, ctx: dict 

1195 ) -> list | None: 

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

1197 

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

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

1200 single agent output. 

1201 """ 

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

1203 return None 

1204 input_spec = step.get("input") 

1205 if input_spec is None: 

1206 return None 

1207 input_val = _resolve_input(input_spec, state, ctx) 

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

1209 

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

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

1212 

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

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

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

1216 are excluded from inverse projection. 

1217 """ 

1218 if self.resolve_agent(step) is None: 

1219 return False 

1220 ctx = self._make_ctx(state) 

1221 if self._should_skip_llm(step, ctx): 

1222 return False 

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

1224 

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

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

1227 ctx: dict = {} 

1228 ctx.update(state) 

1229 ctx.update(self._run_context) 

1230 ctx.update(extras) 

1231 return ctx 

1232 

1233 @staticmethod 

1234 def _read_state( 

1235 state: PipelineState, working_set: ChannelWorkingSet | None 

1236 ) -> PipelineState: 

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

1238 

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

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

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

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

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

1244 declared channel with the same name cannot shadow them. 

1245 """ 

1246 if working_set is None: 

1247 return state 

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

1249 for key in _ENGINE_LOCAL_ITERATION_KEYS: 

1250 if key in state: 

1251 resolved[key] = state[key] 

1252 return resolved 

1253 

1254 def _run_body_step( 

1255 self, 

1256 step: dict, 

1257 state: PipelineState, 

1258 working_set: ChannelWorkingSet | None, 

1259 ) -> PipelineState: 

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

1261 

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

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

1264 """ 

1265 if working_set is None: 

1266 return self.run_step(step, state) 

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

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

1269 return result 

1270 

1271 def _stage_body_channel_writes( 

1272 self, 

1273 step: dict, 

1274 pre_state: PipelineState, 

1275 result_state: PipelineState, 

1276 working_set: ChannelWorkingSet, 

1277 ) -> None: 

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

1279 

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

1281 a step carrying an agent routes its declared writes. 

1282 """ 

1283 if not self._channel_names: 

1284 return 

1285 writes = _route_declared_channels( 

1286 step, 

1287 self._read_state(pre_state, working_set), 

1288 result_state, 

1289 self, 

1290 self._channel_names, 

1291 ) 

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

1293 working_set.stage(name, value) 

1294 

1295 def _resolve_step_runtime( 

1296 self, 

1297 step: dict, 

1298 state: dict, 

1299 ctx: dict, 

1300 *, 

1301 input_override: Any = _UNSET, 

1302 extra_params: dict | None = None, 

1303 ) -> dict: 

1304 """ 

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

1306 templaattien osalta state + run_context -kontekstia vasten. 

1307 

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

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

1310 """ 

1311 resolved: dict = dict(step) 

1312 

1313 if input_override is not _UNSET: 

1314 resolved["input"] = input_override 

1315 else: 

1316 input_spec = step.get("input") 

1317 if input_spec is not None: 

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

1319 

1320 params_spec = step.get("params") 

1321 if params_spec or extra_params: 

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

1323 if not isinstance(base, dict): 

1324 base = {} 

1325 if extra_params: 

1326 base = {**base, **extra_params} 

1327 resolved["params"] = base 

1328 

1329 return resolved 

1330 

1331 def _attach_communication_context( 

1332 self, resolved_step: dict, read_state: PipelineState 

1333 ) -> dict: 

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

1335 

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

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

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

1339 without a read declaration is returned unchanged. 

1340 """ 

1341 communication = resolve_communication_context(resolved_step, read_state) 

1342 if not communication: 

1343 return resolved_step 

1344 return {**resolved_step, COMMUNICATION_CONTEXT_KEY: communication} 

1345 

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

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

1348 

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

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

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

1352 is returned unchanged. 

1353 """ 

1354 entries = resolve_usage_instructions( 

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

1356 ) 

1357 if not entries: 

1358 return resolved_step 

1359 return {**resolved_step, USAGE_INSTRUCTIONS_KEY: entries} 

1360 

1361 @staticmethod 

1362 def _dummy_invoke( 

1363 resolved_spec: dict, step: dict, state: PipelineState 

1364 ) -> PipelineState: 

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

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

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

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

1369 

1370 @staticmethod 

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

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

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

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

1375 if not additions: 

1376 return base 

1377 merged = list(base) 

1378 for err in additions: 

1379 if err not in merged: 

1380 merged.append(err) 

1381 return merged 

1382 

1383 @staticmethod 

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

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

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

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

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

1389 def invoke_with_gate( 

1390 resolved_spec: dict, step: dict, state: PipelineState 

1391 ) -> PipelineState: 

1392 result = invoke_fn(resolved_spec, step, state) 

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

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

1395 return result 

1396 return invoke_with_gate 

1397 

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

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

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

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

1402 def invoke_with_calls( 

1403 resolved_spec: dict, step: dict, state: PipelineState 

1404 ) -> PipelineState: 

1405 result = invoke_fn(resolved_spec, step, state) 

1406 self._execute_requested_calls(step, result, state) 

1407 return result 

1408 return invoke_with_calls 

1409 

1410 def _execute_requested_calls( 

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

1412 ) -> None: 

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

1414 

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

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

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

1418 """ 

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

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

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

1422 if not calls: 

1423 return 

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

1425 for index, call in enumerate(calls): 

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

1427 

1428 def _execute_requested_call( 

1429 self, 

1430 step_id: str, 

1431 allow: list, 

1432 index: int, 

1433 call: Any, 

1434 state: PipelineState, 

1435 ) -> None: 

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

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

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

1439 raise RunHardFailure( 

1440 "CALL_NOT_ALLOWED", 

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

1442 "is not in its allow whitelist", 

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

1444 ) 

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

1446 self._validate_call_params(name, params, step_id) 

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

1448 try: 

1449 self._dispatch_call_step(name, call_step, state) 

1450 except RunHardFailure as exc: 

1451 if exc.code == "BUILTIN_NAME_COLLISION": 

1452 raise 

1453 raise RunHardFailure( 

1454 "CALL_FAILED", 

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

1456 { 

1457 "step_id": step_id, 

1458 "requested_step": name, 

1459 "error_code": exc.code, 

1460 }, 

1461 ) from exc 

1462 except Exception as exc: # noqa: BLE001 

1463 raise RunHardFailure( 

1464 "CALL_FAILED", 

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

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

1467 ) from exc 

1468 

1469 @staticmethod 

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

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

1472 params = call.get("params") 

1473 if params is None: 

1474 return {} 

1475 if not isinstance(params, dict): 

1476 raise RunHardFailure( 

1477 "CALL_INVALID_PARAMS", 

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

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

1480 ) 

1481 return params 

1482 

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

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

1485 

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

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

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

1489 koskee vain calls-polkua. 

1490 """ 

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

1492 return 

1493 declared = self._step_functions.params_for(name) 

1494 if declared is None: 

1495 return 

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

1497 missing = [ 

1498 entry["name"] 

1499 for entry in declared 

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

1501 ] 

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

1503 if not missing and not unknown: 

1504 return 

1505 raise RunHardFailure( 

1506 "CALL_INVALID_PARAMS", 

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

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

1509 { 

1510 "step_id": step_id, 

1511 "requested_step": name, 

1512 "missing": missing, 

1513 "unknown": unknown, 

1514 }, 

1515 ) 

1516 

1517 @staticmethod 

1518 def _synthesize_call_step( 

1519 parent_step_id: str, 

1520 index: int, 

1521 name: str, 

1522 params: dict, 

1523 state: PipelineState, 

1524 ) -> dict: 

1525 """Rakentaa deterministisen synteettisen stepin yhdelle kutsulle. 

1526 

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

1528 discriminator is included when present so concurrent foreach/parallel 

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

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

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

1532 """ 

1533 discriminator = _iteration_discriminator(state) 

1534 token = uuid.uuid4().hex 

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

1536 return { 

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

1538 "type": "builtin", 

1539 "component": name, 

1540 "params": params, 

1541 } 

1542 

1543 def _dispatch_call_step( 

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

1545 ) -> None: 

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

1547 

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

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

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

1551 """ 

1552 is_builtin = self._builtins.has(name) 

1553 is_step_function = ( 

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

1555 ) 

1556 if is_builtin and is_step_function: 

1557 raise RunHardFailure( 

1558 "BUILTIN_NAME_COLLISION", 

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

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

1561 {"component": name}, 

1562 ) 

1563 if is_step_function: 

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

1565 return 

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

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

1568 

1569 @staticmethod 

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

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

1572 (TRUNCATION_ERROR, TOOL_FAILURE, AGENT_ERROR) tai VALIDATION_ERROR 

1573 kantaa kontekstiylityksen signatuurin (assembled_tokens + 

1574 context_budget — _llm._context_overflow_result). 

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

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

1577 code = details.get("error_code") 

1578 if code in RunHardFailure.HARD_ERROR_CODES: 

1579 return True 

1580 if code == "VALIDATION_ERROR": 

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

1582 return False 

1583 

1584 @staticmethod 

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

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

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

1588 kulkeutuu poikkeuksen mukana.""" 

1589 if not isinstance(step_result, dict): 

1590 return 

1591 if is_error_envelope(step_result): 

1592 StepRunner._raise_on_error_envelope(step_result, step_id) 

1593 return 

1594 details = step_result.get("details") 

1595 if not isinstance(details, dict): 

1596 return 

1597 if not StepRunner._is_hard_fail(details): 

1598 return 

1599 code = details.get("error_code") 

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

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

1602 raise RunHardFailure(code, message, details) 

1603 

1604 @staticmethod 

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

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

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

1608 envelope_error = step_result.get("error") 

1609 if isinstance(envelope_error, dict): 

1610 details = envelope_error 

1611 message = envelope_error.get("message") 

1612 else: 

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

1614 message = None 

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

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

1617 raise RunHardFailure(ENVELOPE_ERROR_CODE, message, details) 

1618 

1619 @staticmethod 

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

1621 """ 

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

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

1624 epäonnistuneeksi eikä hiljennä virhettä. 

1625 

1626 Kaikki virhetyypit propagoidaan: 

1627 - budget:max_llm_calls / budget:run_timeout → budjettiloki 

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

1629 """ 

1630 step_result = result.get(step_id) 

1631 if not isinstance(step_result, dict): 

1632 return result 

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

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

1635 return result 

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

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

1638 if message not in errors: 

1639 errors.append(message) 

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

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

1642 else: 

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

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

1645 

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

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

1648 agent_name = step.get("agent") 

1649 if not agent_name: 

1650 return None 

1651 return self._resolver.resolve(agent_name) 

1652 

1653 def run_step( 

1654 self, 

1655 step: dict, 

1656 state: PipelineState, 

1657 working_set: ChannelWorkingSet | None = None, 

1658 ) -> PipelineState: 

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

1660 read_state = self._read_state(state, working_set) 

1661 ctx = self._make_ctx(read_state) 

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

1663 

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

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

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

1667 cond = step.get("condition") 

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

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

1670 return state 

1671 

1672 if step_type == "builtin": 

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

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

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

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

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

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

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

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

1681 is_builtin = self._builtins.has(component) 

1682 is_step_function = ( 

1683 self._step_functions is not None 

1684 and self._step_functions.has(component) 

1685 ) 

1686 if is_builtin and is_step_function: 

1687 raise RunHardFailure( 

1688 "BUILTIN_NAME_COLLISION", 

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

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

1691 "fail-fast)", 

1692 {"component": component}, 

1693 ) 

1694 if is_step_function: 

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

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

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

1698 return builtin_state 

1699 

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

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

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

1703 if ( 

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

1705 and self._should_skip_llm(step, ctx) 

1706 ): 

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

1708 

1709 if step_type == "parallel": 

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

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

1712 if items is not None: 

1713 return self._run_parallel_fanout( 

1714 step, state, items, ctx, working_set 

1715 ) 

1716 # Fallback: single agent call 

1717 agent_name = step.get("agent") 

1718 if not agent_name: 

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

1720 return state 

1721 resolved = self._resolver.resolve(agent_name) 

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

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

1724 resolved_step = self._attach_communication_context( 

1725 resolved_step, read_state 

1726 ) 

1727 resolved_step = self._attach_usage_instructions(resolved_step) 

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

1729 return self._propagate_step_errors(result, step_id) 

1730 

1731 if step_type == "sequential": 

1732 agent_name = step.get("agent") 

1733 if not agent_name: 

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

1735 return state 

1736 resolved = self._resolver.resolve(agent_name) 

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

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

1739 resolved_step = self._attach_communication_context( 

1740 resolved_step, read_state 

1741 ) 

1742 resolved_step = self._attach_usage_instructions(resolved_step) 

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

1744 return self._propagate_step_errors(result, step_id) 

1745 

1746 if step_type == "foreach": 

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

1748 

1749 if step_type == "while": 

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

1751 

1752 if step_type == "call": 

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

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

1755 raise ValueError( 

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

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

1758 ) 

1759 

1760 if step_type == "spawn": 

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

1762 

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

1764 

1765 def _run_parallel_fanout( 

1766 self, 

1767 step: dict, 

1768 state: PipelineState, 

1769 items: list, 

1770 ctx: dict, 

1771 working_set: ChannelWorkingSet | None = None, 

1772 ) -> PipelineState: 

1773 """ 

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

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

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

1777 """ 

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

1779 agent_name = step.get("agent") 

1780 if not agent_name: 

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

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

1783 

1784 resolved = self._resolver.resolve(agent_name) 

1785 

1786 # Apply max_items cap 

1787 max_items_raw = step.get("max_items") 

1788 if max_items_raw is not None: 

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

1790 items = items[:max_items] 

1791 

1792 # Concurrency limit 

1793 max_conc_raw = step.get("max_concurrent") 

1794 if max_conc_raw is not None: 

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

1796 else: 

1797 max_conc = len(items) or 1 

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

1799 

1800 log.debug( 

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

1802 step_id, len(items), max_conc, 

1803 ) 

1804 

1805 read_state = self._read_state(state, working_set) 

1806 

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

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

1809 item_ctx = self._make_ctx(item_state) 

1810 resolved_step = self._resolve_step_runtime( 

1811 step, item_state, item_ctx, 

1812 input_override=item, 

1813 ) 

1814 resolved_step = self._attach_communication_context( 

1815 resolved_step, item_state 

1816 ) 

1817 resolved_step = self._attach_usage_instructions(resolved_step) 

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

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

1820 

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

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

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

1824 item_metrics: dict = {} 

1825 with ThreadPoolExecutor(max_workers=max_conc) as pool: 

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

1827 for future in as_completed(futures): 

1828 idx = futures[future] 

1829 try: 

1830 item_result, item_metrics_map = future.result() 

1831 results[idx] = item_result 

1832 if isinstance(item_metrics_map, dict): 

1833 item_metrics.update(item_metrics_map) 

1834 except SkillLoadError: 

1835 raise 

1836 except RunHardFailure: 

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

1838 raise 

1839 except Exception as exc: # noqa: BLE001 

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

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

1842 

1843 self._stage_fanout_channel_writes(step, results, working_set) 

1844 

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

1846 if item_metrics: 

1847 final = { 

1848 **final, 

1849 "step_context_metrics": _merge_metrics( 

1850 state.get("step_context_metrics"), item_metrics 

1851 ), 

1852 } 

1853 return final 

1854 

1855 def _stage_fanout_channel_writes( 

1856 self, 

1857 step: dict, 

1858 item_outputs: list, 

1859 working_set: ChannelWorkingSet | None, 

1860 ) -> None: 

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

1862 

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

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

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

1866 """ 

1867 if working_set is None or not self._channel_names: 

1868 return 

1869 for output in item_outputs: 

1870 writes = _declared_output_channel_writes( 

1871 step, output, self._channel_names 

1872 ) 

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

1874 working_set.stage(name, value) 

1875 

1876 def _run_foreach( 

1877 self, 

1878 step: dict, 

1879 state: PipelineState, 

1880 working_set: ChannelWorkingSet | None = None, 

1881 ) -> PipelineState: 

1882 """ 

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

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

1885 

1886 When max_concurrent > 1, body iterations run concurrently via 

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

1888 max_concurrent value sets the thread pool size (mirrors parallel 

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

1890 

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

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

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

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

1895 sequential early-exit guarantee cannot be reproduced without sacrificing 

1896 true concurrency. 

1897 

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

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

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

1901 """ 

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

1903 read_state = self._read_state(state, working_set) 

1904 ctx = self._make_ctx(read_state) 

1905 

1906 # Determine items 

1907 input_spec = step.get("input") 

1908 over_key = step.get("over") 

1909 

1910 if input_spec is not None: 

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

1912 if not isinstance(items, list): 

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

1914 elif over_key: 

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

1916 if not isinstance(items, list): 

1917 items = [items] 

1918 else: 

1919 items = [None] 

1920 

1921 # max_iterations cap 

1922 max_iter_raw = step.get("max_iterations") 

1923 if max_iter_raw is not None: 

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

1925 items = items[:max_iter] 

1926 

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

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

1929 max_output_raw = step.get("max_output") 

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

1931 

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

1933 max_conc_raw = step.get("max_concurrent") 

1934 if max_conc_raw is not None: 

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

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

1937 else: 

1938 max_conc = 1 

1939 

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

1941 agent_name = step.get("agent") 

1942 

1943 if max_conc <= 1: 

1944 return self._run_foreach_sequential( 

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

1946 working_set, 

1947 ) 

1948 return self._run_foreach_concurrent( 

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

1950 working_set, 

1951 ) 

1952 

1953 @staticmethod 

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

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

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

1957 if declared_output: 

1958 return iter_state.get(declared_output) 

1959 return iter_state.get("publish") 

1960 

1961 @staticmethod 

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

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

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

1965 if isinstance(item, dict): 

1966 verdict = item.get("verdict") 

1967 if verdict == "FAIL": 

1968 return False 

1969 if verdict == "PASS": 

1970 return True 

1971 passed = item.get("passed") 

1972 if isinstance(passed, bool): 

1973 return passed 

1974 return True 

1975 

1976 @staticmethod 

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

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

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

1980 return { 

1981 "results": item_results, 

1982 "passed": not failing, 

1983 "failing": failing, 

1984 "failed": bool(failing), 

1985 } 

1986 

1987 def _run_foreach_sequential( 

1988 self, 

1989 step_id: str, 

1990 step: dict, 

1991 state: PipelineState, 

1992 items: list, 

1993 body: list, 

1994 agent_name: str | None, 

1995 max_output: int, 

1996 working_set: ChannelWorkingSet | None = None, 

1997 ) -> PipelineState: 

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

1999 body_polku = bool(body) 

2000 declared_output = step.get("output") 

2001 results: list = [] 

2002 produced_outputs = 0 

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

2004 accumulated_metrics: dict = {} 

2005 

2006 for item in items: 

2007 if max_output and produced_outputs >= max_output: 

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

2009 break 

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

2011 

2012 if body_polku: 

2013 for sub_step in body: 

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

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

2016 accumulated_errors = self._merge_errors( 

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

2018 ) 

2019 publish_out = iter_state.get("publish") 

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

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

2022 produced_outputs += 1 

2023 elif agent_name: 

2024 resolved = self._resolver.resolve(agent_name) 

2025 iter_read_state = self._read_state(iter_state, working_set) 

2026 iter_ctx = self._make_ctx(iter_read_state) 

2027 resolved_step = self._resolve_step_runtime( 

2028 step, iter_read_state, iter_ctx 

2029 ) 

2030 resolved_step = self._attach_communication_context( 

2031 resolved_step, iter_read_state 

2032 ) 

2033 resolved_step = self._attach_usage_instructions(resolved_step) 

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

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

2036 item_metrics_map = iter_state.get("step_context_metrics") 

2037 if isinstance(item_metrics_map, dict): 

2038 accumulated_metrics.update(item_metrics_map) 

2039 

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

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

2042 if accumulated_metrics: 

2043 final = { 

2044 **final, 

2045 "step_context_metrics": _merge_metrics( 

2046 state.get("step_context_metrics"), accumulated_metrics 

2047 ), 

2048 } 

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

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

2051 return final 

2052 

2053 @staticmethod 

2054 def _merge_item_working_sets( 

2055 working_set: ChannelWorkingSet | None, 

2056 item_working_sets: list[ChannelWorkingSet | None], 

2057 ) -> None: 

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

2059 if working_set is None: 

2060 return 

2061 for item_working_set in item_working_sets: 

2062 if item_working_set is None: 

2063 continue 

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

2065 working_set.stage(name, value) 

2066 

2067 def _run_foreach_concurrent( 

2068 self, 

2069 step_id: str, 

2070 step: dict, 

2071 state: PipelineState, 

2072 items: list, 

2073 body: list, 

2074 agent_name: str | None, 

2075 max_output: int, 

2076 max_conc: int, 

2077 working_set: ChannelWorkingSet | None = None, 

2078 ) -> PipelineState: 

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

2080 log.info( 

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

2082 step_id, len(items), max_conc, 

2083 ) 

2084 

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

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

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

2088 body_polku = bool(body) 

2089 declared_output = step.get("output") 

2090 _SKIPPED = object() 

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

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

2093 lock = threading.Lock() 

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

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

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

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

2098 

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

2100 nonlocal item_errors 

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

2102 if max_output and stop_flag.is_set(): 

2103 return 

2104 

2105 item_working_set = ( 

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

2107 ) 

2108 item_working_sets[idx] = item_working_set 

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

2110 

2111 if body_polku: 

2112 for sub_step in body: 

2113 iter_state = self._run_body_step( 

2114 sub_step, iter_state, item_working_set 

2115 ) 

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

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

2118 if new_errs: 

2119 with lock: 

2120 item_errors = self._merge_errors(item_errors, new_errs) 

2121 publish_out = iter_state.get("publish") 

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

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

2124 with lock: 

2125 produced_outputs[0] += 1 

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

2127 stop_flag.set() 

2128 elif agent_name: 

2129 resolved = self._resolver.resolve(agent_name) 

2130 item_read_state = self._read_state(iter_state, item_working_set) 

2131 item_ctx = self._make_ctx(item_read_state) 

2132 resolved_step = self._resolve_step_runtime( 

2133 step, item_read_state, item_ctx 

2134 ) 

2135 resolved_step = self._attach_communication_context( 

2136 resolved_step, item_read_state 

2137 ) 

2138 resolved_step = self._attach_usage_instructions(resolved_step) 

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

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

2141 item_metrics_map = iter_state.get("step_context_metrics") 

2142 if isinstance(item_metrics_map, dict): 

2143 with lock: 

2144 accumulated_metrics.update(item_metrics_map) 

2145 

2146 with ThreadPoolExecutor(max_workers=max_conc) as pool: 

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

2148 for future in as_completed(futures): 

2149 try: 

2150 future.result() 

2151 except SkillLoadError: 

2152 raise 

2153 except RunHardFailure: 

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

2155 raise 

2156 except Exception as exc: # noqa: BLE001 

2157 idx = futures[future] 

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

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

2160 

2161 self._merge_item_working_sets(working_set, item_working_sets) 

2162 

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

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

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

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

2167 if accumulated_metrics: 

2168 final = { 

2169 **final, 

2170 "step_context_metrics": _merge_metrics( 

2171 state.get("step_context_metrics"), accumulated_metrics 

2172 ), 

2173 } 

2174 if item_errors: 

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

2176 return final 

2177 

2178 def _run_while( 

2179 self, 

2180 step: dict, 

2181 state: PipelineState, 

2182 working_set: ChannelWorkingSet | None = None, 

2183 ) -> PipelineState: 

2184 """ 

2185 While loop with exit_condition and optional entry condition. 

2186 

2187 Fields: 

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

2189 input: initial value for loop.current 

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

2191 max_iterations: int or template 

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

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

2194 body: list of sub-steps 

2195 

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

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

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

2199 

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

2201 condition evaluation so reads resolve over the committed snapshot plus 

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

2203 from the raw ``state``. 

2204 """ 

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

2206 read_state = self._read_state(state, working_set) 

2207 ctx = self._make_ctx(read_state) 

2208 

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

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

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

2212 entry_cond = step.get("condition") 

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

2214 input_spec = step.get("input") 

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

2216 return {**state, step_id: { 

2217 "passed": True, 

2218 "iterations": 0, 

2219 "output": skip_input, 

2220 "exhausted": False, 

2221 "skipped": True, 

2222 }} 

2223 

2224 # Initial current value from input 

2225 input_spec = step.get("input") 

2226 if input_spec is not None: 

2227 current = _resolve_input(input_spec, read_state, ctx) 

2228 else: 

2229 current = None 

2230 

2231 exit_condition = step.get("exit_condition") 

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

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

2234 declared_output = step.get("output") 

2235 

2236 passed = False 

2237 iteration = 0 

2238 loop_state: PipelineState = dict(state) 

2239 

2240 while iteration < max_iter: 

2241 # Inject loop context 

2242 loop_state = { 

2243 **state, 

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

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

2246 } 

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

2248 if "item" in state: 

2249 loop_state["item"] = state["item"] 

2250 

2251 # Run body steps 

2252 for sub_step in body: 

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

2254 

2255 iteration += 1 

2256 

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

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

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

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

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

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

2263 if declared_output: 

2264 feed_value = loop_state.get(declared_output) 

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

2266 current = feed_value["output"] 

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

2268 current = feed_value["results"] 

2269 else: 

2270 current = feed_value 

2271 

2272 # Check exit condition 

2273 if exit_condition: 

2274 exit_ctx = self._make_ctx( 

2275 self._read_state(loop_state, working_set) 

2276 ) 

2277 if _eval_condition(exit_condition, exit_ctx): 

2278 passed = True 

2279 break 

2280 else: 

2281 passed = True 

2282 break 

2283 

2284 skipped = False 

2285 exhausted = not passed 

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

2287 "passed": passed, 

2288 "iterations": iteration, 

2289 "output": current, 

2290 "exhausted": exhausted, 

2291 "skipped": skipped, 

2292 }} 

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

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

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

2296 # skipped or passed while must never raise. 

2297 fail_if_exhausted = step.get("fail_if_exhausted") 

2298 if ( 

2299 fail_if_exhausted is not None 

2300 and exhausted 

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

2302 ): 

2303 raise RunHardFailure( 

2304 "EXHAUSTED", 

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

2306 "without exit_condition passing (fail_if_exhausted)", 

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

2308 ) 

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

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

2311 if loop_errors: 

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

2313 return final 

2314 

2315 def _run_spawn( 

2316 self, 

2317 step: dict, 

2318 state: PipelineState, 

2319 working_set: ChannelWorkingSet | None = None, 

2320 ) -> PipelineState: 

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

2322 

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

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

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

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

2327 """ 

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

2329 read_state = self._read_state(state, working_set) 

2330 ctx = self._make_ctx(read_state) 

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

2332 

2333 max_depth_raw = policy.get("max_depth") 

2334 if max_depth_raw is None: 

2335 max_depth = _SPAWN_DEFAULT_MAX_DEPTH 

2336 else: 

2337 max_depth = _resolve_int(max_depth_raw, ctx, _SPAWN_DEFAULT_MAX_DEPTH) 

2338 

2339 max_spawn_raw = policy.get("max_spawn_per_node") 

2340 if max_spawn_raw is None: 

2341 max_spawn_per_node = _SPAWN_DEFAULT_MAX_SPAWN_PER_NODE 

2342 else: 

2343 max_spawn_per_node = _resolve_int(max_spawn_raw, ctx, _SPAWN_DEFAULT_MAX_SPAWN_PER_NODE) 

2344 

2345 max_calls_raw = policy.get("max_agent_calls") 

2346 if max_calls_raw is None: 

2347 budget = SpawnBudget(_SPAWN_DEFAULT_MAX_AGENT_CALLS) 

2348 else: 

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

2350 

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

2352 max_concurrent_raw = policy.get("max_concurrent_children") 

2353 if max_concurrent_raw is None: 

2354 max_concurrent_children = run_max_parallel 

2355 else: 

2356 max_concurrent_children = _resolve_int(max_concurrent_raw, ctx, run_max_parallel) 

2357 

2358 director = step.get("agent") 

2359 if not director: 

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

2361 return state 

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

2363 

2364 envelope, metrics = self._spawn_node( 

2365 node_id=step_id, 

2366 role=director, 

2367 depth=0, 

2368 step=step, 

2369 input_override=_UNSET, 

2370 base_state=read_state, 

2371 base_ctx=ctx, 

2372 child_role=child_role, 

2373 budget=budget, 

2374 max_depth=max_depth, 

2375 max_spawn_per_node=max_spawn_per_node, 

2376 max_concurrent_children=max_concurrent_children, 

2377 working_set=working_set, 

2378 ) 

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

2380 if metrics: 

2381 final = { 

2382 **final, 

2383 "step_context_metrics": _merge_metrics( 

2384 state.get("step_context_metrics"), metrics 

2385 ), 

2386 } 

2387 return final 

2388 

2389 def _spawn_node( 

2390 self, 

2391 *, 

2392 node_id: str, 

2393 role: str, 

2394 depth: int, 

2395 step: dict, 

2396 input_override: Any, 

2397 base_state: PipelineState, 

2398 base_ctx: dict, 

2399 child_role: str, 

2400 budget: SpawnBudget, 

2401 max_depth: int, 

2402 max_spawn_per_node: int | None, 

2403 max_concurrent_children: int, 

2404 working_set: ChannelWorkingSet | None, 

2405 ) -> tuple[dict, dict]: 

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

2407 tracer = self._tracer 

2408 parent_obs = ( 

2409 tracer.open_parent_observation( 

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

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

2412 ) 

2413 if tracer 

2414 else None 

2415 ) 

2416 try: 

2417 resolved_role = self._resolver.resolve(role) 

2418 resolved_step = self._resolve_step_runtime( 

2419 step, base_state, base_ctx, input_override=input_override, 

2420 ) 

2421 resolved_step = self._attach_communication_context( 

2422 resolved_step, base_state 

2423 ) 

2424 resolved_step = self._attach_usage_instructions(resolved_step) 

2425 if not budget.consume(): 

2426 raise RunHardFailure( 

2427 "SPAWN_BUDGET_EXHAUSTED", 

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

2429 {"node_id": node_id, "depth": depth}, 

2430 ) 

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

2432 this_result = result_state.get(node_id) 

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

2434 self._stage_spawn_channel_writes( 

2435 step, this_result, working_set 

2436 ) 

2437 

2438 children = self._spawn_children_from_result(this_result) 

2439 envelope: dict = { 

2440 "result": this_result, 

2441 "children": {}, 

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

2443 "failing": [], 

2444 "exhausted": False, 

2445 } 

2446 

2447 if not children: 

2448 return envelope, metrics 

2449 if depth + 1 > max_depth: 

2450 envelope["exhausted"] = True 

2451 return envelope, metrics 

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

2453 raise RunHardFailure( 

2454 "SPAWN_OVER_FLIGHT", 

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

2456 f"exceeds max_spawn_per_node={max_spawn_per_node}", 

2457 { 

2458 "node_id": node_id, 

2459 "depth": depth, 

2460 "children": len(children), 

2461 "max_spawn_per_node": max_spawn_per_node, 

2462 }, 

2463 ) 

2464 

2465 pool_size = self._spawn_pool_size(max_spawn_per_node, max_concurrent_children) 

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

2467 for idx, child in enumerate(children): 

2468 child_id, child_envelope, child_metrics = self._spawn_child( 

2469 node_id=node_id, 

2470 child_role=child_role, 

2471 depth=depth, 

2472 step=step, 

2473 base_state=base_state, 

2474 base_ctx=base_ctx, 

2475 budget=budget, 

2476 max_depth=max_depth, 

2477 max_spawn_per_node=max_spawn_per_node, 

2478 max_concurrent_children=max_concurrent_children, 

2479 working_set=working_set, 

2480 child=child, 

2481 idx=idx, 

2482 ) 

2483 metrics = _merge_metrics(metrics, child_metrics) 

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

2485 if not child_envelope["passed"]: 

2486 envelope["passed"] = False 

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

2488 return envelope, metrics 

2489 

2490 pool = ThreadPoolExecutor(max_workers=pool_size) 

2491 cancel = threading.Event() 

2492 futures = [] 

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

2494 for idx, child in enumerate(children): 

2495 child_working_set = ( 

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

2497 ) 

2498 ctx = contextvars.copy_context() 

2499 future = pool.submit( 

2500 ctx.run, 

2501 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=child_working_set, 

2513 child=child, 

2514 idx=idx, 

2515 ) 

2516 futures.append(future) 

2517 if child_working_set is not None: 

2518 child_working_sets[future] = child_working_set 

2519 hard_error: Exception | None = None 

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

2521 try: 

2522 for fut in as_completed(futures): 

2523 try: 

2524 child_results[fut] = fut.result() 

2525 except (RunHardFailure, SkillLoadError) as exc: 

2526 hard_error = exc 

2527 cancel.set() 

2528 for pending in futures: 

2529 pending.cancel() 

2530 break 

2531 if hard_error is None: 

2532 for fut in futures: 

2533 child_id, child_envelope, child_metrics = child_results[fut] 

2534 self._merge_spawn_fork_writes( 

2535 working_set, child_working_sets.get(fut) 

2536 ) 

2537 metrics = _merge_metrics(metrics, child_metrics) 

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

2539 if not child_envelope["passed"]: 

2540 envelope["passed"] = False 

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

2542 finally: 

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

2544 if hard_error is not None: 

2545 raise hard_error 

2546 

2547 return envelope, metrics 

2548 finally: 

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

2550 tracer.close_parent_observation(parent_obs) 

2551 

2552 def _stage_spawn_channel_writes( 

2553 self, 

2554 step: dict, 

2555 result: Any, 

2556 working_set: ChannelWorkingSet | None, 

2557 ) -> None: 

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

2559 if working_set is None: 

2560 return 

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

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

2563 working_set.stage(name, value) 

2564 

2565 @staticmethod 

2566 def _merge_spawn_fork_writes( 

2567 working_set: ChannelWorkingSet | None, 

2568 fork: ChannelWorkingSet | None, 

2569 ) -> None: 

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

2571 

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

2573 working set is never mutated from a worker thread. 

2574 """ 

2575 if working_set is None or fork is None: 

2576 return 

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

2578 working_set.stage(name, value) 

2579 

2580 @staticmethod 

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

2582 if max_spawn_per_node is None: 

2583 return max(1, max_concurrent_children) 

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

2585 

2586 def _spawn_child( 

2587 self, 

2588 *, 

2589 node_id: str, 

2590 child_role: str, 

2591 depth: int, 

2592 step: dict, 

2593 base_state: PipelineState, 

2594 base_ctx: dict, 

2595 budget: SpawnBudget, 

2596 max_depth: int, 

2597 max_spawn_per_node: int | None, 

2598 max_concurrent_children: int, 

2599 working_set: ChannelWorkingSet | None, 

2600 child: Any, 

2601 idx: int, 

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

2603 if not self._spawn_has_input(child): 

2604 raise RunHardFailure( 

2605 "INVALID_CHILD_PLAN", 

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

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

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

2609 ) 

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

2611 child_input = child["input"] 

2612 resolved_child_input = _resolve_input(child_input, base_state, base_ctx) 

2613 child_envelope, child_metrics = self._spawn_node( 

2614 node_id=child_id, 

2615 role=child_role, 

2616 depth=depth + 1, 

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

2618 input_override=resolved_child_input, 

2619 base_state=base_state, 

2620 base_ctx=base_ctx, 

2621 child_role=child_role, 

2622 budget=budget, 

2623 max_depth=max_depth, 

2624 max_spawn_per_node=max_spawn_per_node, 

2625 max_concurrent_children=max_concurrent_children, 

2626 working_set=working_set, 

2627 ) 

2628 return child_id, child_envelope, child_metrics 

2629 

2630 @staticmethod 

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

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

2633 if not isinstance(result, dict): 

2634 return [] 

2635 raw = result.get("children") 

2636 if raw is None: 

2637 return [] 

2638 if not isinstance(raw, list): 

2639 raise RunHardFailure( 

2640 "INVALID_CHILD_PLAN", 

2641 "spawn agent 'children' must be a list", 

2642 {"children": repr(raw)[:200]}, 

2643 ) 

2644 return raw 

2645 

2646 @staticmethod 

2647 def _spawn_has_input(child: Any) -> bool: 

2648 """A valid child plan item: a dict carrying a non-empty 'input'.""" 

2649 if not isinstance(child, dict): 

2650 return False 

2651 input_val = child.get("input") 

2652 if input_val is None: 

2653 return False 

2654 if isinstance(input_val, str) and not input_val.strip(): 

2655 return False 

2656 if isinstance(input_val, (list, dict)) and not input_val: 

2657 return False 

2658 return True 

2659 

2660 @staticmethod 

2661 def _spawn_is_soft_fail(result: Any) -> bool: 

2662 """A soft/gap agent outcome (escalate, parse_error, error, budget:*) — not a hard error. 

2663 

2664 An envelope-shaped result is never soft: ``status:error`` is a hard 

2665 failure (the gate raises before this), and ``ok``/``partial``/ 

2666 ``no_success`` are valid non-fatal results. 

2667 """ 

2668 if not isinstance(result, dict): 

2669 return False 

2670 if result.get("status") in ENVELOPE_STATUSES: 

2671 return False 

2672 if _SPAWN_SOFT_FAIL_KEYS & result.keys(): 

2673 return True 

2674 return any(key.startswith("budget:") for key in result) 

2675 

2676 

2677# --------------------------------------------------------------------------- 

2678# GraphBuilder 

2679# --------------------------------------------------------------------------- 

2680 

2681def _topological_order(steps: list[dict]) -> list[dict]: 

2682 """ 

2683 Palauttaa stepit topologisessa järjestyksessä depends_on-kenttien perusteella. 

2684 """ 

2685 id_to_step = {s["id"]: s for s in steps} 

2686 visited: set[str] = set() 

2687 order: list[dict] = [] 

2688 

2689 def visit(step_id: str) -> None: 

2690 if step_id in visited: 

2691 return 

2692 visited.add(step_id) 

2693 for dep in (id_to_step[step_id].get("depends_on") or []): 

2694 if dep in id_to_step: 

2695 visit(dep) 

2696 order.append(id_to_step[step_id]) 

2697 

2698 for s in steps: 

2699 visit(s["id"]) 

2700 return order 

2701 

2702 

2703# --------------------------------------------------------------------------- 

2704# Call-step build-time expansion (ADR-0025 D3/D4) 

2705# --------------------------------------------------------------------------- 

2706# 

2707# A `type: call` step is expanded at build time into the function's inlined body 

2708# steps plus two synthesized op steps (`__call_bind` / `__call_emit`). The 

2709# runtime executes only this flat graph; LangGraph never sees a call step. The 

2710# caller reads the function outputs via the call result shape 

2711# `state[<call_id>] = {outputs: {<name>: value}}` (00-process-contract r.70). 

2712# The function body's `{{ params.<name> }}` references are rewritten to 

2713# `{{ <call_id>.params.<name> }}` so the bind step's params namespace resolves. 

2714# 

2715# Isolation (ADR-0025 D5 / 00-functions § Isolation) and recursion (D7/D9): a 

2716# function body step must not reference a step-id outside its own body (a 

2717# one-way dependency check — outward references to caller step-ids are build 

2718# errors); a cycle in the function call graph (self or mutual) is a build 

2719# error. Bounded while loops are runtime constructs and are not cycle-flagged. 

2720 

2721# Call step result namespace keys (internal scaffolding). 

2722def _scope_params_value(value: Any, call_id: str) -> Any: 

2723 """Rewrite `params.` template references to the call-scoped namespace. 

2724 

2725 Operates on the function body's string values (templates). Dict keys are 

2726 untouched — only templated string VALUES are scoped, so a function body's 

2727 `{{ params.<name> }}` becomes `{{ <call_id>.params.<name> }}`. 

2728 """ 

2729 if isinstance(value, str): 

2730 return re.sub(r"\bparams\.", f"{call_id}.params.", value) 

2731 if isinstance(value, dict): 

2732 return {k: _scope_params_value(v, call_id) for k, v in value.items()} 

2733 if isinstance(value, list): 

2734 return [_scope_params_value(v, call_id) for v in value] 

2735 return value 

2736 

2737 

2738def _scope_params(step: dict, call_id: str) -> dict: 

2739 """Deep-scope a function body step's params references for a call id.""" 

2740 scoped: dict = dict(step) 

2741 for field in ("input", "params", "condition", "exit_condition", "agent", "function"): 

2742 if field in scoped and isinstance(scoped[field], (str, dict, list)): 

2743 scoped[field] = _scope_params_value(scoped[field], call_id) 

2744 if "body" in scoped and isinstance(scoped["body"], list): 

2745 scoped["body"] = [_scope_params(b, call_id) for b in scoped["body"]] 

2746 return scoped 

2747 

2748 

2749def _collect_step_ids(steps: list[dict]) -> set[str]: 

2750 """All step ids declared in *steps*, including nested body step ids.""" 

2751 ids: set[str] = set() 

2752 for step in steps: 

2753 step_id = step.get("id") 

2754 if isinstance(step_id, str): 

2755 ids.add(step_id) 

2756 body = step.get("body") 

2757 if isinstance(body, list): 

2758 ids |= _collect_step_ids(body) 

2759 return ids 

2760 

2761 

2762def _iter_steps(steps: list[dict]): 

2763 """Yield every step in *steps*, recursing into nested bodies.""" 

2764 for step in steps: 

2765 yield step 

2766 body = step.get("body") 

2767 if isinstance(body, list): 

2768 yield from _iter_steps(body) 

2769 

2770 

2771_TEMPLATE_REF_RE = re.compile(r"\{\{\s*([^}]*)\}\}") 

2772_STRING_LITERAL_RE = re.compile(r"'[^']*'|\"[^\"]*\"") 

2773_IDENTIFIER_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") 

2774 

2775_REFERENCE_FIELDS = ("input", "params", "condition", "exit_condition") 

2776 

2777# Engine-reserved template namespaces — never step-id references, even if a 

2778# caller happens to declare a step with one of these names. 

2779_RESERVED_NAMESPACES = frozenset({"params", "loop", "item", "_item", "budget", "config"}) 

2780 

2781 

2782def _referenced_names(value: Any) -> set[str]: 

2783 """Root identifiers read by templated strings. 

2784 

2785 Only the leading identifier of each reference (before the first ``.`` or 

2786 ``[``) can name a step-id; nested field segments are ignored. Quoted 

2787 literals are ignored. 

2788 """ 

2789 names: set[str] = set() 

2790 if isinstance(value, str): 

2791 for expr in _TEMPLATE_REF_RE.findall(value): 

2792 bare = _STRING_LITERAL_RE.sub("", expr) 

2793 for match in _IDENTIFIER_RE.finditer(bare): 

2794 if match.start() == 0 or bare[match.start() - 1] != ".": 

2795 names.add(match.group()) 

2796 elif isinstance(value, dict): 

2797 for v in value.values(): 

2798 names |= _referenced_names(v) 

2799 elif isinstance(value, list): 

2800 for v in value: 

2801 names |= _referenced_names(v) 

2802 return names 

2803 

2804 

2805def _validate_function_purity( 

2806 fn_name: str, body: list[dict], visible_ids: set[str] 

2807) -> None: 

2808 """One-way isolation: a function body must not reference a caller step-id. 

2809 

2810 A body step may read its own body step-ids and the params namespace; a 

2811 reference to a step-id that exists outside the body (``visible_ids`` but 

2812 not declared in the body) is an outward dependency and a hard build error. 

2813 """ 

2814 local_ids = _collect_step_ids(body) 

2815 for step in _iter_steps(body): 

2816 step_id = step.get("id", "?") 

2817 for dep in step.get("depends_on") or []: 

2818 if dep in visible_ids and dep not in local_ids: 

2819 raise ValueError( 

2820 f"function '{fn_name}' step '{step_id}': depends_on " 

2821 f"'{dep}' is outside the function body (isolation — " 

2822 "function bodies see only their own steps and params)" 

2823 ) 

2824 for field in _REFERENCE_FIELDS: 

2825 for ref in _referenced_names(step.get(field)): 

2826 if ref in _RESERVED_NAMESPACES: 

2827 continue 

2828 if ref in visible_ids and ref not in local_ids: 

2829 raise ValueError( 

2830 f"function '{fn_name}' step '{step_id}': '{field}' " 

2831 f"references '{ref}', a step-id outside the function " 

2832 "body (isolation — function bodies see only their own " 

2833 "steps and params)" 

2834 ) 

2835 

2836 

2837def _expand_call( 

2838 step: dict, 

2839 registry: ManifestRegistry, 

2840 stack: list[str], 

2841 call_map: dict[str, str], 

2842 expanded_functions: dict[str, str], 

2843 visible_ids: set[str], 

2844) -> list[dict]: 

2845 """Expand a call step into bind + inlined body + emit steps.""" 

2846 fn_name = step["function"] 

2847 call_id = step["id"] 

2848 

2849 if fn_name in stack: 

2850 chain = " -> ".join(stack + [fn_name]) 

2851 raise ValueError( 

2852 f"cycle detected in function expansion: {chain} " 

2853 "(ADR-0025 D7/D9 — recursion must be a bounded while loop or idempotent re-run)" 

2854 ) 

2855 

2856 # ADR-0025 D7: inlining the same function twice in one pipeline build 

2857 # collides its body step-ids (flat graph). This is a hard build error until 

2858 # id-prefixing lands; naming the function and both call sites pins the bug. 

2859 if fn_name in expanded_functions: 

2860 raise ValueError( 

2861 f"duplicate call expansion: function '{fn_name}' called at both " 

2862 f"'{expanded_functions[fn_name]}' and '{call_id}' in one pipeline — " 

2863 "inlined body step ids collide (ADR-0025 D7)" 

2864 ) 

2865 expanded_functions[fn_name] = call_id 

2866 

2867 function = registry.get("Function", fn_name) 

2868 if function is None: 

2869 raise ValueError( 

2870 f"call step '{call_id}' references unknown function '{fn_name}' " 

2871 "(no kind: Function manifest in the workflows tree)" 

2872 ) 

2873 

2874 body = (function.get("spec") or {}).get("steps") or [] 

2875 _validate_function_purity(fn_name, body, visible_ids) 

2876 

2877 sub_stack = list(stack) + [fn_name] 

2878 nested_visible = visible_ids | _collect_step_ids(body) 

2879 outputs_spec = ((function.get("spec") or {}).get("interface") or {}).get("outputs") or [] 

2880 

2881 bind_id = f"_call__{call_id}__bind" 

2882 emit_id = f"_call__{call_id}__emit" 

2883 call_map[call_id] = emit_id 

2884 

2885 bind_step: dict = { 

2886 "id": bind_id, 

2887 "type": "builtin", 

2888 "component": "__call_bind", 

2889 "params": {**(step.get("params") or {}), "__call_id": call_id}, 

2890 "depends_on": list(step.get("depends_on") or []), 

2891 } 

2892 

2893 expanded_body: list[dict] = [] 

2894 for body_step in body: 

2895 scoped = _scope_params(body_step, call_id) 

2896 for sub in _expand_step( 

2897 scoped, registry, sub_stack, call_map, expanded_functions, nested_visible 

2898 ): 

2899 dep = list(sub.get("depends_on") or []) 

2900 if bind_id not in dep: 

2901 sub["depends_on"] = [bind_id, *dep] 

2902 expanded_body.append(sub) 

2903 

2904 emit_step: dict = { 

2905 "id": emit_id, 

2906 "type": "builtin", 

2907 "component": "__call_emit", 

2908 "params": {"__call_id": call_id, "__outputs": outputs_spec}, 

2909 "depends_on": [bind_id] + [s["id"] for s in expanded_body], 

2910 } 

2911 

2912 return [bind_step, *expanded_body, emit_step] 

2913 

2914 

2915def _expand_step( 

2916 step: dict, 

2917 registry: ManifestRegistry, 

2918 stack: list[str], 

2919 call_map: dict[str, str], 

2920 expanded_functions: dict[str, str], 

2921 visible_ids: set[str], 

2922) -> list[dict]: 

2923 """Expand one step (recursing into while/foreach bodies).""" 

2924 if step.get("type") == "call": 

2925 return _expand_call( 

2926 step, registry, stack, call_map, expanded_functions, visible_ids 

2927 ) 

2928 if "body" in step and isinstance(step["body"], list): 

2929 expanded_body: list[dict] = [] 

2930 for sub in step["body"]: 

2931 expanded_body.extend( 

2932 _expand_step( 

2933 sub, registry, stack, call_map, expanded_functions, visible_ids 

2934 ) 

2935 ) 

2936 return [{**step, "body": expanded_body}] 

2937 return [step] 

2938 

2939 

2940def _rewrite_call_deps(steps: list[dict], call_map: dict[str, str]) -> None: 

2941 """Rewrite `depends_on` references to call ids → the corresponding emit id. 

2942 

2943 After a call is expanded away, a downstream step that depends_on the call id 

2944 must instead wait on the emit step (which writes state[<call_id>]). This 

2945 guarantees the caller's `{{ <call_id>.outputs.<name> }}` reads resolve after 

2946 the function body ran. 

2947 """ 

2948 for step in steps: 

2949 deps = step.get("depends_on") or [] 

2950 if deps: 

2951 step["depends_on"] = [call_map.get(d, d) for d in deps] 

2952 if "body" in step and isinstance(step["body"], list): 

2953 _rewrite_call_deps(step["body"], call_map) 

2954 

2955 

2956def expand_calls(steps: list[dict], registry: ManifestRegistry) -> list[dict]: 

2957 """Expand all `call` steps into the flat runtime graph (ADR-0025 D4). 

2958 

2959 Returns a new step list with each call replaced by its bind + inlined body + 

2960 emit steps; a call step never survives to the runtime graph. Cycles and 

2961 outward references in a function body are hard errors. 

2962 """ 

2963 call_map: dict[str, str] = {} 

2964 expanded_functions: dict[str, str] = {} 

2965 visible_ids = _collect_step_ids(steps) 

2966 expanded: list[dict] = [] 

2967 for step in steps: 

2968 expanded.extend( 

2969 _expand_step(step, registry, [], call_map, expanded_functions, visible_ids) 

2970 ) 

2971 _rewrite_call_deps(expanded, call_map) 

2972 return expanded 

2973 

2974 

2975_CHANNEL_TYPES = frozenset({"LastValue", "Topic", "BinaryOperatorAggregate"}) 

2976 

2977 

2978def _validate_channel_entry(index: int, channel: Any) -> None: 

2979 """Validate one spec.channels entry against the frozen ChannelSpec shape. 

2980 

2981 The engine build path does not run manifest-schema validation, so a 

2982 malformed entry is rejected here — before any ``["name"]`` indexing — with 

2983 an error that identifies the offending entry. 

2984 """ 

2985 if not isinstance(channel, dict): 

2986 raise ValueError( 

2987 f"Pipeline spec.channels[{index}] must be a mapping; got " 

2988 f"{type(channel).__name__}" 

2989 ) 

2990 name = channel.get("name") 

2991 if not isinstance(name, str) or not name: 

2992 raise ValueError( 

2993 f"Pipeline spec.channels[{index}] is missing a valid 'name'; each " 

2994 "channel requires a non-empty string 'name'" 

2995 ) 

2996 if "type" in channel and channel["type"] not in _CHANNEL_TYPES: 

2997 raise ValueError( 

2998 f"Pipeline spec.channels[{index}] ('{name}') has invalid type " 

2999 f"'{channel['type']}'; expected one of {sorted(_CHANNEL_TYPES)}" 

3000 ) 

3001 

3002 

3003def _validated_channels(raw_channels: Any) -> list[dict]: 

3004 """Return spec.channels as a validated list, or [] when absent. 

3005 

3006 ``None`` (absent) and an empty list are both valid no-channel pipelines. 

3007 """ 

3008 if raw_channels is None: 

3009 return [] 

3010 if not isinstance(raw_channels, list): 

3011 raise ValueError( 

3012 f"Pipeline spec.channels must be a list; got " 

3013 f"{type(raw_channels).__name__}" 

3014 ) 

3015 for index, channel in enumerate(raw_channels): 

3016 _validate_channel_entry(index, channel) 

3017 return raw_channels 

3018 

3019 

3020def _validate_step_channel_bindings( 

3021 steps: list[dict], channel_names: frozenset[str] 

3022) -> None: 

3023 """Reject malformed or undeclared step-level channel bindings at build time. 

3024 

3025 Every step's declared ``output_channel`` must be a non-empty declared 

3026 channel name, and every declared ``input_channels`` entry must be a 

3027 non-empty declared channel name. This runs unconditionally, so a binding 

3028 to a channel absent from ``spec.channels`` fails the build even when no 

3029 channels are declared. 

3030 """ 

3031 for step in _iter_steps(steps): 

3032 _validate_step_output_channel(step, channel_names) 

3033 _validate_step_input_channels(step, channel_names) 

3034 

3035 

3036def _validate_step_output_channel(step: dict, channel_names: frozenset[str]) -> None: 

3037 channel = step.get("output_channel") 

3038 if channel is None: 

3039 return 

3040 if not isinstance(channel, str) or not channel: 

3041 raise RunHardFailure( 

3042 "CHANNEL_BINDING_ERROR", 

3043 f"Step '{step.get('id', '?')}' output_channel must be a non-empty " 

3044 f"string; got {channel!r}", 

3045 ) 

3046 if channel not in channel_names: 

3047 raise RunHardFailure( 

3048 "CHANNEL_BINDING_ERROR", 

3049 f"Step '{step.get('id', '?')}' declares output_channel '{channel}' " 

3050 "which is not declared in spec.channels", 

3051 ) 

3052 

3053 

3054def _validate_step_input_channels(step: dict, channel_names: frozenset[str]) -> None: 

3055 channels = step.get("input_channels") 

3056 if channels is None: 

3057 return 

3058 if not isinstance(channels, list): 

3059 raise RunHardFailure( 

3060 "CHANNEL_BINDING_ERROR", 

3061 f"Step '{step.get('id', '?')}' input_channels must be a list of " 

3062 f"channel names; got {type(channels).__name__}", 

3063 ) 

3064 for channel in channels: 

3065 if not isinstance(channel, str) or not channel: 

3066 raise RunHardFailure( 

3067 "CHANNEL_BINDING_ERROR", 

3068 f"Step '{step.get('id', '?')}' input_channels has an invalid " 

3069 f"channel name '{channel}'; channel names must be non-empty " 

3070 "strings", 

3071 ) 

3072 if channel not in channel_names: 

3073 raise RunHardFailure( 

3074 "CHANNEL_BINDING_ERROR", 

3075 f"Step '{step.get('id', '?')}' reads undeclared channel " 

3076 f"'{channel}'; the channel must be declared in spec.channels", 

3077 ) 

3078 

3079 

3080def _validate_channel_names(channels: list[dict], steps: list[dict]) -> None: 

3081 """Reject duplicate and engine/step-reserved channel names.""" 

3082 forbidden = ( 

3083 {_CORE} 

3084 | set(_RESERVED_NAMESPACES) 

3085 | _CORE_STATE_KEYS 

3086 | _collect_step_ids(steps) 

3087 ) 

3088 seen: set[str] = set() 

3089 for channel in channels: 

3090 name = channel.get("name") 

3091 if name in seen: 

3092 raise ValueError( 

3093 f"Pipeline declares channel '{name}' more than once; channel " 

3094 "names must be unique" 

3095 ) 

3096 seen.add(name) 

3097 if name in forbidden: 

3098 raise ValueError( 

3099 f"Pipeline channel '{name}' collides with an engine-reserved " 

3100 "name (a step id, engine state key, reserved namespace, or the " 

3101 "core state channel); channel names must be unique and " 

3102 "non-reserved" 

3103 ) 

3104 

3105 

3106def _input_path_root(value: str) -> str | None: 

3107 """Leading state key of a bare dot-path input string, or None. 

3108 

3109 Template strings, bracket-prefixed verbatim literals, and blank strings 

3110 are not state reads and yield None. 

3111 """ 

3112 if "{{" in value: 

3113 return None 

3114 stripped = value.lstrip() 

3115 if not stripped or stripped.startswith("["): 

3116 return None 

3117 return re.split(r"[.\[]", stripped, maxsplit=1)[0] 

3118 

3119 

3120def _iter_input_roots(input_spec: Any): 

3121 """Yield every bare dot-path root in an input spec, recursing into containers.""" 

3122 if isinstance(input_spec, str): 

3123 root = _input_path_root(input_spec) 

3124 if root: 

3125 yield root 

3126 elif isinstance(input_spec, dict): 

3127 for value in input_spec.values(): 

3128 yield from _iter_input_roots(value) 

3129 elif isinstance(input_spec, list): 

3130 for value in input_spec: 

3131 yield from _iter_input_roots(value) 

3132 

3133 

3134def _validate_channel_inputs(channels: list[dict], steps: list[dict]) -> None: 

3135 """Reject channel-pipeline input roots that name nothing the state exposes. 

3136 

3137 The channel state is the declared channels plus the engine state, so a bare 

3138 dot-path input root must be a declared channel, a known step id, a reserved 

3139 namespace, or an engine state key. Anything else is a hard build error — 

3140 there is no silent fallback to a missing key. 

3141 """ 

3142 allowed = ( 

3143 {ch["name"] for ch in channels} 

3144 | _collect_step_ids(steps) 

3145 | set(_RESERVED_NAMESPACES) 

3146 | _CORE_STATE_KEYS 

3147 ) 

3148 for step in _iter_steps(steps): 

3149 for root in _iter_input_roots(step.get("input")): 

3150 if root not in allowed: 

3151 raise ValueError( 

3152 f"Pipeline channel step '{step.get('id', '?')}' input " 

3153 f"references unknown root '{root}'; in a channel pipeline " 

3154 "an input root must be a declared channel, a step id, a " 

3155 "reserved namespace, or an engine state key" 

3156 ) 

3157 

3158 

3159def _resolve_allow_instruction( 

3160 entry: str, builtins: BuiltinExecutor, step_functions: StepFunctionExecutor | None 

3161) -> str | None: 

3162 """Resolve one whitelist entry's usage instruction, builtin-first. 

3163 

3164 Mirrors the runtime dispatch order (ADR-0037 D2): a name in the builtin 

3165 register resolves to the Core-owned registry text, otherwise to the 

3166 StepFunction's ``agent_instructions``. 

3167 """ 

3168 return instruction_text(entry, builtins, step_functions) 

3169 

3170 

3171_AGENT_INVOKING_STEP_TYPES = frozenset({"sequential", "parallel", "spawn", "foreach"}) 

3172 

3173 

3174def _validate_allow_step_types(steps: list[dict]) -> None: 

3175 """Reject ``allow`` on a step that does not actually invoke an agent. 

3176 

3177 Only an agent-bearing step consumes its whitelist; on any other step the 

3178 declaration would be silently dropped — notably a ``call`` step, which 

3179 build-time expansion removes before the whitelist validator runs, and an 

3180 agent-less ``sequential``/``spawn``/``foreach``. A container step that 

3181 declares a ``body`` runs its body path, so a ``body`` wins over a declared 

3182 ``agent`` and the whitelist would be inert. Runs on the raw step list, 

3183 before call expansion. 

3184 """ 

3185 for step in _iter_steps(steps): 

3186 if not step.get("allow"): 

3187 continue 

3188 step_type = step.get("type", "sequential") 

3189 agent = step.get("agent") 

3190 invokes_agent = ( 

3191 step_type in _AGENT_INVOKING_STEP_TYPES 

3192 and agent 

3193 and not step.get("body") 

3194 ) 

3195 if invokes_agent: 

3196 continue 

3197 step_id = step.get("id", "?") 

3198 raise RunHardFailure( 

3199 "ALLOW_INVALID_STEP_TYPE", 

3200 f"Step '{step_id}' declares 'allow' but is not an agent-bearing " 

3201 f"step that invokes an agent: type '{step_type}' with agent " 

3202 f"'{agent}' does not actually invoke an agent", 

3203 {"step_id": step_id, "type": step_type, "agent": agent}, 

3204 ) 

3205 

3206 

3207def _validate_allow_instructions( 

3208 steps: list[dict], 

3209 builtins: BuiltinExecutor, 

3210 step_functions: StepFunctionExecutor | None, 

3211) -> None: 

3212 """Require a non-empty usage instruction for every ``allow`` entry. 

3213 

3214 Walks all steps — including nested loop/parallel bodies — and fails fast 

3215 when a whitelisted name resolves to no instruction: neither a whitelistable 

3216 builtin registry text nor a StepFunction ``agent_instructions``. 

3217 """ 

3218 for step in _iter_steps(steps): 

3219 for entry in step.get("allow") or []: 

3220 if _resolve_allow_instruction(entry, builtins, step_functions): 

3221 continue 

3222 step_id = step.get("id", "?") 

3223 raise RunHardFailure( 

3224 "AGENT_INSTRUCTION_MISSING", 

3225 f"Step '{step_id}' allow entry '{entry}' has no usage " 

3226 "instruction: it is neither a whitelistable builtin nor a " 

3227 "StepFunction with agent_instructions", 

3228 {"step_id": step_id, "allow": entry}, 

3229 ) 

3230 

3231 

3232def _flatten_channel_state(state: dict) -> dict: 

3233 """Read the flat committed snapshot nested under the Core namespace.""" 

3234 return dict(state.get(_CORE) or {}) 

3235 

3236 

3237def _strip_channel_keys(flat: dict, channel_names: frozenset[str]) -> dict: 

3238 """Drop channel-named keys so a node emits only the core state.""" 

3239 return {k: v for k, v in flat.items() if k not in channel_names} 

3240 

3241 

3242_FAILURE_ENVELOPE_KEYS = frozenset({"error", "parse_error"}) 

3243 

3244_NON_ERROR_ENVELOPE_STATUSES: frozenset[str] = ENVELOPE_STATUSES - {"error"} 

3245 

3246 

3247def _is_failure_envelope(value: Any) -> bool: 

3248 """True when a step output is not a successfully parsed dict. 

3249 

3250 Missing output, non-dict values, and the ``error``/``parse_error`` 

3251 envelopes written by the LLM failure paths are not routed. A valid 

3252 non-error envelope (``ok``/``partial``/``no_success``) is routed even when 

3253 it carries an explicit ``"error": null``. 

3254 """ 

3255 if not isinstance(value, dict): 

3256 return True 

3257 if value.get("status") in _NON_ERROR_ENVELOPE_STATUSES: 

3258 return False 

3259 return bool(_FAILURE_ENVELOPE_KEYS & value.keys()) 

3260 

3261 

3262def _declared_output_channel_writes( 

3263 step: dict, output: Any, channel_names: frozenset[str] 

3264) -> dict[str, Any]: 

3265 """Route one step output's content to its declared output channel. 

3266 

3267 Returns ``{channel_name: output["content"]}`` for the caller to stage into 

3268 the working set. Failure envelopes and steps without an ``output_channel`` 

3269 are not routed; an undeclared target channel is a hard error. 

3270 """ 

3271 if _is_failure_envelope(output): 

3272 return {} 

3273 channel = step.get("output_channel") 

3274 if not channel: 

3275 return {} 

3276 if channel not in channel_names: 

3277 raise RunHardFailure( 

3278 "CHANNEL_BINDING_ERROR", 

3279 f"Step '{step.get('id', '?')}' declares output_channel '{channel}' " 

3280 "which is not declared in spec.channels", 

3281 ) 

3282 return {channel: output.get("content")} 

3283 

3284 

3285def _route_declared_channels( 

3286 step: dict, 

3287 pre_state: dict, 

3288 core: dict, 

3289 step_runner: Any, 

3290 channel_names: frozenset[str], 

3291) -> dict[str, Any]: 

3292 """Route a step's declared output channel write to its channel. 

3293 

3294 Returns ``{channel_name: value}`` for LangGraph to merge through the 

3295 channel's reducer, where ``channel_name`` is the step's single declared 

3296 ``output_channel`` and ``value`` is the agent envelope's ``content``. Only 

3297 an actual agent invocation is routed: a ``skip_llm_if`` passthrough and a 

3298 parallel fan-out envelope are excluded. A step without an ``output_channel`` 

3299 routes nothing (its output stays in ``core`` untouched), and failure 

3300 envelopes are not routed. A spawn step is routed at the spawn seam from its 

3301 node result, never here from its envelope. 

3302 """ 

3303 step_id = step.get("id", "?") 

3304 if step.get("type") == "spawn": 

3305 return {} 

3306 if step_id not in core: 

3307 return {} 

3308 agent_spec = step_runner.resolve_agent(step) 

3309 if agent_spec is None: 

3310 return {} 

3311 if not step_runner.ran_agent(step, pre_state): 

3312 return {} 

3313 return _declared_output_channel_writes(step, core[step_id], channel_names) 

3314 

3315 

3316def _make_step_node( 

3317 step: dict, 

3318 step_runner: Any, 

3319 channel_names: frozenset[str], 

3320 channel_types: dict[str, str], 

3321) -> Any: 

3322 if not channel_names: 

3323 def node_fn(state: PipelineState) -> PipelineState: 

3324 return step_runner.run_step(step, state) 

3325 return node_fn 

3326 

3327 def node_fn(state: PipelineState) -> PipelineState: 

3328 committed = _flatten_channel_state(state) 

3329 working_set = ChannelWorkingSet(committed, channel_types) 

3330 core = _strip_channel_keys( 

3331 step_runner.run_step(step, committed, working_set), channel_names 

3332 ) 

3333 writes = _route_declared_channels( 

3334 step, committed, core, step_runner, channel_names 

3335 ) 

3336 for name, value in writes.items(): 

3337 working_set.stage(name, value) 

3338 channels = working_set.commit() 

3339 return {_CORE: {**core, **channels}} 

3340 return node_fn 

3341 

3342 

3343def _collect_parallel_channel_writes( 

3344 steps: list[dict], 

3345 pre_state: dict, 

3346 core: dict, 

3347 step_runner: Any, 

3348 channel_names: frozenset[str], 

3349 branch_working_sets: dict[str, ChannelWorkingSet] | None = None, 

3350) -> list[tuple[str, Any]]: 

3351 """Collect one parallel node's declared writes in branch order. 

3352 

3353 Every branch write is emitted, so fan-in feeds the channel-type reducer 

3354 every write in deterministic declaration/branch order: Topic appends each 

3355 write, LastValue keeps the last, and BinaryOperatorAggregate combines them 

3356 per its accumulated shape (list concat, collection-map union, dict merge, 

3357 scalar last). A branch's isolated body writes are emitted before its own 

3358 declared write, mirroring the top-level node's route-after-run order. 

3359 """ 

3360 writes: list[tuple[str, Any]] = [] 

3361 for step in steps: 

3362 if branch_working_sets is not None: 

3363 branch_working_set = branch_working_sets.get(step["id"]) 

3364 if branch_working_set is not None: 

3365 writes.extend(branch_working_set.pending_writes()) 

3366 branch = _route_declared_channels( 

3367 step, pre_state, core, step_runner, channel_names 

3368 ) 

3369 for channel_name, value in branch.items(): 

3370 writes.append((channel_name, value)) 

3371 return writes 

3372 

3373 

3374def _make_parallel_node( 

3375 group_steps: list[dict], 

3376 step_runner: Any, 

3377 channel_names: frozenset[str], 

3378 channel_types: dict[str, str], 

3379) -> Any: 

3380 def node_fn(state: PipelineState) -> PipelineState: 

3381 base = ( 

3382 _flatten_channel_state(state) 

3383 if channel_names else state 

3384 ) 

3385 working_set = ( 

3386 ChannelWorkingSet(base, channel_types) if channel_names else None 

3387 ) 

3388 # Each branch writes into its own fork; the shared set is advanced only 

3389 # at fan-in, so branch-internal body writes never race. 

3390 branch_working_sets = ( 

3391 {s["id"]: working_set.fork() for s in group_steps} 

3392 if working_set is not None else None 

3393 ) 

3394 

3395 def submit_branch(pool: Any, step: dict) -> Any: 

3396 if branch_working_sets is None: 

3397 return pool.submit(step_runner.run_step, step, base) 

3398 return pool.submit( 

3399 step_runner.run_step, step, base, branch_working_sets[step["id"]] 

3400 ) 

3401 

3402 results: dict[str, Any] = {} 

3403 with ThreadPoolExecutor(max_workers=len(group_steps)) as pool: 

3404 futures = {submit_branch(pool, s): s["id"] for s in group_steps} 

3405 for future in as_completed(futures): 

3406 sid = futures[future] 

3407 try: 

3408 out = future.result() 

3409 results[sid] = out.get(sid) 

3410 except SkillLoadError: 

3411 raise 

3412 except RunHardFailure: 

3413 pool.shutdown(wait=False, cancel_futures=True) 

3414 raise 

3415 except Exception as exc: # noqa: BLE001 

3416 log.error("Step '%s' failed: %s", sid, exc) 

3417 results[sid] = {"error": str(exc)} 

3418 if channel_names: 

3419 merged = _strip_channel_keys({**base, **results}, channel_names) 

3420 writes = _collect_parallel_channel_writes( 

3421 group_steps, base, merged, step_runner, channel_names, 

3422 branch_working_sets, 

3423 ) 

3424 for name, value in writes: 

3425 working_set.stage(name, value) 

3426 channels = working_set.commit() 

3427 return {_CORE: {**merged, **channels}} 

3428 return {**state, **results} 

3429 return node_fn 

3430 

3431 

3432class _ChannelGraphShim: 

3433 """Flat-dict boundary over a Core-owned channel-schema compiled graph. 

3434 

3435 Preserves the invoke contract used by main.py and tests: a flat dict in, 

3436 a flat dict out. The flat input is seeded through ``seed_committed`` and 

3437 nested under ``__core__`` for the compiled graph; the committed snapshot is 

3438 unwrapped back out. Streaming and state surfaces are not exposed because 

3439 they would leak or accept the internal ``__core__`` shape; read-only 

3440 introspection delegates to the compiled graph. 

3441 """ 

3442 

3443 def __init__(self, compiled: Any, specs: list[ChannelSpec]) -> None: 

3444 self._compiled = compiled 

3445 self._specs = specs 

3446 

3447 def invoke(self, flat_state: dict, *args: Any, **kwargs: Any) -> dict: 

3448 seeded = seed_committed(self._specs, flat_state) 

3449 result = self._compiled.invoke(wrap(seeded), *args, **kwargs) 

3450 return unwrap(result) 

3451 

3452 async def ainvoke(self, flat_state: dict, *args: Any, **kwargs: Any) -> dict: 

3453 seeded = seed_committed(self._specs, flat_state) 

3454 result = await self._compiled.ainvoke(wrap(seeded), *args, **kwargs) 

3455 return unwrap(result) 

3456 

3457 def stream(self, *args: Any, **kwargs: Any) -> Any: 

3458 self._reject_internal_shape("stream") 

3459 

3460 def astream(self, *args: Any, **kwargs: Any) -> Any: 

3461 self._reject_internal_shape("astream") 

3462 

3463 def update_state(self, *args: Any, **kwargs: Any) -> Any: 

3464 self._reject_internal_shape("update_state") 

3465 

3466 def aupdate_state(self, *args: Any, **kwargs: Any) -> Any: 

3467 self._reject_internal_shape("aupdate_state") 

3468 

3469 def get_state(self, *args: Any, **kwargs: Any) -> Any: 

3470 self._reject_internal_shape("get_state") 

3471 

3472 def aget_state(self, *args: Any, **kwargs: Any) -> Any: 

3473 self._reject_internal_shape("aget_state") 

3474 

3475 def _reject_internal_shape(self, surface: str) -> None: 

3476 raise NotImplementedError( 

3477 f"_ChannelGraphShim.{surface}() is not supported: only the flat " 

3478 ".invoke()/.ainvoke() contract is exposed through the channel " 

3479 "boundary shim" 

3480 ) 

3481 

3482 def __getattr__(self, name: str) -> Any: 

3483 return getattr(self._compiled, name) 

3484 

3485 

3486class GraphBuilder: 

3487 """ 

3488 Rakentaa LangGraph StateGraphin Pipeline-manifestista. 

3489 

3490 run_context: template-kontekstidata (budget, config jne.) — ei tallennu stateen, 

3491 käytetään vain {{ }} -templatejen evaluointiin. 

3492 """ 

3493 

3494 def __init__( 

3495 self, 

3496 registry: ManifestRegistry, 

3497 builtins: BuiltinExecutor | None = None, 

3498 invoke_agent_fn: Any | None = None, 

3499 run_context: dict | None = None, 

3500 tracer: Any | None = None, 

3501 ) -> None: 

3502 self._registry = registry 

3503 self._resolver = AgentResolver(registry) 

3504 self._invoke_agent_fn = invoke_agent_fn 

3505 self._builtins = builtins or _DEFAULT_BUILTINS 

3506 if invoke_agent_fn is not None: 

3507 self._builtins.invoke_agent_fn = invoke_agent_fn 

3508 self._step_functions = StepFunctionExecutor(registry) 

3509 self._step_runner = StepRunner( 

3510 self._resolver, self._builtins, invoke_agent_fn, run_context, 

3511 step_functions=self._step_functions, 

3512 tracer=tracer, 

3513 ) 

3514 

3515 def build(self, pipeline: dict): # -> CompiledGraph 

3516 """Rakentaa ja palauttaa compilatun LangGraph StateGraphin.""" 

3517 try: 

3518 from langgraph.graph import StateGraph, END 

3519 except ImportError as exc: 

3520 raise ImportError( 

3521 "langgraph is required to build a workflow. " 

3522 "Install it with: pip install langgraph" 

3523 ) from exc 

3524 

3525 steps: list[dict] = (pipeline.get("spec") or {}).get("steps") or [] 

3526 # Validate `allow` step types on the raw graph: call-expansion removes 

3527 # call steps (and their dropped `allow`) before the whitelist validator. 

3528 _validate_allow_step_types(steps) 

3529 # Build-time call-step expansion (ADR-0025 D4): the runtime executes only 

3530 # the flat expanded graph. LangGraph never sees a call step. 

3531 steps = expand_calls(steps, self._registry) 

3532 ordered = _topological_order(steps) 

3533 

3534 channels: list[dict] = _validated_channels( 

3535 (pipeline.get("spec") or {}).get("channels") 

3536 ) 

3537 if channels: 

3538 _validate_channel_names(channels, steps) 

3539 _validate_channel_inputs(channels, steps) 

3540 specs = parse_channel_specs( 

3541 [{"name": ch["name"], "type": ch.get("type", LAST_VALUE)} for ch in channels] 

3542 ) 

3543 channel_types = {spec.name: spec.type for spec in specs} 

3544 channel_names = frozenset(channel_types) 

3545 _validate_step_channel_bindings(steps, channel_names) 

3546 _validate_allow_instructions(steps, self._builtins, self._step_functions) 

3547 

3548 # Validate the pipeline's env map (spec.envs must be a map — ADR-0012). 

3549 envs = (pipeline.get("spec") or {}).get("envs") 

3550 if envs is not None and not isinstance(envs, dict): 

3551 raise ValueError( 

3552 f"Pipeline '{((pipeline.get('metadata') or {}).get('name') or '?')}': " 

3553 "spec.envs must be a map of env name → image ref (ADR-0012)" 

3554 ) 

3555 

3556 # Group steps into LangGraph nodes: 

3557 # Steps with the same depends_on set AND type==parallel → one node (LangGraph-level parallel) 

3558 nodes: list[list[dict]] = [] 

3559 for step in ordered: 

3560 dep_sig = frozenset(step.get("depends_on") or []) 

3561 if nodes: 

3562 last_group_sig = frozenset( 

3563 dep 

3564 for s in nodes[-1] 

3565 for dep in (s.get("depends_on") or []) 

3566 ) 

3567 last_types = {s.get("type") for s in nodes[-1]} 

3568 if ( 

3569 dep_sig == last_group_sig 

3570 and step.get("type") == "parallel" 

3571 and last_types == {"parallel"} 

3572 ): 

3573 nodes[-1].append(step) 

3574 continue 

3575 nodes.append([step]) 

3576 

3577 state_schema = _build_state_schema() if channels else PipelineState 

3578 graph: StateGraph = StateGraph(state_schema) 

3579 step_runner = self._step_runner 

3580 step_runner.set_channel_routing(channel_names) 

3581 

3582 prev_node_id: str | None = None 

3583 for group in nodes: 

3584 if len(group) == 1: 

3585 node_id = group[0]["id"] 

3586 else: 

3587 node_id = "parallel_" + "_".join(s["id"] for s in group) 

3588 

3589 captured_group = list(group) 

3590 

3591 if len(captured_group) == 1: 

3592 fn = _make_step_node( 

3593 captured_group[0], step_runner, channel_names, 

3594 channel_types, 

3595 ) 

3596 else: 

3597 fn = _make_parallel_node( 

3598 captured_group, 

3599 step_runner, 

3600 channel_names, 

3601 channel_types, 

3602 ) 

3603 

3604 graph.add_node(node_id, fn) 

3605 

3606 if prev_node_id is None: 

3607 graph.set_entry_point(node_id) 

3608 else: 

3609 graph.add_edge(prev_node_id, node_id) 

3610 

3611 prev_node_id = node_id 

3612 

3613 if prev_node_id: 

3614 graph.add_edge(prev_node_id, END) 

3615 else: 

3616 graph.add_node("noop", lambda s: s) 

3617 graph.set_entry_point("noop") 

3618 graph.add_edge("noop", END) 

3619 

3620 compiled = graph.compile() 

3621 if channel_names: 

3622 return _ChannelGraphShim(compiled, specs) 

3623 return compiled 

3624 

3625 

3626# --------------------------------------------------------------------------- 

3627# Julkinen API 

3628# --------------------------------------------------------------------------- 

3629 

3630_registry_cache: ManifestRegistry | None = None 

3631 

3632 

3633def _get_registry(dirs: list[Path] | None = None) -> ManifestRegistry: 

3634 global _registry_cache 

3635 if _registry_cache is None or dirs is not None: 

3636 loader = ManifestLoader(dirs) 

3637 manifests = loader.load_all() 

3638 _registry_cache = ManifestRegistry(manifests) 

3639 return _registry_cache 

3640 

3641 

3642def build_workflow( 

3643 pipeline_name: str, 

3644 *, 

3645 dirs: list[Path] | None = None, 

3646 builtins: BuiltinExecutor | None = None, 

3647 invoke_agent_fn: Any | None = None, 

3648 run_context: dict | None = None, 

3649 tracer: Any | None = None, 

3650): 

3651 """ 

3652 Lataa Pipeline-manifesti nimeltä *pipeline_name* ja rakentaa 

3653 LangGraph-workflown. 

3654 

3655 Args: 

3656 pipeline_name: Pipeline metadata.name -arvo. 

3657 dirs: Manifestihakemistot (oletus: MANIFEST_DIRS). 

3658 builtins: Oma BuiltinExecutor (oletus: _DEFAULT_BUILTINS). 

3659 invoke_agent_fn: fn(resolved_spec, step, state) → state. 

3660 run_context: Template-kontekstidata, esim. {"budget": {...}, "config": {...}}. 

3661 

3662 Returns: 

3663 Compilattu LangGraph CompiledGraph. 

3664 """ 

3665 registry = _get_registry(dirs) 

3666 pipeline = registry.get("Pipeline", pipeline_name) 

3667 if pipeline is None: 

3668 available = [ 

3669 (m.get("metadata") or {}).get("name") 

3670 for m in registry.all_of_kind("Pipeline") 

3671 ] 

3672 raise KeyError( 

3673 f"Pipeline '{pipeline_name}' not found. Available: {available}" 

3674 ) 

3675 builder = GraphBuilder(registry, builtins, invoke_agent_fn, run_context, tracer=tracer) 

3676 return builder.build(pipeline)