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

1304 statements  

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

20import os 

21import re 

22import threading 

23from concurrent.futures import ThreadPoolExecutor, as_completed 

24from pathlib import Path 

25from typing import Any 

26 

27import yaml 

28 

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

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

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

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

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

34try: 

35 from _llm import SkillLoadError # noqa: F401 

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

37 from langgraph._llm import SkillLoadError # noqa: F401 

38 

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

40try: 

41 from exec_runner import CommandExecutor # noqa: F401 

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

43 from langgraph.exec_runner import CommandExecutor # noqa: F401 

44 

45log = logging.getLogger(__name__) 

46 

47 

48class RunHardFailure(Exception): 

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

50 

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

52 (kuratoidun kontekstin katkaisu) tai 

53 kontekstiylityksen signatuurin kantava VALIDATION_ERROR 

54 (assembled_tokens + context_budget, _llm._context_overflow_result). 

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

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

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

58 """ 

59 

60 HARD_ERROR_CODES: frozenset[str] = frozenset({"TRUNCATION_ERROR"}) 

61 

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

63 super().__init__(message) 

64 self.code = code 

65 self.message = message 

66 self.details = details 

67 

68 

69class SpawnBudget: 

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

71 

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

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

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

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

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

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

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

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

80 """ 

81 

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

83 self._remaining: int | None = ( 

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

85 ) 

86 self._lock = threading.Lock() 

87 

88 def consume(self) -> bool: 

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

90 with self._lock: 

91 if self._remaining is None: 

92 return True 

93 if self._remaining <= 0: 

94 return False 

95 self._remaining -= 1 

96 return True 

97 

98 

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

100 

101_SPAWN_DEFAULT_MAX_AGENT_CALLS: int = 64 

102_SPAWN_DEFAULT_MAX_DEPTH: int = 1 

103_SPAWN_DEFAULT_MAX_SPAWN_PER_NODE: int = 16 

104 

105# --------------------------------------------------------------------------- 

106# Polut 

107# --------------------------------------------------------------------------- 

108 

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

110MANIFEST_DIRS = [ 

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

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

113] 

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

115 

116 

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

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

119 

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

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

122 """ 

123 return [ 

124 d for d in ( 

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

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

127 ) 

128 if d.is_dir() 

129 ] 

130 

131# --------------------------------------------------------------------------- 

132# Template-resoluutio 

133# --------------------------------------------------------------------------- 

134 

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

136 

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

138_UNRESOLVED = object() 

139 

140 

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

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

143 

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

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

146 """ 

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

148 base = m.group(1) 

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

150 return base, indices 

151 

152 

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

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

155 for idx in indices: 

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

157 return _UNRESOLVED 

158 obj = obj[idx] 

159 return obj 

160 

161 

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

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

164 

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

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

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

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

169 """ 

170 base, indices = _split_key_indices(part) 

171 if isinstance(obj, dict): 

172 if base: 

173 if base not in obj: 

174 return _UNRESOLVED 

175 obj = obj[base] 

176 return _apply_indices(obj, indices) 

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

178 if indices: 

179 return _apply_indices(obj, indices) 

180 if base and base.isdigit(): 

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

182 return _UNRESOLVED 

183 return _UNRESOLVED 

184 

185 

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

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

188 

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

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

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

192 """ 

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

194 obj = _dot_get_segment(obj, part) 

195 if obj is _UNRESOLVED: 

196 return None 

197 return obj 

198 

199 

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

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

202 

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

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

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

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

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

208 """ 

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

210 if obj is None: 

211 raise ValueError( 

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

213 ) 

214 obj = _dot_get_segment(obj, part) 

215 if obj is _UNRESOLVED: 

216 raise ValueError( 

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

218 ) 

219 return obj 

220 

221 

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

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

224 

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

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

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

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

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

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

231 """ 

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

233 if not out_spec: 

234 return None 

235 source = out_spec.get("source") 

236 if not source: 

237 return None 

238 return _dot_get(final_state, source) 

239 

240 

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

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

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

244 merged = dict(base or {}) 

245 merged.update(additions) 

246 return merged 

247 

248 

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

250_SINGLE_OPS = '+-<>' 

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

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

253 

254 

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

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

257 

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

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

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

261 """ 

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

263 i, n = 0, len(expr) 

264 while i < n: 

265 ch = expr[i] 

266 if ch.isspace(): 

267 i += 1 

268 continue 

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

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

271 i += 1 

272 continue 

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

274 two = expr[i:i + 2] 

275 if two in _MULTI_OPS: 

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

277 i += 2 

278 continue 

279 if ch in _SINGLE_OPS: 

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

281 i += 1 

282 continue 

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

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

285 j = i + 1 

286 buf: list[str] = [] 

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

288 buf.append(expr[j]) 

289 j += 1 

290 if j >= n: 

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

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

293 i = j + 1 

294 continue 

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

296 j = i 

297 if ch == '.': 

298 j += 1 

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

300 j += 1 

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

302 else: 

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

304 j += 1 

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

306 j += 1 

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

308 j += 1 

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

310 else: 

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

312 i = j 

313 continue 

314 j = i 

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

316 j += 1 

317 if j == i: 

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

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

320 i = j 

321 return tokens 

322 

323 

324# AST node shapes: 

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

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

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

328# ('neg', node) unary minus 

329# ('not', node) not X 

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

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

332 

333 

334class _ExprParser: 

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

336 

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

338 Grammar: 

339 expr := or_expr 

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

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

342 not_expr := 'not' not_expr | cmp_expr 

343 cmp_expr := add_expr (cmp_op add_expr)? 

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

345 unary_expr:= '-' unary_expr | primary 

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

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

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

349 unresolved read there never raises. 

350 """ 

351 

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

353 self._tokens = tokens 

354 self._i = 0 

355 

356 def parse(self) -> tuple: 

357 node = self._or_expr() 

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

359 tok = self._tokens[self._i] 

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

361 return node 

362 

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

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

365 

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

367 tok = self._tokens[self._i] 

368 self._i += 1 

369 return tok 

370 

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

372 tok = self._peek() 

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

374 

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

376 tok = self._peek() 

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

378 

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

380 tok = self._next() 

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

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

383 

384 def _or_expr(self) -> tuple: 

385 node = self._and_expr() 

386 while self._peek_name('or'): 

387 self._next() 

388 right = self._and_expr() 

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

390 return node 

391 

392 def _and_expr(self) -> tuple: 

393 node = self._not_expr() 

394 while self._peek_name('and'): 

395 self._next() 

396 right = self._not_expr() 

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

398 return node 

399 

400 def _not_expr(self) -> tuple: 

401 if self._peek_name('not'): 

402 self._next() 

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

404 return self._cmp_expr() 

405 

406 def _cmp_expr(self) -> tuple: 

407 node = self._add_expr() 

408 tok = self._peek() 

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

410 self._next() 

411 right = self._add_expr() 

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

413 return node 

414 

415 def _add_expr(self) -> tuple: 

416 node = self._unary_expr() 

417 while self._peek_op('+'): 

418 self._next() 

419 right = self._unary_expr() 

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

421 return node 

422 

423 def _unary_expr(self) -> tuple: 

424 if self._peek_op('-'): 

425 self._next() 

426 node = self._unary_expr() 

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

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

429 return ('neg', node) 

430 return self._primary() 

431 

432 def _primary(self) -> tuple: 

433 tok = self._peek() 

434 if tok is None: 

435 raise ValueError("Unexpected end of expression") 

436 kind, value = tok 

437 if kind == 'num': 

438 self._next() 

439 return ('lit', value) 

440 if kind == 'str': 

441 self._next() 

442 return ('lit', value) 

443 if kind == 'punct': 

444 if value == '(': 

445 self._next() 

446 node = self._or_expr() 

447 self._expect_punct(')') 

448 return node 

449 if value == '[': 

450 self._next() 

451 items: list[tuple] = [] 

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

453 while True: 

454 items.append(self._or_expr()) 

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

456 self._next() 

457 continue 

458 break 

459 self._expect_punct(']') 

460 return ('list', items) 

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

462 if kind == 'name': 

463 self._next() 

464 lowered = value.lower() 

465 if lowered == 'true': 

466 return ('lit', True) 

467 if lowered == 'false': 

468 return ('lit', False) 

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

470 return ('lit', None) 

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

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

473 return ('ref', value) 

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

475 

476 

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

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

479 

480 

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

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

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

484 return left + right 

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

486 return left + right 

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

488 return left + right 

489 raise ValueError( 

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

491 ) 

492 

493 

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

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

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

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

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

499 return node[1] 

500 return _eval_ast(node, ctx, strict) 

501 

502 

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

504 left = _eval_ast(left_node, ctx, strict) 

505 right = _eval_comparison_rhs(right_node, ctx, strict) 

506 try: 

507 if op == '==': 

508 return left == right 

509 if op == '!=': 

510 return left != right 

511 if op == '<=': 

512 return left <= right 

513 if op == '>=': 

514 return left >= right 

515 if op == '<': 

516 return left < right 

517 if op == '>': 

518 return left > right 

519 except TypeError: 

520 return False 

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

522 

523 

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

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

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

527 kind = node[0] 

528 if kind == 'lit': 

529 return node[1] 

530 if kind == 'ref': 

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

532 if kind == 'list': 

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

534 if kind == 'neg': 

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

536 if kind == 'not': 

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

538 if kind == 'cmp': 

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

540 if kind == 'binop': 

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

542 if op == 'and': 

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

544 return False 

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

546 if op == 'or': 

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

548 return True 

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

550 if op == '+': 

551 left = _eval_ast(left_node, ctx, strict) 

552 right = _eval_ast(right_node, ctx, strict) 

553 return _add_values(left, right) 

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

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

556 

557 

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

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

560 

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

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

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

564 side already determines the result. 

565 """ 

566 expr = expr.strip() 

567 tokens = _tokenize_expr(expr) 

568 if not tokens: 

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

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

571 

572 

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

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

575 if isinstance(value, str): 

576 stripped = value.strip() 

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

578 m = _TMPL_RE.fullmatch(stripped) 

579 if m: 

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

581 # Inline substitution → always returns string 

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

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

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

585 return _TMPL_RE.sub(_replace, value) 

586 if isinstance(value, dict): 

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

588 if isinstance(value, list): 

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

590 return value 

591 

592 

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

594 """ 

595 Resolve step input spec to a concrete value: 

596 "{{ item }}" → template 

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

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

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

600 None → None 

601 """ 

602 if input_spec is None: 

603 return None 

604 if isinstance(input_spec, str): 

605 if '{{' in input_spec: 

606 return _resolve_template(input_spec, ctx) 

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

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

609 # builtin verbatim so it resolves the placeholder itself. 

610 if input_spec.lstrip().startswith('['): 

611 return input_spec 

612 # Dot-path into state (no braces) 

613 return _dot_get(state, input_spec) 

614 if isinstance(input_spec, dict): 

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

616 if isinstance(input_spec, list): 

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

618 return input_spec 

619 

620 

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

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

623 

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

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

626 raises, never silently evaluates to falsy. 

627 """ 

628 if cond is None: 

629 return True 

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

631 if isinstance(resolved, bool): 

632 return resolved 

633 if isinstance(resolved, str): 

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

635 return bool(resolved) 

636 

637 

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

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

640 try: 

641 return int(v) 

642 except (TypeError, ValueError): 

643 return default 

644 

645 

646# --------------------------------------------------------------------------- 

647# ManifestLoader 

648# --------------------------------------------------------------------------- 

649 

650 

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

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

653 try: 

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

655 except OSError as exc: 

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

657 return [] 

658 

659 results: list[dict] = [] 

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

661 raw_clean = "\n".join( 

662 line for line in raw.splitlines() 

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

664 ) 

665 try: 

666 doc = yaml.safe_load(raw_clean) 

667 except yaml.YAMLError as exc: 

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

669 continue 

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

671 results.append(doc) 

672 return results 

673 

674 

675class ManifestLoader: 

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

677 

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

679 self._dirs = dirs or MANIFEST_DIRS 

680 

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

682 manifests: list[dict] = [] 

683 for d in self._dirs: 

684 if not d.exists(): 

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

686 continue 

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

688 docs = _parse_manifest_file(path) 

689 manifests.extend(docs) 

690 if docs: 

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

692 return manifests 

693 

694 

695# --------------------------------------------------------------------------- 

696# ManifestRegistry 

697# --------------------------------------------------------------------------- 

698 

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

700 

701 

702class ManifestRegistry: 

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

704 

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

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

707 for doc in manifests: 

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

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

710 if kind and name: 

711 key: ResourceKey = (kind, name) 

712 if key in self._store: 

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

714 self._store[key] = doc 

715 

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

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

718 

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

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

721 

722 

723# --------------------------------------------------------------------------- 

724# AgentResolver 

725# --------------------------------------------------------------------------- 

726 

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

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

729 result = dict(base) 

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

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

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

733 else: 

734 result[k] = v 

735 return result 

736 

737 

738class AgentResolver: 

739 """ 

740 Palauttaa fully-resolved agent spec:n instanssille. 

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

742 """ 

743 

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

745 self._registry = registry 

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

747 

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

749 if instance_name in self._cache: 

750 return self._cache[instance_name] 

751 

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

753 if instance is None: 

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

755 if agent is None: 

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

757 resolved = agent 

758 else: 

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

760 if based_on: 

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

762 if base_agent is None: 

763 raise KeyError( 

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

765 ) 

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

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

768 if k != "based_on"} 

769 merged_spec = _deep_merge(base_spec, inst_spec) 

770 else: 

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

772 if k != "based_on"} 

773 

774 resolved = { 

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

776 "kind": "Agent", 

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

778 "spec": merged_spec, 

779 } 

780 

781 self._cache[instance_name] = resolved 

782 return resolved 

783 

784 

785# --------------------------------------------------------------------------- 

786# State type 

787# --------------------------------------------------------------------------- 

788 

789PipelineState = dict[str, Any] 

790 

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

792_UNSET: Any = object() 

793 

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

795# BuiltinExecutor 

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

797 

798class BuiltinExecutor: 

799 """ 

800 Ajaa builtin-komponentit. 

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

802 Nimet normalisoidaan: viiva → alaviiva (esim. 'path-verifier' == 'path_verifier'). 

803 """ 

804 

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

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

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

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

809 

810 def __init__(self) -> None: 

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

812 "passthrough": self._passthrough, 

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

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

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

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

817 "__call_bind": self._call_bind, 

818 "__call_emit": self._call_emit, 

819 } 

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

821 # GraphBuilder. Profile builtins that make LLM decisions 

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

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

824 # executes (binding happens at build_workflow time). 

825 self.invoke_agent_fn: Any | None = None 

826 

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

828 self._handlers[name] = fn 

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

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

831 if normalised != name: 

832 self._handlers[normalised] = fn 

833 

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

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

836 if fn is not None: 

837 return fn 

838 import importlib 

839 for module_name in self._LAZY_MODULES: 

840 try: 

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

842 except ImportError: 

843 continue 

844 mod.register_all(self) 

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

846 if fn is None: 

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

848 return fn 

849 

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

851 fn = self.get(component) 

852 return fn(step, state) 

853 

854 # --- default builtins --- 

855 

856 @staticmethod 

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

858 return state 

859 

860 @staticmethod 

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

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

863 

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

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

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

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

868 """ 

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

870 if not call_id: 

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

872 bound = { 

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

874 } 

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

876 

877 @staticmethod 

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

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

880 

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

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

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

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

885 

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

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

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

889 """ 

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

891 call_id = params.get("__call_id") 

892 if not call_id: 

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

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

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

896 for spec in outputs_spec: 

897 if not isinstance(spec, dict): 

898 continue 

899 name = spec.get("name") 

900 source = spec.get("source") 

901 if name is None: 

902 continue 

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

904 if source is None: 

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

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

907 if optional: 

908 continue 

909 raise RunHardFailure( 

910 "UNRESOLVED_OUTPUT", 

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

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

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

914 ) 

915 try: 

916 value = _dot_get_strict(state, source) 

917 except ValueError: 

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

919 if optional: 

920 continue 

921 raise RunHardFailure( 

922 "UNRESOLVED_OUTPUT", 

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

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

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

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

927 ) from None 

928 outputs[name] = value 

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

930 

931 

932_DEFAULT_BUILTINS = BuiltinExecutor() 

933 

934 

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

936 """Rekisteröi v1-domain-builtinit (path-verifier, ...). 

937 

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

939 builtinit voivat olla pipeline-spesifisiäkin v1:ssä. 

940 """ 

941 try: 

942 from langgraph import _builtins # type: ignore 

943 except ImportError: 

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

945 import importlib.util 

946 spec = importlib.util.spec_from_file_location( 

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

948 ) 

949 if spec and spec.loader: 

950 _builtins = importlib.util.module_from_spec(spec) 

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

952 else: 

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

954 return 

955 _builtins.register_all(executor) 

956 _register_function_builtins(executor) 

957 

958 

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

960 """Rekisteröi function-engine builtinit (coverage-check, question-block-form). 

961 

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

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

964 """ 

965 try: 

966 from langgraph import _builtins_functions # type: ignore 

967 except ImportError: 

968 import importlib.util 

969 spec = importlib.util.spec_from_file_location( 

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

971 ) 

972 if spec and spec.loader: 

973 _builtins_functions = importlib.util.module_from_spec(spec) 

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

975 else: 

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

977 return 

978 _builtins_functions.register_all(executor) 

979 

980 

981_register_domain_builtins(_DEFAULT_BUILTINS) 

982 

983# --------------------------------------------------------------------------- 

984# StepRunner 

985# --------------------------------------------------------------------------- 

986 

987class StepRunner: 

988 """ 

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

990 

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

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

993 """ 

994 

995 def __init__( 

996 self, 

997 resolver: AgentResolver, 

998 builtins: BuiltinExecutor | None = None, 

999 invoke_agent_fn: Any | None = None, 

1000 run_context: dict | None = None, 

1001 *, 

1002 envs: dict | None = None, 

1003 command_executor: CommandExecutor | None = None, 

1004 tracer: Any | None = None, 

1005 ) -> None: 

1006 self._resolver = resolver 

1007 self._builtins = builtins or _DEFAULT_BUILTINS 

1008 self._invoke_agent = self._wrap_hard_failure_gate( 

1009 invoke_agent_fn or self._dummy_invoke 

1010 ) 

1011 self._run_context: dict = run_context or {} 

1012 self._envs: dict = envs or {} 

1013 self._command_executor = command_executor or CommandExecutor() 

1014 self._tracer = tracer 

1015 

1016 def set_envs(self, envs: dict) -> None: 

1017 """Bind the pipeline's spec.envs map (env names → image refs) for 

1018 command-step env validation. Called by GraphBuilder per pipeline.""" 

1019 self._envs = envs 

1020 

1021 @staticmethod 

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

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

1024 

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

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

1027 truthy expression skips the LLM. 

1028 """ 

1029 raw = step.get("skip_llm_if") 

1030 if raw is None: 

1031 return False 

1032 resolved = _resolve_template(raw, ctx) 

1033 if isinstance(resolved, bool): 

1034 return resolved 

1035 if isinstance(resolved, str): 

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

1037 return bool(resolved) 

1038 

1039 @staticmethod 

1040 def _skip_llm_passthrough(step: dict, state: PipelineState, ctx: dict) -> PipelineState: 

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

1042 

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

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

1045 """ 

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

1047 input_spec = step.get("input") 

1048 resolved = _resolve_input(input_spec, state, ctx) if input_spec is not None else None 

1049 if isinstance(resolved, list): 

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

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

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

1053 return {**state, step_id: resolved} 

1054 

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

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

1057 ctx: dict = {} 

1058 ctx.update(state) 

1059 ctx.update(self._run_context) 

1060 ctx.update(extras) 

1061 return ctx 

1062 

1063 def _resolve_step_runtime( 

1064 self, 

1065 step: dict, 

1066 state: dict, 

1067 ctx: dict, 

1068 *, 

1069 input_override: Any = _UNSET, 

1070 extra_params: dict | None = None, 

1071 ) -> dict: 

1072 """ 

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

1074 templaattien osalta state + run_context -kontekstia vasten. 

1075 

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

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

1078 """ 

1079 resolved: dict = dict(step) 

1080 

1081 if input_override is not _UNSET: 

1082 resolved["input"] = input_override 

1083 else: 

1084 input_spec = step.get("input") 

1085 if input_spec is not None: 

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

1087 

1088 params_spec = step.get("params") 

1089 if params_spec or extra_params: 

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

1091 if not isinstance(base, dict): 

1092 base = {} 

1093 if extra_params: 

1094 base = {**base, **extra_params} 

1095 resolved["params"] = base 

1096 

1097 return resolved 

1098 

1099 @staticmethod 

1100 def _dummy_invoke( 

1101 resolved_spec: dict, step: dict, state: PipelineState 

1102 ) -> PipelineState: 

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

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

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

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

1107 

1108 @staticmethod 

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

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

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

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

1113 if not additions: 

1114 return base 

1115 merged = list(base) 

1116 for err in additions: 

1117 if err not in merged: 

1118 merged.append(err) 

1119 return merged 

1120 

1121 @staticmethod 

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

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

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

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

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

1127 def invoke_with_gate( 

1128 resolved_spec: dict, step: dict, state: PipelineState 

1129 ) -> PipelineState: 

1130 result = invoke_fn(resolved_spec, step, state) 

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

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

1133 return result 

1134 return invoke_with_gate 

1135 

1136 @staticmethod 

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

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

1139 (TRUNCATION_ERROR) tai VALIDATION_ERROR kantaa kontekstiylityksen 

1140 signatuurin (assembled_tokens + context_budget — 

1141 _llm._context_overflow_result). Generic-VALIDATION_ERROR ei kaada 

1142 ajoa.""" 

1143 code = details.get("error_code") 

1144 if code in RunHardFailure.HARD_ERROR_CODES: 

1145 return True 

1146 if code == "VALIDATION_ERROR": 

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

1148 return False 

1149 

1150 @staticmethod 

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

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

1153 (_is_hard_fail); stepin details kulkeutuu poikkeuksen mukana.""" 

1154 if not isinstance(step_result, dict): 

1155 return 

1156 details = step_result.get("details") 

1157 if not isinstance(details, dict): 

1158 return 

1159 if not StepRunner._is_hard_fail(details): 

1160 return 

1161 code = details.get("error_code") 

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

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

1164 raise RunHardFailure(code, message, details) 

1165 

1166 @staticmethod 

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

1168 """ 

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

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

1171 epäonnistuneeksi eikä hiljennä virhettä. 

1172 

1173 Kaikki virhetyypit propagoidaan: 

1174 - budget:max_llm_calls / budget:run_timeout → budjettiloki 

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

1176 """ 

1177 step_result = result.get(step_id) 

1178 if not isinstance(step_result, dict): 

1179 return result 

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

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

1182 return result 

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

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

1185 if message not in errors: 

1186 errors.append(message) 

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

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

1189 else: 

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

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

1192 

1193 def run_step(self, step: dict, state: PipelineState) -> PipelineState: 

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

1195 ctx = self._make_ctx(state) 

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

1197 

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

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

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

1201 cond = step.get("condition") 

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

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

1204 return state 

1205 

1206 if step_type == "builtin": 

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

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

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

1210 # template-arvot (esim. "{{ budget.min_severity_for_ticket }}") 

1211 resolved_step = self._resolve_step_runtime(step, state, ctx) 

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

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

1214 return builtin_state 

1215 

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

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

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

1219 if ( 

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

1221 and self._should_skip_llm(step, ctx) 

1222 ): 

1223 return self._skip_llm_passthrough(step, state, ctx) 

1224 

1225 if step_type == "parallel": 

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

1227 input_spec = step.get("input") 

1228 if input_spec is not None: 

1229 input_val = _resolve_input(input_spec, state, ctx) 

1230 if isinstance(input_val, list): 

1231 return self._run_parallel_fanout(step, state, input_val, ctx) 

1232 # Fallback: single agent call 

1233 agent_name = step.get("agent") 

1234 if not agent_name: 

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

1236 return state 

1237 resolved = self._resolver.resolve(agent_name) 

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

1239 resolved_step = self._resolve_step_runtime(step, state, ctx) 

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

1241 return self._propagate_step_errors(result, step_id) 

1242 

1243 if step_type == "sequential": 

1244 agent_name = step.get("agent") 

1245 if not agent_name: 

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

1247 return state 

1248 resolved = self._resolver.resolve(agent_name) 

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

1250 resolved_step = self._resolve_step_runtime(step, state, ctx) 

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

1252 return self._propagate_step_errors(result, step_id) 

1253 

1254 if step_type == "foreach": 

1255 return self._run_foreach(step, state) 

1256 

1257 if step_type == "while": 

1258 return self._run_while(step, state) 

1259 

1260 if step_type == "command": 

1261 return self._run_command_step(step, state) 

1262 

1263 if step_type == "call": 

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

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

1266 raise ValueError( 

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

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

1269 ) 

1270 

1271 if step_type == "spawn": 

1272 return self._run_spawn(step, state) 

1273 

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

1275 

1276 def _run_parallel_fanout( 

1277 self, 

1278 step: dict, 

1279 state: PipelineState, 

1280 items: list, 

1281 ctx: dict, 

1282 ) -> PipelineState: 

1283 """ 

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

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

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

1287 """ 

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

1289 agent_name = step.get("agent") 

1290 if not agent_name: 

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

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

1293 

1294 resolved = self._resolver.resolve(agent_name) 

1295 

1296 # Apply max_items cap 

1297 max_items_raw = step.get("max_items") 

1298 if max_items_raw is not None: 

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

1300 items = items[:max_items] 

1301 

1302 # Concurrency limit 

1303 max_conc_raw = step.get("max_concurrent") 

1304 if max_conc_raw is not None: 

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

1306 else: 

1307 max_conc = len(items) or 1 

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

1309 

1310 log.debug( 

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

1312 step_id, len(items), max_conc, 

1313 ) 

1314 

1315 # Pre-fetch path verification map if present in state (from path-verifier builtin) 

1316 verify_by_path: dict = {} 

1317 verify_step = state.get("verify_paths") 

1318 if isinstance(verify_step, dict): 

1319 vbp = verify_step.get("by_path") 

1320 if isinstance(vbp, dict): 

1321 verify_by_path = vbp 

1322 

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

1324 item_state = {**state, "_item": item, "item": item} 

1325 item_ctx = self._make_ctx(item_state) 

1326 # Per-item path verification block injection (matches doc_files[*].path) 

1327 extra: dict = {} 

1328 if isinstance(item, dict) and verify_by_path: 

1329 p = item.get("path") 

1330 if p and p in verify_by_path: 

1331 extra["path_verification_block"] = verify_by_path[p] 

1332 resolved_step = self._resolve_step_runtime( 

1333 step, item_state, item_ctx, 

1334 input_override=item, 

1335 extra_params=extra or None, 

1336 ) 

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

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

1339 

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

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

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

1343 item_metrics: dict = {} 

1344 with ThreadPoolExecutor(max_workers=max_conc) as pool: 

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

1346 for future in as_completed(futures): 

1347 idx = futures[future] 

1348 try: 

1349 item_result, item_metrics_map = future.result() 

1350 results[idx] = item_result 

1351 if isinstance(item_metrics_map, dict): 

1352 item_metrics.update(item_metrics_map) 

1353 except SkillLoadError: 

1354 raise 

1355 except RunHardFailure: 

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

1357 raise 

1358 except Exception as exc: # noqa: BLE001 

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

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

1361 

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

1363 if item_metrics: 

1364 final = { 

1365 **final, 

1366 "step_context_metrics": _merge_metrics( 

1367 state.get("step_context_metrics"), item_metrics 

1368 ), 

1369 } 

1370 return final 

1371 

1372 def _run_foreach(self, step: dict, state: PipelineState) -> PipelineState: 

1373 """ 

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

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

1376 

1377 When max_concurrent > 1, body iterations run concurrently via 

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

1379 max_concurrent value sets the thread pool size (mirrors parallel 

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

1381 

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

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

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

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

1386 sequential early-exit guarantee cannot be reproduced without sacrificing 

1387 true concurrency. 

1388 """ 

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

1390 ctx = self._make_ctx(state) 

1391 

1392 # Determine items 

1393 input_spec = step.get("input") 

1394 over_key = step.get("over") 

1395 

1396 if input_spec is not None: 

1397 items: list = _resolve_input(input_spec, state, ctx) or [] 

1398 if not isinstance(items, list): 

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

1400 elif over_key: 

1401 items = state.get(over_key, [None]) 

1402 if not isinstance(items, list): 

1403 items = [items] 

1404 else: 

1405 items = [None] 

1406 

1407 # max_iterations cap 

1408 max_iter_raw = step.get("max_iterations") 

1409 if max_iter_raw is not None: 

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

1411 items = items[:max_iter] 

1412 

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

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

1415 max_output_raw = step.get("max_output") 

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

1417 

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

1419 max_conc_raw = step.get("max_concurrent") 

1420 if max_conc_raw is not None: 

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

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

1423 else: 

1424 max_conc = 1 

1425 

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

1427 agent_name = step.get("agent") 

1428 

1429 if max_conc <= 1: 

1430 return self._run_foreach_sequential( 

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

1432 ) 

1433 return self._run_foreach_concurrent( 

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

1435 ) 

1436 

1437 @staticmethod 

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

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

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

1441 if declared_output: 

1442 return iter_state.get(declared_output) 

1443 return iter_state.get("publish") 

1444 

1445 @staticmethod 

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

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

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

1449 if isinstance(item, dict): 

1450 verdict = item.get("verdict") 

1451 if verdict == "FAIL": 

1452 return False 

1453 if verdict == "PASS": 

1454 return True 

1455 passed = item.get("passed") 

1456 if isinstance(passed, bool): 

1457 return passed 

1458 return True 

1459 

1460 @staticmethod 

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

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

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

1464 return { 

1465 "results": item_results, 

1466 "passed": not failing, 

1467 "failing": failing, 

1468 "failed": bool(failing), 

1469 } 

1470 

1471 def _run_foreach_sequential( 

1472 self, 

1473 step_id: str, 

1474 step: dict, 

1475 state: PipelineState, 

1476 items: list, 

1477 body: list, 

1478 agent_name: str | None, 

1479 max_output: int, 

1480 ) -> PipelineState: 

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

1482 body_polku = bool(body) 

1483 declared_output = step.get("output") 

1484 results: list = [] 

1485 produced_outputs = 0 

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

1487 accumulated_metrics: dict = {} 

1488 

1489 for item in items: 

1490 if max_output and produced_outputs >= max_output: 

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

1492 break 

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

1494 

1495 if body_polku: 

1496 for sub_step in body: 

1497 iter_state = self.run_step(sub_step, iter_state) 

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

1499 accumulated_errors = self._merge_errors( 

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

1501 ) 

1502 publish_out = iter_state.get("publish") 

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

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

1505 produced_outputs += 1 

1506 elif agent_name: 

1507 resolved = self._resolver.resolve(agent_name) 

1508 iter_state = self._invoke_agent(resolved, step, iter_state) 

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

1510 item_metrics_map = iter_state.get("step_context_metrics") 

1511 if isinstance(item_metrics_map, dict): 

1512 accumulated_metrics.update(item_metrics_map) 

1513 

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

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

1516 if accumulated_metrics: 

1517 final = { 

1518 **final, 

1519 "step_context_metrics": _merge_metrics( 

1520 state.get("step_context_metrics"), accumulated_metrics 

1521 ), 

1522 } 

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

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

1525 return final 

1526 

1527 def _run_foreach_concurrent( 

1528 self, 

1529 step_id: str, 

1530 step: dict, 

1531 state: PipelineState, 

1532 items: list, 

1533 body: list, 

1534 agent_name: str | None, 

1535 max_output: int, 

1536 max_conc: int, 

1537 ) -> PipelineState: 

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

1539 log.info( 

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

1541 step_id, len(items), max_conc, 

1542 ) 

1543 

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

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

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

1547 body_polku = bool(body) 

1548 declared_output = step.get("output") 

1549 _SKIPPED = object() 

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

1551 lock = threading.Lock() 

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

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

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

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

1556 

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

1558 nonlocal item_errors 

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

1560 if max_output and stop_flag.is_set(): 

1561 return 

1562 

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

1564 

1565 if body_polku: 

1566 for sub_step in body: 

1567 iter_state = self.run_step(sub_step, iter_state) 

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

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

1570 if new_errs: 

1571 with lock: 

1572 item_errors = self._merge_errors(item_errors, new_errs) 

1573 publish_out = iter_state.get("publish") 

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

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

1576 with lock: 

1577 produced_outputs[0] += 1 

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

1579 stop_flag.set() 

1580 elif agent_name: 

1581 resolved = self._resolver.resolve(agent_name) 

1582 iter_state = self._invoke_agent(resolved, step, iter_state) 

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

1584 item_metrics_map = iter_state.get("step_context_metrics") 

1585 if isinstance(item_metrics_map, dict): 

1586 with lock: 

1587 accumulated_metrics.update(item_metrics_map) 

1588 

1589 with ThreadPoolExecutor(max_workers=max_conc) as pool: 

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

1591 for future in as_completed(futures): 

1592 try: 

1593 future.result() 

1594 except SkillLoadError: 

1595 raise 

1596 except RunHardFailure: 

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

1598 raise 

1599 except Exception as exc: # noqa: BLE001 

1600 idx = futures[future] 

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

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

1603 

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

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

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

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

1608 if accumulated_metrics: 

1609 final = { 

1610 **final, 

1611 "step_context_metrics": _merge_metrics( 

1612 state.get("step_context_metrics"), accumulated_metrics 

1613 ), 

1614 } 

1615 if item_errors: 

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

1617 return final 

1618 

1619 def _run_while(self, step: dict, state: PipelineState) -> PipelineState: 

1620 """ 

1621 While loop with exit_condition and optional entry condition. 

1622 

1623 Fields: 

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

1625 input: initial value for loop.current 

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

1627 max_iterations: int or template 

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

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

1630 body: list of sub-steps 

1631 

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

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

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

1635 """ 

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

1637 ctx = self._make_ctx(state) 

1638 

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

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

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

1642 entry_cond = step.get("condition") 

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

1644 input_spec = step.get("input") 

1645 skip_input = _resolve_input(input_spec, state, ctx) if input_spec is not None else None 

1646 return {**state, step_id: { 

1647 "passed": True, 

1648 "iterations": 0, 

1649 "output": skip_input, 

1650 "exhausted": False, 

1651 "skipped": True, 

1652 }} 

1653 

1654 # Initial current value from input 

1655 input_spec = step.get("input") 

1656 if input_spec is not None: 

1657 current = _resolve_input(input_spec, state, ctx) 

1658 else: 

1659 current = None 

1660 

1661 exit_condition = step.get("exit_condition") 

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

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

1664 declared_output = step.get("output") 

1665 

1666 passed = False 

1667 iteration = 0 

1668 loop_state: PipelineState = dict(state) 

1669 

1670 while iteration < max_iter: 

1671 # Inject loop context 

1672 loop_state = { 

1673 **state, 

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

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

1676 } 

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

1678 if "item" in state: 

1679 loop_state["item"] = state["item"] 

1680 

1681 # Run body steps 

1682 for sub_step in body: 

1683 loop_state = self.run_step(sub_step, loop_state) 

1684 

1685 iteration += 1 

1686 

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

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

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

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

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

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

1693 if declared_output: 

1694 feed_value = loop_state.get(declared_output) 

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

1696 current = feed_value["output"] 

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

1698 current = feed_value["results"] 

1699 else: 

1700 current = feed_value 

1701 

1702 # Check exit condition 

1703 if exit_condition: 

1704 exit_ctx = self._make_ctx(loop_state) 

1705 if _eval_condition(exit_condition, exit_ctx): 

1706 passed = True 

1707 break 

1708 else: 

1709 passed = True 

1710 break 

1711 

1712 skipped = False 

1713 exhausted = not passed 

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

1715 "passed": passed, 

1716 "iterations": iteration, 

1717 "output": current, 

1718 "exhausted": exhausted, 

1719 "skipped": skipped, 

1720 }} 

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

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

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

1724 # skipped or passed while must never raise. 

1725 fail_if_exhausted = step.get("fail_if_exhausted") 

1726 if ( 

1727 fail_if_exhausted is not None 

1728 and exhausted 

1729 and _eval_condition(fail_if_exhausted, self._make_ctx(state)) 

1730 ): 

1731 raise RunHardFailure( 

1732 "EXHAUSTED", 

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

1734 "without exit_condition passing (fail_if_exhausted)", 

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

1736 ) 

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

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

1739 if loop_errors: 

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

1741 return final 

1742 

1743 def _run_spawn(self, step: dict, state: PipelineState) -> PipelineState: 

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

1745 

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

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

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

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

1750 """ 

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

1752 ctx = self._make_ctx(state) 

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

1754 

1755 max_depth_raw = policy.get("max_depth") 

1756 if max_depth_raw is None: 

1757 max_depth = _SPAWN_DEFAULT_MAX_DEPTH 

1758 else: 

1759 max_depth = _resolve_int(max_depth_raw, ctx, _SPAWN_DEFAULT_MAX_DEPTH) 

1760 

1761 max_spawn_raw = policy.get("max_spawn_per_node") 

1762 if max_spawn_raw is None: 

1763 max_spawn_per_node = _SPAWN_DEFAULT_MAX_SPAWN_PER_NODE 

1764 else: 

1765 max_spawn_per_node = _resolve_int(max_spawn_raw, ctx, _SPAWN_DEFAULT_MAX_SPAWN_PER_NODE) 

1766 

1767 max_calls_raw = policy.get("max_agent_calls") 

1768 if max_calls_raw is None: 

1769 budget = SpawnBudget(_SPAWN_DEFAULT_MAX_AGENT_CALLS) 

1770 else: 

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

1772 

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

1774 max_concurrent_raw = policy.get("max_concurrent_children") 

1775 if max_concurrent_raw is None: 

1776 max_concurrent_children = run_max_parallel 

1777 else: 

1778 max_concurrent_children = _resolve_int(max_concurrent_raw, ctx, run_max_parallel) 

1779 

1780 director = step.get("agent") 

1781 if not director: 

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

1783 return state 

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

1785 

1786 envelope, metrics = self._spawn_node( 

1787 node_id=step_id, 

1788 role=director, 

1789 depth=0, 

1790 step=step, 

1791 input_override=_UNSET, 

1792 base_state=state, 

1793 base_ctx=ctx, 

1794 child_role=child_role, 

1795 budget=budget, 

1796 max_depth=max_depth, 

1797 max_spawn_per_node=max_spawn_per_node, 

1798 max_concurrent_children=max_concurrent_children, 

1799 ) 

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

1801 if metrics: 

1802 final = { 

1803 **final, 

1804 "step_context_metrics": _merge_metrics( 

1805 state.get("step_context_metrics"), metrics 

1806 ), 

1807 } 

1808 return final 

1809 

1810 def _spawn_node( 

1811 self, 

1812 *, 

1813 node_id: str, 

1814 role: str, 

1815 depth: int, 

1816 step: dict, 

1817 input_override: Any, 

1818 base_state: PipelineState, 

1819 base_ctx: dict, 

1820 child_role: str, 

1821 budget: SpawnBudget, 

1822 max_depth: int, 

1823 max_spawn_per_node: int | None, 

1824 max_concurrent_children: int, 

1825 ) -> tuple[dict, dict]: 

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

1827 tracer = self._tracer 

1828 parent_obs = ( 

1829 tracer.open_parent_observation( 

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

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

1832 ) 

1833 if tracer 

1834 else None 

1835 ) 

1836 try: 

1837 resolved_role = self._resolver.resolve(role) 

1838 resolved_step = self._resolve_step_runtime( 

1839 step, base_state, base_ctx, input_override=input_override, 

1840 ) 

1841 if not budget.consume(): 

1842 raise RunHardFailure( 

1843 "SPAWN_BUDGET_EXHAUSTED", 

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

1845 {"node_id": node_id, "depth": depth}, 

1846 ) 

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

1848 this_result = result_state.get(node_id) 

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

1850 

1851 children = self._spawn_children_from_result(this_result) 

1852 envelope: dict = { 

1853 "result": this_result, 

1854 "children": {}, 

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

1856 "failing": [], 

1857 "exhausted": False, 

1858 } 

1859 

1860 if not children: 

1861 return envelope, metrics 

1862 if depth + 1 > max_depth: 

1863 envelope["exhausted"] = True 

1864 return envelope, metrics 

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

1866 raise RunHardFailure( 

1867 "SPAWN_OVER_FLIGHT", 

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

1869 f"exceeds max_spawn_per_node={max_spawn_per_node}", 

1870 { 

1871 "node_id": node_id, 

1872 "depth": depth, 

1873 "children": len(children), 

1874 "max_spawn_per_node": max_spawn_per_node, 

1875 }, 

1876 ) 

1877 

1878 pool_size = self._spawn_pool_size(max_spawn_per_node, max_concurrent_children) 

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

1880 for idx, child in enumerate(children): 

1881 child_id, child_envelope, child_metrics = self._spawn_child( 

1882 node_id=node_id, 

1883 child_role=child_role, 

1884 depth=depth, 

1885 step=step, 

1886 base_state=base_state, 

1887 base_ctx=base_ctx, 

1888 budget=budget, 

1889 max_depth=max_depth, 

1890 max_spawn_per_node=max_spawn_per_node, 

1891 max_concurrent_children=max_concurrent_children, 

1892 child=child, 

1893 idx=idx, 

1894 ) 

1895 metrics = _merge_metrics(metrics, child_metrics) 

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

1897 if not child_envelope["passed"]: 

1898 envelope["passed"] = False 

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

1900 return envelope, metrics 

1901 

1902 pool = ThreadPoolExecutor(max_workers=pool_size) 

1903 cancel = threading.Event() 

1904 futures = [] 

1905 for idx, child in enumerate(children): 

1906 ctx = contextvars.copy_context() 

1907 futures.append( 

1908 pool.submit( 

1909 ctx.run, 

1910 self._spawn_child, 

1911 node_id=node_id, 

1912 child_role=child_role, 

1913 depth=depth, 

1914 step=step, 

1915 base_state=base_state, 

1916 base_ctx=base_ctx, 

1917 budget=budget, 

1918 max_depth=max_depth, 

1919 max_spawn_per_node=max_spawn_per_node, 

1920 max_concurrent_children=max_concurrent_children, 

1921 child=child, 

1922 idx=idx, 

1923 ) 

1924 ) 

1925 hard_error: Exception | None = None 

1926 try: 

1927 for fut in as_completed(futures): 

1928 try: 

1929 child_id, child_envelope, child_metrics = fut.result() 

1930 except (RunHardFailure, SkillLoadError) as exc: 

1931 hard_error = exc 

1932 cancel.set() 

1933 for pending in futures: 

1934 pending.cancel() 

1935 break 

1936 metrics = _merge_metrics(metrics, child_metrics) 

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

1938 if not child_envelope["passed"]: 

1939 envelope["passed"] = False 

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

1941 finally: 

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

1943 if hard_error is not None: 

1944 raise hard_error 

1945 

1946 return envelope, metrics 

1947 finally: 

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

1949 tracer.close_parent_observation(parent_obs) 

1950 

1951 @staticmethod 

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

1953 if max_spawn_per_node is None: 

1954 return max(1, max_concurrent_children) 

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

1956 

1957 def _spawn_child( 

1958 self, 

1959 *, 

1960 node_id: str, 

1961 child_role: str, 

1962 depth: int, 

1963 step: dict, 

1964 base_state: PipelineState, 

1965 base_ctx: dict, 

1966 budget: SpawnBudget, 

1967 max_depth: int, 

1968 max_spawn_per_node: int | None, 

1969 max_concurrent_children: int, 

1970 child: Any, 

1971 idx: int, 

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

1973 if not self._spawn_has_input(child): 

1974 raise RunHardFailure( 

1975 "INVALID_CHILD_PLAN", 

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

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

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

1979 ) 

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

1981 child_input = child["input"] 

1982 child_envelope, child_metrics = self._spawn_node( 

1983 node_id=child_id, 

1984 role=child_role, 

1985 depth=depth + 1, 

1986 step={"id": child_id, "input": child_input}, 

1987 input_override=child_input, 

1988 base_state=base_state, 

1989 base_ctx=base_ctx, 

1990 child_role=child_role, 

1991 budget=budget, 

1992 max_depth=max_depth, 

1993 max_spawn_per_node=max_spawn_per_node, 

1994 max_concurrent_children=max_concurrent_children, 

1995 ) 

1996 return child_id, child_envelope, child_metrics 

1997 

1998 @staticmethod 

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

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

2001 if not isinstance(result, dict): 

2002 return [] 

2003 raw = result.get("children") 

2004 if raw is None: 

2005 return [] 

2006 if not isinstance(raw, list): 

2007 raise RunHardFailure( 

2008 "INVALID_CHILD_PLAN", 

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

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

2011 ) 

2012 return raw 

2013 

2014 @staticmethod 

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

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

2017 if not isinstance(child, dict): 

2018 return False 

2019 input_val = child.get("input") 

2020 if input_val is None: 

2021 return False 

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

2023 return False 

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

2025 return False 

2026 return True 

2027 

2028 @staticmethod 

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

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

2031 if not isinstance(result, dict): 

2032 return False 

2033 if _SPAWN_SOFT_FAIL_KEYS & result.keys(): 

2034 return True 

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

2036 

2037 def _run_command_step(self, step: dict, state: PipelineState) -> PipelineState: 

2038 """Execute a manifest command step (type: command). 

2039 

2040 Validation (ADR-0012 — no silent assumptions, fail-fast): 

2041 - 'command' is required and must be a non-empty string 

2042 - 'env' (optional) must name a key in the pipeline's spec.envs map; 

2043 an unknown name is a hard error listing the available names 

2044 Execution: 

2045 - env absent → engine container (subprocess, `sh -c`) — documented 

2046 default, recorded explicitly in the step result (invariant b7) 

2047 - env present → env container via Kubernetes exec (mechanic A): 

2048 own pod (hostname), container slug(env), `sh -c <command>` 

2049 Result stored at state[step_id]: {exit_code, stdout, stderr, env, 

2050 executed_in}. A non-zero exit code is a result, not a pipeline 

2051 failure — LLM steps consume it (TDD-loop building block). 

2052 """ 

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

2054 command = step.get("command") 

2055 if not isinstance(command, str) or not command.strip(): 

2056 raise ValueError( 

2057 f"command step '{step_id}': field 'command' is required and " 

2058 "must be a non-empty string (ADR-0012)" 

2059 ) 

2060 env_name = step.get("env") 

2061 if env_name is not None: 

2062 if not isinstance(env_name, str) or not env_name.strip(): 

2063 raise ValueError( 

2064 f"command step '{step_id}': field 'env' must be a non-empty string (ADR-0012)" 

2065 ) 

2066 if env_name not in self._envs: 

2067 available = ", ".join(sorted(self._envs)) or "(none)" 

2068 raise ValueError( 

2069 f"command step '{step_id}': unknown env '{env_name}' " 

2070 f"(available envs: {available}) (ADR-0012)" 

2071 ) 

2072 result = self._command_executor.run_in_env(command, env_name) 

2073 step_result: dict = {**result, "env": env_name, "executed_in": "env"} 

2074 else: 

2075 result = self._command_executor.run_in_engine(command) 

2076 step_result = {**result, "env": None, "executed_in": "engine"} 

2077 return {**state, step_id: step_result} 

2078 

2079 

2080# --------------------------------------------------------------------------- 

2081# GraphBuilder 

2082# --------------------------------------------------------------------------- 

2083 

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

2085 """ 

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

2087 """ 

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

2089 visited: set[str] = set() 

2090 order: list[dict] = [] 

2091 

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

2093 if step_id in visited: 

2094 return 

2095 visited.add(step_id) 

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

2097 if dep in id_to_step: 

2098 visit(dep) 

2099 order.append(id_to_step[step_id]) 

2100 

2101 for s in steps: 

2102 visit(s["id"]) 

2103 return order 

2104 

2105 

2106# --------------------------------------------------------------------------- 

2107# Call-step build-time expansion (ADR-0025 D3/D4) 

2108# --------------------------------------------------------------------------- 

2109# 

2110# A `type: call` step is expanded at build time into the function's inlined body 

2111# steps plus two synthesized op steps (`__call_bind` / `__call_emit`). The 

2112# runtime executes only this flat graph; LangGraph never sees a call step. The 

2113# caller reads the function outputs via the call result shape 

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

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

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

2117# 

2118# Isolation/purity (ADR-0025 D5) and recursion (D7/D9): `command` in a body is a 

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

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

2121 

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

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

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

2125 

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

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

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

2129 """ 

2130 if isinstance(value, str): 

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

2132 if isinstance(value, dict): 

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

2134 if isinstance(value, list): 

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

2136 return value 

2137 

2138 

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

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

2141 scoped: dict = dict(step) 

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

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

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

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

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

2147 return scoped 

2148 

2149 

2150def _validate_function_purity(fn_name: str, body: list[dict]) -> None: 

2151 """function-body purity (ADR-0025 D5): `command` is a hard build error.""" 

2152 for step in body: 

2153 if step.get("type") == "command": 

2154 raise ValueError( 

2155 f"function '{fn_name}' step '{step['id']}': command is forbidden " 

2156 "in a function body (ADR-0025 D5 — purity; no side-effect execution)" 

2157 ) 

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

2159 _validate_function_purity(fn_name, step["body"]) 

2160 

2161 

2162def _expand_call( 

2163 step: dict, 

2164 registry: ManifestRegistry, 

2165 stack: list[str], 

2166 call_map: dict[str, str], 

2167 expanded_functions: dict[str, str], 

2168) -> list[dict]: 

2169 """Expand a call step into bind + inlined body + emit steps.""" 

2170 fn_name = step["function"] 

2171 call_id = step["id"] 

2172 

2173 if fn_name in stack: 

2174 chain = " -> ".join(stack + [fn_name]) 

2175 raise ValueError( 

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

2177 "(ADR-0025 D7/D9 — recursion must be a bounded while loop or idempotent re-run)" 

2178 ) 

2179 

2180 # ADR-0025 D7: inlining the same function twice in one pipeline build 

2181 # collides its body step-ids (flat graph). This is a hard build error until 

2182 # id-prefixing lands; naming the function and both call sites pins the bug. 

2183 if fn_name in expanded_functions: 

2184 raise ValueError( 

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

2186 f"'{expanded_functions[fn_name]}' and '{call_id}' in one pipeline — " 

2187 "inlined body step ids collide (ADR-0025 D7)" 

2188 ) 

2189 expanded_functions[fn_name] = call_id 

2190 

2191 function = registry.get("Function", fn_name) 

2192 if function is None: 

2193 raise ValueError( 

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

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

2196 ) 

2197 

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

2199 _validate_function_purity(fn_name, body) 

2200 

2201 sub_stack = list(stack) + [fn_name] 

2202 outputs_spec = ((function.get("spec") or {}).get("interface") or {}).get("outputs") or [] 

2203 

2204 bind_id = f"_call__{call_id}__bind" 

2205 emit_id = f"_call__{call_id}__emit" 

2206 call_map[call_id] = emit_id 

2207 

2208 bind_step: dict = { 

2209 "id": bind_id, 

2210 "type": "builtin", 

2211 "component": "__call_bind", 

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

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

2214 } 

2215 

2216 expanded_body: list[dict] = [] 

2217 for body_step in body: 

2218 scoped = _scope_params(body_step, call_id) 

2219 for sub in _expand_step(scoped, registry, sub_stack, call_map, expanded_functions): 

2220 dep = list(sub.get("depends_on") or []) 

2221 if bind_id not in dep: 

2222 sub["depends_on"] = [bind_id, *dep] 

2223 expanded_body.append(sub) 

2224 

2225 emit_step: dict = { 

2226 "id": emit_id, 

2227 "type": "builtin", 

2228 "component": "__call_emit", 

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

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

2231 } 

2232 

2233 return [bind_step, *expanded_body, emit_step] 

2234 

2235 

2236def _expand_step( 

2237 step: dict, 

2238 registry: ManifestRegistry, 

2239 stack: list[str], 

2240 call_map: dict[str, str], 

2241 expanded_functions: dict[str, str], 

2242) -> list[dict]: 

2243 """Expand one step (recursing into while/foreach bodies).""" 

2244 if step.get("type") == "call": 

2245 return _expand_call(step, registry, stack, call_map, expanded_functions) 

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

2247 expanded_body: list[dict] = [] 

2248 for sub in step["body"]: 

2249 expanded_body.extend( 

2250 _expand_step(sub, registry, stack, call_map, expanded_functions) 

2251 ) 

2252 return [{**step, "body": expanded_body}] 

2253 return [step] 

2254 

2255 

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

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

2258 

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

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

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

2262 the function body ran. 

2263 """ 

2264 for step in steps: 

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

2266 if deps: 

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

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

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

2270 

2271 

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

2273 """Expand all `call` steps into the flat runtime graph (ADR-0025 D4). 

2274 

2275 Returns a new step list with each call replaced by its bind + inlined body + 

2276 emit steps; a call step never survives to the runtime graph. Cycles and 

2277 command-in-body are hard errors. 

2278 """ 

2279 call_map: dict[str, str] = {} 

2280 expanded_functions: dict[str, str] = {} 

2281 expanded: list[dict] = [] 

2282 for step in steps: 

2283 expanded.extend( 

2284 _expand_step(step, registry, [], call_map, expanded_functions) 

2285 ) 

2286 _rewrite_call_deps(expanded, call_map) 

2287 return expanded 

2288 

2289 

2290class GraphBuilder: 

2291 """ 

2292 Rakentaa LangGraph StateGraphin Pipeline-manifestista. 

2293 

2294 run_context: template-kontekstidata (budget, config jne.) — ei tallennu stateen, 

2295 käytetään vain {{ }} -templatejen evaluointiin. 

2296 """ 

2297 

2298 def __init__( 

2299 self, 

2300 registry: ManifestRegistry, 

2301 builtins: BuiltinExecutor | None = None, 

2302 invoke_agent_fn: Any | None = None, 

2303 run_context: dict | None = None, 

2304 command_executor: CommandExecutor | None = None, 

2305 tracer: Any | None = None, 

2306 ) -> None: 

2307 self._registry = registry 

2308 self._resolver = AgentResolver(registry) 

2309 if invoke_agent_fn is not None: 

2310 (builtins or _DEFAULT_BUILTINS).invoke_agent_fn = invoke_agent_fn 

2311 self._step_runner = StepRunner( 

2312 self._resolver, builtins, invoke_agent_fn, run_context, 

2313 command_executor=command_executor, 

2314 tracer=tracer, 

2315 ) 

2316 

2317 def build(self, pipeline: dict): # -> CompiledGraph 

2318 """Rakentaa ja palauttaa compilatun LangGraph StateGraphin.""" 

2319 try: 

2320 from langgraph.graph import StateGraph, END 

2321 except ImportError as exc: 

2322 raise ImportError( 

2323 "langgraph is required to build a workflow. " 

2324 "Install it with: pip install langgraph" 

2325 ) from exc 

2326 

2327 steps: list[dict] = (pipeline.get("spec") or {}).get("steps") or [] 

2328 # Build-time call-step expansion (ADR-0025 D4): the runtime executes only 

2329 # the flat expanded graph. LangGraph never sees a call step. 

2330 steps = expand_calls(steps, self._registry) 

2331 ordered = _topological_order(steps) 

2332 

2333 # Bind the pipeline's env map to command steps (env validation at 

2334 # step-execution time, spec.envs must be a map — ADR-0012). 

2335 envs = (pipeline.get("spec") or {}).get("envs") 

2336 if envs is not None and not isinstance(envs, dict): 

2337 raise ValueError( 

2338 f"Pipeline '{((pipeline.get('metadata') or {}).get('name') or '?')}': " 

2339 "spec.envs must be a map of env name → image ref (ADR-0012)" 

2340 ) 

2341 self._step_runner.set_envs(envs or {}) 

2342 

2343 # Group steps into LangGraph nodes: 

2344 # Steps with the same depends_on set AND type==parallel → one node (LangGraph-level parallel) 

2345 nodes: list[list[dict]] = [] 

2346 for step in ordered: 

2347 dep_sig = frozenset(step.get("depends_on") or []) 

2348 if nodes: 

2349 last_group_sig = frozenset( 

2350 dep 

2351 for s in nodes[-1] 

2352 for dep in (s.get("depends_on") or []) 

2353 ) 

2354 last_types = {s.get("type") for s in nodes[-1]} 

2355 if ( 

2356 dep_sig == last_group_sig 

2357 and step.get("type") == "parallel" 

2358 and last_types == {"parallel"} 

2359 ): 

2360 nodes[-1].append(step) 

2361 continue 

2362 nodes.append([step]) 

2363 

2364 graph: StateGraph = StateGraph(PipelineState) 

2365 step_runner = self._step_runner 

2366 

2367 prev_node_id: str | None = None 

2368 for group in nodes: 

2369 if len(group) == 1: 

2370 node_id = group[0]["id"] 

2371 else: 

2372 node_id = "parallel_" + "_".join(s["id"] for s in group) 

2373 

2374 captured_group = list(group) 

2375 

2376 if len(captured_group) == 1: 

2377 def make_node(step: dict): 

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

2379 return step_runner.run_step(step, state) 

2380 return node_fn 

2381 fn = make_node(captured_group[0]) 

2382 else: 

2383 def make_parallel_node(group_steps: list[dict]): 

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

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

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

2387 futures = { 

2388 pool.submit(step_runner.run_step, s, state): s["id"] 

2389 for s in group_steps 

2390 } 

2391 for future in as_completed(futures): 

2392 sid = futures[future] 

2393 try: 

2394 out = future.result() 

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

2396 except SkillLoadError: 

2397 raise 

2398 except RunHardFailure: 

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

2400 raise 

2401 except Exception as exc: # noqa: BLE001 

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

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

2404 return {**state, **results} 

2405 return node_fn 

2406 fn = make_parallel_node(captured_group) 

2407 

2408 graph.add_node(node_id, fn) 

2409 

2410 if prev_node_id is None: 

2411 graph.set_entry_point(node_id) 

2412 else: 

2413 graph.add_edge(prev_node_id, node_id) 

2414 

2415 prev_node_id = node_id 

2416 

2417 if prev_node_id: 

2418 graph.add_edge(prev_node_id, END) 

2419 else: 

2420 graph.add_node("noop", lambda s: s) 

2421 graph.set_entry_point("noop") 

2422 graph.add_edge("noop", END) 

2423 

2424 return graph.compile() 

2425 

2426 

2427# --------------------------------------------------------------------------- 

2428# Julkinen API 

2429# --------------------------------------------------------------------------- 

2430 

2431_registry_cache: ManifestRegistry | None = None 

2432 

2433 

2434def _get_registry(dirs: list[Path] | None = None) -> ManifestRegistry: 

2435 global _registry_cache 

2436 if _registry_cache is None or dirs is not None: 

2437 loader = ManifestLoader(dirs) 

2438 manifests = loader.load_all() 

2439 _registry_cache = ManifestRegistry(manifests) 

2440 return _registry_cache 

2441 

2442 

2443def build_workflow( 

2444 pipeline_name: str, 

2445 *, 

2446 dirs: list[Path] | None = None, 

2447 builtins: BuiltinExecutor | None = None, 

2448 invoke_agent_fn: Any | None = None, 

2449 run_context: dict | None = None, 

2450 tracer: Any | None = None, 

2451): 

2452 """ 

2453 Lataa Pipeline-manifesti nimeltä *pipeline_name* ja rakentaa 

2454 LangGraph-workflown. 

2455 

2456 Args: 

2457 pipeline_name: Pipeline metadata.name -arvo. 

2458 dirs: Manifestihakemistot (oletus: MANIFEST_DIRS). 

2459 builtins: Oma BuiltinExecutor (oletus: _DEFAULT_BUILTINS). 

2460 invoke_agent_fn: fn(resolved_spec, step, state) → state. 

2461 run_context: Template-kontekstidata, esim. {"budget": {...}, "config": {...}}. 

2462 

2463 Returns: 

2464 Compilattu LangGraph CompiledGraph. 

2465 """ 

2466 registry = _get_registry(dirs) 

2467 pipeline = registry.get("Pipeline", pipeline_name) 

2468 if pipeline is None: 

2469 available = [ 

2470 (m.get("metadata") or {}).get("name") 

2471 for m in registry.all_of_kind("Pipeline") 

2472 ] 

2473 raise KeyError( 

2474 f"Pipeline '{pipeline_name}' not found. Available: {available}" 

2475 ) 

2476 builder = GraphBuilder(registry, builtins, invoke_agent_fn, run_context, tracer=tracer) 

2477 return builder.build(pipeline)