Coverage for core/langgraph/_llm.py: 87%

624 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-25 03:42 +0000

1""" 

2_llm.py — OpenAI-compatible LLM invoker + Langfuse tracing + skill injection. 

3 

4OpenAIInvoker on callable, joka StepRunner kutsuu agent-stepille 

5(`sequential`, `parallel`, `foreach`-body sequential/parallel). Se: 

6 

7- kokoaa system-viestin AgentInstance-spec:n tiedoista: renderöity 

8 agent-prompti (params-substituutio), jonka perään engine injektoi 

9 `spec.skills`-skillit automaattisesti 

10- serialisoi `step.input`-arvon user-messageksi 

11- ajaa OpenAI-streamin DeepSeek-yhteensopivalla endpointilla 

12- raportoi Langfuse-generationina pipeline-trace alle 

13- parsii vastauksen strict-JSONina; ei-JSON-vastaus tuottaa status:error- 

14 envelopen ja fail-fastin, ei raakatekstin fallbackia 

15- valvoo RunBudgetia (max LLM calls / wall-clock timeout) 

16 

17API: 

18 invoker = OpenAIInvoker(config=..., budget=..., project_slug=..., pipeline_name=...) 

19 new_state = invoker(resolved_spec, resolved_step, state) 

20""" 

21from __future__ import annotations 

22 

23import contextvars 

24import json 

25import logging 

26import os 

27import re 

28import subprocess 

29import threading 

30import time 

31import uuid 

32from dataclasses import dataclass, field 

33from pathlib import Path 

34from typing import Any 

35 

36log = logging.getLogger(__name__) 

37 

38# Workspace-root resolver for the file-access tool — sama dual-import-kuvio 

39# kuin runner.py:ssa: flat sys.path (testit, discover-wrapperit) vs. qualified 

40# langgraph.* (main.py, PYTHONPATH=/app). 

41try: 

42 from _files_output import resolve_workspace_root 

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

44 from langgraph._files_output import resolve_workspace_root 

45 

46# Deterministic communication-context block (Phase 3): sama dual-import-kuvio 

47# kuin yllä — flat sys.path (testit) vs. qualified langgraph.* (main.py). 

48try: 

49 from _data_structure import ( 

50 COMMUNICATION_CONTEXT_KEY, 

51 render_communication_block, 

52 ) 

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

54 from langgraph._data_structure import ( 

55 COMMUNICATION_CONTEXT_KEY, 

56 render_communication_block, 

57 ) 

58 

59# Static agent output envelope contract (Phase 2): same dual-import pattern as 

60# above — flat sys.path (tests) vs. qualified langgraph.* (main.py). 

61try: 

62 from _envelope import ( 

63 ENVELOPE_CONTRACT_BLOCK, 

64 ENVELOPE_ERROR_CODE, 

65 validate_envelope, 

66 ) 

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

68 from langgraph._envelope import ( 

69 ENVELOPE_CONTRACT_BLOCK, 

70 ENVELOPE_ERROR_CODE, 

71 validate_envelope, 

72 ) 

73 

74# Native-requirement detector for tool calls expressed as assistant-content 

75# markup (ADR-0041 D3): same dual-import pattern as above — flat sys.path 

76# (tests) vs. qualified langgraph.* (main.py). 

77try: 

78 from _tool_markup import contains_tool_call_markup 

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

80 from langgraph._tool_markup import contains_tool_call_markup 

81 

82# File-access tool name constants for native-call routing: same dual-import 

83# pattern as above — flat sys.path (tests) vs. qualified langgraph.* (main.py). 

84try: 

85 from _instructions import ( 

86 LIST_DIR_TOOL_NAME as _LIST_DIR_TOOL_NAME, 

87 READ_FILE_TOOL_NAME as _READ_FILE_TOOL_NAME, 

88 ) 

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

90 from langgraph._instructions import ( 

91 LIST_DIR_TOOL_NAME as _LIST_DIR_TOOL_NAME, 

92 READ_FILE_TOOL_NAME as _READ_FILE_TOOL_NAME, 

93 ) 

94 

95# Structural tool contract: the resolved `Tool` carrier key and the invocation 

96# context handed to `Tool.execute` (Phase 3). Same dual-import pattern. 

97try: 

98 from tools.contract import RESOLVED_TOOLS_KEY, ToolContext, ToolResult 

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

100 from langgraph.tools.contract import RESOLVED_TOOLS_KEY, ToolContext, ToolResult 

101 

102# Per-tool read_file module: definition + execution single source (Phase 1). 

103# Same dual-import pattern as above — flat sys.path (tests) vs. qualified 

104# langgraph.* (main.py). The byte/line caps are re-exported for compatibility. 

105try: 

106 from tools.read_file import ( 

107 READ_FILE_MAX_BYTES as _READ_FILE_MAX_BYTES, # noqa: F401 

108 READ_FILE_MAX_LINES as _READ_FILE_MAX_LINES, # noqa: F401 

109 ReadFileResult, 

110 read_file as _read_file, 

111 ) 

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

113 from langgraph.tools.read_file import ( 

114 READ_FILE_MAX_BYTES as _READ_FILE_MAX_BYTES, # noqa: F401 

115 READ_FILE_MAX_LINES as _READ_FILE_MAX_LINES, # noqa: F401 

116 ReadFileResult, 

117 read_file as _read_file, 

118 ) 

119 

120# Per-tool list_dir module: definition + execution single source (Phase 2). 

121# Same dual-import pattern as above — flat sys.path (tests) vs. qualified 

122# langgraph.* (main.py). The cap is re-exported for compatibility. 

123try: 

124 from tools.list_dir import ( 

125 LIST_DIR_MAX_ENTRIES as _LIST_DIR_MAX_ENTRIES, # noqa: F401 

126 ListDirEntry, 

127 ListDirResult, 

128 list_dir as _execute_list_dir_tool, 

129 ) 

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

131 from langgraph.tools.list_dir import ( 

132 LIST_DIR_MAX_ENTRIES as _LIST_DIR_MAX_ENTRIES, # noqa: F401 

133 ListDirEntry, 

134 ListDirResult, 

135 list_dir as _execute_list_dir_tool, 

136 ) 

137 

138SKILL_DIR = Path("/skills") 

139 

140 

141class SkillLoadError(ValueError): 

142 """Skill-latauksen kova virhe (ADR-0012) — ei hiljaista fallbackia. 

143 

144 Erottaa skill-resoluution virheet muista ValueErrorista: runnerin 

145 parallel-käsittelijät re-raisaavat tämän (kova ajo-failure kaikilla 

146 step-polkuilla), kun muut step-virheet pysyvät state-virheinä. 

147 """ 

148 

149# Kontekstin eheys -sopimus (Phase 4 doc, ## Kontekstin eheys): 

150# koottu konteksti toimitetaan mallikutsulle kokonaisuudessaan tai step 

151# epäonnistuu kovasti — ei hiljaista leikkausta missään muodossa. 

152DEFAULT_CONTEXT_BUDGET_TOKENS = 100_000 

153DEFAULT_MAX_PARALLEL = 500 

154_CONTEXT_BUDGET_ERROR_CODE = "VALIDATION_ERROR" 

155_TOOL_FAILURE_ERROR_CODE = "TOOL_FAILURE" 

156_TIKTOKEN_ENCODING = "cl100k_base" 

157 

158# --- Opt-in tool loop (agent spec.tools resolved into `Tool` instances) --- 

159# The invoker receives the calling agent's resolved tools under 

160# RESOLVED_TOOLS_KEY and injects exactly those definitions as the request-level 

161# `tools`. A tool round = one additional inference call consumed through the run 

162# budget. The file-access name constants live in the Core-owned registry 

163# (_instructions) and are imported under their local names; every other 

164# declared tool executes through the structural `Tool.execute` contract. 

165_TOOL_STOP_REASON_BUDGET = "budget" 

166_TIKTOKEN_CACHE_DIR = Path(__file__).parent / "tiktoken_cache" 

167 

168_LANGFUSE_FLUSH_EVERY = 8 

169 

170 

171# --------------------------------------------------------------------------- 

172# RunBudget — deterministinen pipeline-ajon suoritusbudjetti 

173# --------------------------------------------------------------------------- 

174 

175 

176@dataclass 

177class RunBudget: 

178 max_llm_calls: int = 48 

179 run_timeout_seconds: int = 240 

180 start: float = field(default_factory=time.monotonic) 

181 llm_calls: int = 0 

182 stopped: bool = False 

183 stop_reason: str = "" 

184 _lock: threading.Lock = field(default_factory=threading.Lock) 

185 

186 def try_consume(self) -> bool: 

187 with self._lock: 

188 if self.stopped: 

189 return False 

190 if time.monotonic() - self.start > self.run_timeout_seconds: 

191 self.stopped = True 

192 self.stop_reason = "run_timeout" 

193 return False 

194 if self.llm_calls >= self.max_llm_calls: 

195 self.stopped = True 

196 self.stop_reason = "max_llm_calls" 

197 return False 

198 self.llm_calls += 1 

199 return True 

200 

201 def as_stats(self) -> dict[str, Any]: 

202 return { 

203 "llm_calls": self.llm_calls, 

204 "budget_stopped": self.stopped, 

205 "budget_stop_reason": self.stop_reason or None, 

206 } 

207 

208 

209# --------------------------------------------------------------------------- 

210# Skill loading 

211# --------------------------------------------------------------------------- 

212 

213 

214def _skill_repos_from_env() -> list[dict]: 

215 """Lue SKILL_REPOS-env (JSON [{"name": ..., "url": ...}]) → lista entryistä. 

216 

217 Puuttuva env → [] (sopimus: SKILL_REPOS on valinnainen). Mikä tahansa 

218 epäkelpo arvo on kova virhe (ADR-0012): malformi JSON, ei-lista, tai 

219 entry ilman ei-tyhjää name- ja url-merkkijonoa → SkillLoadError. 

220 """ 

221 raw = os.getenv("SKILL_REPOS") 

222 if not raw: 

223 return [] 

224 try: 

225 repos = json.loads(raw) 

226 except json.JSONDecodeError as exc: 

227 raise SkillLoadError(f"SKILL_REPOS is not valid JSON: {exc}") from exc 

228 if not isinstance(repos, list): 

229 raise SkillLoadError(f"SKILL_REPOS must be a JSON list, got {type(repos).__name__}") 

230 for idx, repo in enumerate(repos): 

231 if not isinstance(repo, dict): 

232 raise SkillLoadError( 

233 f"SKILL_REPOS entry {idx} must be an object, got {type(repo).__name__}" 

234 ) 

235 name = repo.get("name") 

236 url = repo.get("url") 

237 if not isinstance(name, str) or not name.strip(): 

238 raise SkillLoadError(f"SKILL_REPOS entry {idx} is missing a non-empty string 'name'") 

239 if not isinstance(url, str) or not url.strip(): 

240 raise SkillLoadError( 

241 f"SKILL_REPOS entry {idx} ('{name}') is missing a non-empty string 'url'" 

242 ) 

243 return repos 

244 

245 

246def locate_skills_root(repo_mount: Path) -> Path: 

247 """Etsi skill-repo-mountista tasan yksi `skills`-niminen kansio (find). 

248 

249 Ajaa hardcodetun find-komennon (ei parametrisoitava): 

250 

251 find <mount> -mindepth 1 -type d -name .git -prune \\ 

252 -o -type d -name skills -print 

253 

254 Etsii pelkät `skills`-nimiset kansiot (ei tiedostoja, ei sisäkansioita), 

255 `.git`-puu poistetaan (prune), osumat lajitellaan (find-järjestys ei ole 

256 taattu → determinismi). Tarkalleen 1 osuma → palauttaa polun Path- 

257 objektina. 0 tai >1 osumaa on kova virhe (ADR-0012), kuten myös itse 

258 komennon epäonnistuminen (find puuttuu, returncode != 0) — ei hiljaista 

259 ohitusta. 

260 """ 

261 cmd = [ 

262 "find", 

263 str(repo_mount), 

264 "-mindepth", 

265 "1", 

266 "-type", 

267 "d", 

268 "-name", 

269 ".git", 

270 "-prune", 

271 "-o", 

272 "-type", 

273 "d", 

274 "-name", 

275 "skills", 

276 "-print", 

277 ] 

278 try: 

279 proc = subprocess.run(cmd, capture_output=True, text=True) 

280 except OSError as exc: 

281 raise SkillLoadError( 

282 f"failed to run find for skill mount {repo_mount}: {exc}" 

283 ) from exc 

284 if proc.returncode != 0: 

285 raise SkillLoadError( 

286 f"find failed for skill mount {repo_mount} " 

287 f"(rc={proc.returncode}): {proc.stderr.strip()}" 

288 ) 

289 hits = sorted(line.strip() for line in proc.stdout.splitlines() if line.strip()) 

290 if not hits: 

291 raise SkillLoadError(f"no skills folder found in {repo_mount}") 

292 if len(hits) > 1: 

293 raise SkillLoadError( 

294 f"ambiguous skills folders found in {repo_mount}: " + ", ".join(hits) 

295 ) 

296 return Path(hits[0]) 

297 

298 

299def _normalize_skill_libs() -> list[Path]: 

300 """Skill-lähdelista: koodirepon skills/-kirjasto ensin, sitten skill-repot. 

301 

302 Koodirepon implisiittinen skills/-kirjasto (`PROJECT_ROOT/skills/`, 

303 oletus `/project/skills`) on aina korkein prioriteetti skill-lähteenä 

304 (koodirepon ylivalta, ADR-0018 D3), ja skill-repot tulevat SKILL_REPOS- 

305 järjestyksessä (ADR-0018 D4) listan loppuun: kunkin mountin kohdalla 

306 etsitään `skills`-kansio locate_skills_root()-tuloksella. Jos skill- 

307 reposta ei löydy skills-kansiota, ajo epäonnistuu kovasti (ADR-0012) — 

308 koodirepon puuttuvan skills/-kansion hiljainen ohitus pysyy ennallaan 

309 (resoluutio ohittaa puuttuvan kirjaston). 

310 """ 

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

312 return [project_root / "skills"] + [ 

313 locate_skills_root(SKILL_DIR / repo["name"]) for repo in _skill_repos_from_env() 

314 ] 

315 

316 

317def load_skills(spec: dict, *, max_chars: int = 16000) -> str: 

318 """ 

319 Lukee `spec.skills`-flat-listan (entryjä) ja palauttaa yhdistetyn 

320 tekstin. Kunkin tiedoston yläraja on max_chars merkkiä (oletus 

321 16 000): ylittävä tiedosto on kova SkillLoadError (ADR-0012) — 

322 ei hiljaista katkaisua. 

323 

324 Entry on joko kansionimi (`<nimi>`): koko `<nimi>/`-kansion sisältö 

325 injektoidaan otsakkeilla (SKILL.md ensin, sitten loput 

326 aakkosjärjestyksessä suhteellisen polun mukaan), tai tarkka 

327 tiedosto (`<skill>/<tiedostonimi>`): vain kyseinen tiedosto 

328 injektoidaan. Tiedosto-entryt eivät saa sisältää alipolkuja — 

329 täsmälleen yksi `/`; virheellinen muoto (johto-/loppuviiva, tyhjä 

330 segmentti, `..`, absoluuttinen polku) on kova SkillLoadError. 

331 

332 Ensimmäinen skill-lähde, josta kohde löytyy, voittaa (first-match): 

333 koodirepon skills/-kirjasto (`PROJECT_ROOT/skills/`, koodirepon 

334 ylivalta, ADR-0018 D3) ensin, sitten skill-repot SKILL_REPOS- 

335 järjestyksessä (prioriteetti, ADR-0018 D4); puuttuvat lähteet 

336 ohitetaan hiljaa. 

337 

338 Resolvoitumaton entry → SkillLoadError (ADR-0012): viesti listaa 

339 etsityt kirjastopolut ja saatavilla olevat skillit. Ei hiljaista 

340 ohitusta. 

341 """ 

342 skills = (spec.get("spec") or {}).get("skills") or [] 

343 if not skills: 

344 return "" 

345 libs = _normalize_skill_libs() 

346 parts: list[str] = [] 

347 for entry in skills: 

348 parts.extend(_load_bare_skill(entry, libs, max_chars=max_chars)) 

349 return "\n\n---\n\n".join(parts) 

350 

351 

352def _parse_skill_entry(entry: str) -> tuple[str, str | None]: 

353 """Parseroi spec.skills-entryn: kansionimi → (nimi, None), tarkka 

354 tiedosto `<skill>/<tiedostonimi>` → (skill, tiedostonimi). 

355 

356 Virheellinen muoto → kova SkillLoadError: tyhjä entry, `.`/`..`, 

357 tyhjä segmentti, absoluuttinen polku tai >1 vinoviivaa (alipolut 

358 eivät ole sallittuja). 

359 """ 

360 if not entry.strip(): 

361 raise SkillLoadError( 

362 f"skill entry '{entry}' is empty; use '<skill>' " 

363 "or '<skill>/<file>'" 

364 ) 

365 if entry.startswith("/"): 

366 raise SkillLoadError( 

367 f"skill entry '{entry}' is an absolute path; use '<skill>' " 

368 "or '<skill>/<file>'" 

369 ) 

370 parts = entry.split("/") 

371 if any(seg in ("", ".", "..") for seg in parts): 

372 raise SkillLoadError( 

373 f"skill entry '{entry}' contains an invalid path segment " 

374 "(''|'.'|'..'); use '<skill>' or '<skill>/<file>'" 

375 ) 

376 if len(parts) == 1: 

377 return entry, None 

378 if len(parts) > 2: 

379 raise SkillLoadError( 

380 f"skill entry '{entry}' has too many path segments; subpaths " 

381 "are not allowed, use '<skill>' or '<skill>/<file>'" 

382 ) 

383 skill, filename = parts 

384 return skill, filename 

385 

386 

387def _load_bare_skill(entry: str, libs: list[Path], *, max_chars: int) -> list[str]: 

388 """Skill-entry (kansionimi tai tarkka tiedosto) ladattuna otsakkeilla. 

389 

390 Kansionimi (`<nimi>`): kansion `lib / <nimi>/` kaikki tiedostot 

391 injektoidaan (SKILL.md ensin, sitten loput aakkosjärjestyksessä). 

392 Tarkka tiedosto (`<skill>/<tiedostonimi>`): vain kyseinen tiedosto 

393 `lib / <skill> / <tiedosto>` injektoidaan otsakkeella 

394 `## Skill: <skill> — <tiedosto>`. 

395 

396 Ensimmäinen skill-lähde, josta kohde löytyy, voittaa (first-match). 

397 Kansionimelle vastaava `lib / entry` tiedostona → SkillLoadError; 

398 tiedosto-entrylle kansion löytyminen ilman kohdetiedostoa ohittaa 

399 kyseisen lähteen hiljaa. Virheellinen entry-muoto tai kohde, jota 

400 ei löydy mistään skill-lähteestä → SkillLoadError. 

401 """ 

402 name, filename = _parse_skill_entry(entry) 

403 for lib in libs: 

404 folder = lib / name 

405 if filename is None: 

406 if folder.is_dir(): 

407 parts: list[str] = [] 

408 for path in _skill_folder_files(folder): 

409 relpath = path.relative_to(folder) 

410 parts.append( 

411 f"## Skill: {name} — {relpath}\n\n" 

412 f"{_read_skill_file(entry, path, max_chars=max_chars)}" 

413 ) 

414 return parts 

415 if folder.is_file(): 

416 raise SkillLoadError( 

417 f"skill '{entry}' resolves to a file, expected a skill folder " 

418 f"(SKILL.md etc.): {folder}" 

419 ) 

420 else: 

421 file_path = folder / filename 

422 if file_path.is_file(): 

423 return [ 

424 f"## Skill: {name} — {filename}\n\n" 

425 f"{_read_skill_file(entry, file_path, max_chars=max_chars)}" 

426 ] 

427 if folder.is_dir(): 

428 continue 

429 raise SkillLoadError(_unresolved_skill_error(entry, libs)) 

430 

431 

432def _skill_folder_files(folder: Path) -> list[Path]: 

433 """Kansion kaikki tiedostot rekursiivisesti, deterministinen järjestys: 

434 juuren SKILL.md ensin, sitten loput suhteellisen polun mukaan 

435 aakkosjärjestyksessä.""" 

436 files = [p for p in folder.rglob("*") if p.is_file()] 

437 files.sort( 

438 key=lambda p: ( 

439 p.relative_to(folder) != Path("SKILL.md"), 

440 str(p.relative_to(folder)), 

441 ) 

442 ) 

443 return files 

444 

445 

446def _read_skill_file(entry: str, path: Path, *, max_chars: int) -> str: 

447 try: 

448 text = path.read_text(encoding="utf-8", errors="replace") 

449 except OSError as exc: 

450 raise SkillLoadError( 

451 f"skill entry '{entry}' file unreadable: {path} ({exc})" 

452 ) from exc 

453 if len(text) > max_chars: 

454 raise SkillLoadError( 

455 f"skill entry '{entry}' file exceeds the {max_chars}-char limit: " 

456 f"{path} ({len(text)} chars)" 

457 ) 

458 return text 

459 

460 

461def _available_skill_names(libs: list[Path]) -> list[str]: 

462 """Kirjastojen tarjonta: skill-kansiot (sisältävät SKILL.md) + top-tason 

463 *.md-tiedostot. Sorted + deduped.""" 

464 names: set[str] = set() 

465 for lib in libs: 

466 if not lib.is_dir(): 

467 continue 

468 for child in lib.iterdir(): 

469 if child.is_dir(): 

470 if (child / "SKILL.md").is_file(): 

471 names.add(child.name) 

472 elif child.is_file() and child.name.endswith(".md"): 

473 names.add(child.name) 

474 return sorted(names) 

475 

476 

477def _unresolved_skill_error(entry: str, libs: list[Path]) -> str: 

478 available = ", ".join(_available_skill_names(libs)) or "(none)" 

479 searched = "\n".join(f" - {lib}" for lib in libs) 

480 return ( 

481 f"skill '{entry}' not found in any skill repo " 

482 f"(available skills: {available}). Searched skill repos:\n{searched}" 

483 ) 

484 

485 

486# --------------------------------------------------------------------------- 

487# Template substitution (simple {{ key }} replacement against a dict) 

488# --------------------------------------------------------------------------- 

489 

490_TMPL_RE = re.compile(r"\{\{\s*([a-zA-Z_][a-zA-Z0-9_.]*)\s*\}\}") 

491 

492 

493def _render(text: str, ctx: dict[str, Any]) -> str: 

494 if not isinstance(text, str) or "{{" not in text: 

495 return text 

496 

497 def _lookup(path: str) -> str: 

498 cur: Any = ctx 

499 for part in path.split("."): 

500 if isinstance(cur, dict): 

501 cur = cur.get(part) 

502 else: 

503 return "" 

504 if cur is None: 

505 return "" 

506 return str(cur) if not isinstance(cur, str) else cur 

507 

508 return _TMPL_RE.sub(lambda m: _lookup(m.group(1)), text) 

509 

510 

511# --------------------------------------------------------------------------- 

512# Token counting (deterministinen ja provider-agnostinen; Phase 4 doc) 

513# --------------------------------------------------------------------------- 

514 

515_tokenizer_encoding: Any | None = None 

516_tokenizer_lock = threading.Lock() 

517 

518 

519def _tokenizer() -> Any: 

520 """tiktoken-encoding lazy-singleton, joka käyttää bundlattua 

521 TIKTOKEN_CACHE_DIR:ää (core/langgraph/tiktoken_cache/) — ei 

522 verkkolatausta ensimmäiselläkään käyttökerralla.""" 

523 global _tokenizer_encoding 

524 if _tokenizer_encoding is None: 

525 with _tokenizer_lock: 

526 if _tokenizer_encoding is None: 

527 if not os.environ.get("TIKTOKEN_CACHE_DIR"): 

528 os.environ["TIKTOKEN_CACHE_DIR"] = str(_TIKTOKEN_CACHE_DIR) 

529 from tiktoken import get_encoding 

530 

531 _tokenizer_encoding = get_encoding(_TIKTOKEN_ENCODING) 

532 return _tokenizer_encoding 

533 

534 

535def count_context_tokens(messages: list[dict[str, str]]) -> int: 

536 """Kootun kontekstin koko tokeneina: system + user -viestien sisältöjen 

537 summa. Deterministinen, ei riipu providerista eikä kutsutusta mallista.""" 

538 enc = _tokenizer() 

539 total = 0 

540 for message in messages: 

541 content = message.get("content") 

542 if isinstance(content, str): 

543 total += len(enc.encode_ordinary(content)) 

544 return total 

545 

546 

547# --------------------------------------------------------------------------- 

548# Langfuse trace helpers 

549# --------------------------------------------------------------------------- 

550 

551_PARENT_STACK: contextvars.ContextVar[tuple[str, ...]] = contextvars.ContextVar( 

552 "langfuse_parent_stack", default=() 

553) 

554 

555 

556class LangfuseTracer: 

557 """ 

558 Per-ajo Langfuse-tracen alustus + generation-spanit per LLM-kutsu. 

559 Käyttää singleton-Langfuse-clientiä env-muuttujista. 

560 """ 

561 

562 def __init__(self, project_slug: str, pipeline_name: str, run_id: str): 

563 self.project_slug = project_slug 

564 self.pipeline_name = pipeline_name 

565 self.run_id = run_id or "unknown" 

566 self._lf: Any = None 

567 self._trace_ctx: dict[str, Any] | None = None 

568 self._flush_counter = 0 

569 self._init_done = False 

570 

571 def _client(self) -> Any: 

572 if self._lf is not None: 

573 return self._lf 

574 try: 

575 from langfuse import Langfuse # type: ignore 

576 except ImportError: 

577 log.warning("[langfuse] package not installed — tracing disabled") 

578 return None 

579 prev = os.environ.get("LANGFUSE_TRACING_ENABLED") 

580 os.environ["LANGFUSE_TRACING_ENABLED"] = "true" 

581 try: 

582 self._lf = Langfuse() 

583 except Exception as exc: # noqa: BLE001 

584 log.warning("[langfuse] init failed: %s — tracing disabled", exc) 

585 self._lf = None 

586 finally: 

587 if prev is None: 

588 os.environ["LANGFUSE_TRACING_ENABLED"] = "false" 

589 else: 

590 os.environ["LANGFUSE_TRACING_ENABLED"] = prev 

591 return self._lf 

592 

593 @property 

594 def trace_name(self) -> str: 

595 return f"{self.project_slug}:{self.pipeline_name}" 

596 

597 @property 

598 def session_id(self) -> str: 

599 return f"{self.project_slug}-{self.pipeline_name}" 

600 

601 def _ctx(self) -> dict[str, Any] | None: 

602 lf = self._client() 

603 if lf is None: 

604 return None 

605 if self._trace_ctx is None: 

606 self._trace_ctx = {"trace_id": lf.create_trace_id(seed=self.run_id)} 

607 return self._trace_ctx 

608 

609 def _ctx_with_parent(self) -> dict[str, Any] | None: 

610 ctx = self._ctx() 

611 if ctx is None: 

612 return None 

613 stack = _PARENT_STACK.get() 

614 if stack: 

615 return {**ctx, "parent_span_id": stack[-1]} 

616 return ctx 

617 

618 def open_parent_observation( 

619 self, 

620 *, 

621 name: str, 

622 input: dict[str, Any] | None = None, 

623 metadata: dict[str, Any] | None = None, 

624 ) -> Any: 

625 lf = self._client() 

626 ctx = self._ctx_with_parent() 

627 if lf is None or ctx is None: 

628 return _NoopObservation() 

629 try: 

630 obs = lf.start_observation( 

631 trace_context=ctx, 

632 name=name, 

633 as_type="span", 

634 input=input or {}, 

635 metadata=metadata or {}, 

636 ) 

637 except Exception as exc: # noqa: BLE001 

638 log.warning("[langfuse] open_parent_observation failed: %s", exc) 

639 return _NoopObservation() 

640 stack = _PARENT_STACK.get() 

641 _PARENT_STACK.set((*stack, obs.id)) 

642 return obs 

643 

644 def close_parent_observation(self, obs: Any) -> None: 

645 if isinstance(obs, _NoopObservation): 

646 return 

647 stack = _PARENT_STACK.get() 

648 if stack and stack[-1] == obs.id: 

649 _PARENT_STACK.set(stack[:-1]) 

650 obs.end() 

651 

652 def init_trace(self) -> None: 

653 if self._init_done: 

654 return 

655 lf = self._client() 

656 ctx = self._ctx() 

657 if lf is None or ctx is None: 

658 self._init_done = True 

659 return 

660 try: 

661 obs = lf.start_observation( 

662 trace_context=ctx, 

663 name=self.trace_name, 

664 as_type="span", 

665 input={ 

666 "pipeline": self.pipeline_name, 

667 "project": self.project_slug, 

668 "run_id": self.run_id, 

669 }, 

670 ) 

671 self._apply_meta(obs) 

672 obs.end() 

673 self.flush(force=True) 

674 except Exception as exc: # noqa: BLE001 

675 log.warning("[langfuse] init_trace failed: %s", exc) 

676 self._init_done = True 

677 

678 def _apply_meta(self, obs: Any) -> None: 

679 try: 

680 obs.update_trace( 

681 name=self.trace_name, 

682 user_id=self.project_slug, 

683 session_id=self.session_id, 

684 tags=[f"pipeline:{self.pipeline_name}"], 

685 ) 

686 except Exception: # noqa: BLE001 

687 pass 

688 

689 def generation( 

690 self, 

691 name: str, 

692 *, 

693 model: str, 

694 temperature: float, 

695 messages: list[dict[str, str]], 

696 metadata: dict[str, Any] | None = None, 

697 ) -> Any: 

698 lf = self._client() 

699 ctx = self._ctx() 

700 if lf is None or ctx is None: 

701 return _NoopObservation() 

702 try: 

703 obs = lf.start_observation( 

704 trace_context=self._ctx_with_parent(), 

705 name=name, 

706 as_type="generation", 

707 model=model, 

708 model_parameters={"temperature": temperature}, 

709 input=messages, 

710 metadata=metadata, 

711 ) 

712 self._apply_meta(obs) 

713 return obs 

714 except Exception as exc: # noqa: BLE001 

715 log.warning("[langfuse] generation failed: %s", exc) 

716 return _NoopObservation() 

717 

718 def flush(self, *, force: bool = False) -> None: 

719 lf = self._client() 

720 if lf is None: 

721 return 

722 self._flush_counter += 1 

723 if force or self._flush_counter % _LANGFUSE_FLUSH_EVERY == 0: 

724 try: 

725 lf.flush() 

726 except Exception: # noqa: BLE001 

727 pass 

728 

729 

730class _NoopObservation: 

731 def update(self, **_: Any) -> None: ... 

732 def end(self) -> None: ... 

733 

734 

735# --------------------------------------------------------------------------- 

736# JSON output parsing — LLM-vastauksen JSON-rakenteiden jäsennys 

737# --------------------------------------------------------------------------- 

738 

739 

740def _try_parse_json(text: str) -> tuple[Any, str | None]: 

741 """Parse the response as strict JSON. Palauttaa (data, error). 

742 

743 No fence stripping, no embedded-object extraction, and no repair/retry: 

744 the whole stripped text must decode. data=None on failure. 

745 """ 

746 if not text or not text.strip(): 

747 return None, "empty" 

748 try: 

749 return json.loads(text.strip()), None 

750 except json.JSONDecodeError as e: 

751 return None, str(e) 

752 

753 

754# --------------------------------------------------------------------------- 

755# File-access tool execution + deterministic-tool soft result 

756# --------------------------------------------------------------------------- 

757 

758 

759def _invalid_tool_arguments_result( 

760 name: str, reason: str 

761) -> ReadFileResult | ListDirResult: 

762 """Malformed tool arguments (unparseable JSON, non-object JSON, wrong 

763 types) are an expected domain error: a soft, model-visible tool result — 

764 never the technical-failure marker (ADR-0012, ADR-0030 D8).""" 

765 if name == _LIST_DIR_TOOL_NAME: 

766 return ListDirResult([], False, "", f"{_LIST_DIR_TOOL_NAME}: {reason}") 

767 return ReadFileResult("", False, f"{_READ_FILE_TOOL_NAME}: {reason}") 

768 

769 

770def _file_result_from_tool_result( 

771 name: str, result: ToolResult 

772) -> ReadFileResult | ListDirResult: 

773 """Adapt a file tool's `ToolResult` back to its module result shape.""" 

774 state_patch = result.state_patch or {} 

775 if name == _LIST_DIR_TOOL_NAME: 

776 return ListDirResult( 

777 _list_dir_entries(result.content), 

778 bool(state_patch.get("truncated", False)), 

779 str(state_patch.get("root", "")), 

780 result.error, 

781 ) 

782 return ReadFileResult( 

783 result.content, 

784 bool(state_patch.get("truncated", False)), 

785 result.error, 

786 ) 

787 

788 

789def _list_dir_entries(content: str) -> list[ListDirEntry]: 

790 """Decode the list_dir tool's JSON entry array; malformed content is empty.""" 

791 try: 

792 items = json.loads(content) 

793 except (TypeError, json.JSONDecodeError): 

794 return [] 

795 if not isinstance(items, list): 

796 return [] 

797 return [ 

798 ListDirEntry( 

799 path=item["path"], 

800 name=item["name"], 

801 size=item["size"], 

802 kind=item["kind"], 

803 ) 

804 for item in items 

805 if isinstance(item, dict) 

806 ] 

807 

808 

809def _parse_deterministic_tool_arguments(name: str, tool_call: Any) -> dict: 

810 """The call's arguments object, else a hard CALL_INVALID_PARAMS failure.""" 

811 try: 

812 args = json.loads(getattr(tool_call.function, "arguments", "{}")) 

813 except (TypeError, json.JSONDecodeError) as exc: 

814 raise _ToolHardFailure( 

815 "CALL_INVALID_PARAMS", 

816 f"tool '{name}' has invalid arguments: {exc}", 

817 ) from exc 

818 if not isinstance(args, dict): 

819 raise _ToolHardFailure( 

820 "CALL_INVALID_PARAMS", 

821 f"tool '{name}' arguments must be a JSON object, got " 

822 f"{type(args).__name__}", 

823 ) 

824 return args 

825 

826 

827class _ToolHardFailure(Exception): 

828 """A native tool call that must fail the run with a CALL_* hard code.""" 

829 

830 def __init__(self, code: str, message: str) -> None: 

831 super().__init__(message) 

832 self.code = code 

833 self.message = message 

834 

835 

836def _execute_tool_or_call_failed( 

837 tool: Any, context: ToolContext, parent_step_id: str, name: str 

838) -> Any: 

839 """Run one resolved tool, mapping technical failures to CALL_* codes. 

840 

841 A `BUILTIN_NAME_COLLISION` a tool raises is preserved; every other 

842 exception becomes a hard `CALL_FAILED` (the legacy deterministic-dispatch 

843 contract), so the caller's `tool_failure` mapping stays observable. 

844 """ 

845 try: 

846 return tool.execute(context) 

847 except _ToolHardFailure: 

848 raise 

849 except Exception as exc: # noqa: BLE001 

850 code = ( 

851 "BUILTIN_NAME_COLLISION" 

852 if getattr(exc, "code", None) == "BUILTIN_NAME_COLLISION" 

853 else "CALL_FAILED" 

854 ) 

855 message = getattr(exc, "message", None) or f"{type(exc).__name__}: {exc}" 

856 raise _ToolHardFailure( 

857 code, f"step '{parent_step_id}' tool '{name}' failed: {message}" 

858 ) from exc 

859 

860 

861def _synthesize_call_step( 

862 parent_step_id: str, index: int, name: str, params: dict, state: dict 

863) -> dict: 

864 """Build the synthetic call step for one deterministic tool invocation. 

865 

866 Mirrors ``StepRunner._synthesize_call_step`` so state-patch keys keep the 

867 engine's id schema: the engine-local iteration discriminator (when 

868 present) plus a process-unique token keep concurrent calls distinct. 

869 """ 

870 try: 

871 from runner import _iteration_discriminator 

872 except ImportError: # pragma: no cover — main.py run (qualified imports) 

873 from langgraph.runner import _iteration_discriminator 

874 

875 discriminator = _iteration_discriminator(state) 

876 token = uuid.uuid4().hex 

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

878 return { 

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

880 "type": "builtin", 

881 "component": name, 

882 "params": params, 

883 } 

884 

885 

886@dataclass 

887class DispatchedToolResult: 

888 """A deterministic tool executed through the structural `Tool` contract. 

889 

890 `result` carries the tool's model-facing content envelope (the `ToolResult` 

891 content), which is also the message fed back to the model. 

892 """ 

893 

894 name: str 

895 arguments: dict 

896 result: Any 

897 truncated: bool = False 

898 error: str | None = None 

899 

900 def as_tool_message(self) -> str: 

901 if isinstance(self.result, str): 

902 return self.result 

903 return json.dumps(self.result, ensure_ascii=False, default=str) 

904 

905 

906# --------------------------------------------------------------------------- 

907# OpenAIInvoker 

908# --------------------------------------------------------------------------- 

909 

910 

911class OpenAIInvoker: 

912 """ 

913 Default LLM invoker for agent-platform pipelines. 

914 

915 Yksi instanssi per pipeline-ajo. Thread-safe (semafori rinnakkaisuudelle). 

916 """ 

917 

918 def __init__( 

919 self, 

920 *, 

921 providers: dict[str, dict[str, Any]], 

922 max_parallel: int, 

923 budget: RunBudget, 

924 tracer: LangfuseTracer, 

925 ) -> None: 

926 self._providers = providers 

927 self._clients: dict[str, Any] = {} 

928 self.budget = budget 

929 self.tracer = tracer 

930 self._semaphore = threading.Semaphore(max(1, max_parallel)) 

931 

932 def _openai_client(self, base_url: str, api_key: str) -> Any: 

933 key = f"{base_url}|{api_key}" 

934 if key not in self._clients: 

935 from openai import OpenAI # type: ignore 

936 self._clients[key] = OpenAI(base_url=base_url, api_key=api_key) 

937 return self._clients[key] 

938 

939 # ---- Public callable ------------------------------------------------- 

940 

941 def __call__( 

942 self, 

943 resolved_spec: dict, 

944 resolved_step: dict, 

945 state: dict, 

946 ) -> dict: 

947 """ 

948 StepRunner-yhteensopiva: (spec, step, state) → uusi state. 

949 

950 `resolved_step` = step jossa params/input on JO resolvoitu 

951 StepRunnerissa template-engineä käyttäen. Tämä invoker ei enää 

952 evaluoi `{{ }}`-lausekkeita state/run_context-vastaan, vain 

953 substituoi `step.params` ja sisäänrakennetut feature-arvot 

954 agentin `system_prompt`/`user`-mallineisiin. 

955 """ 

956 step_id = resolved_step.get("id", "?") 

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

958 resolved_tools = resolved_step.get(RESOLVED_TOOLS_KEY) or [] 

959 

960 model_spec = agent_spec.get("model") or {} 

961 provider_name = model_spec.get("provider") 

962 

963 if not provider_name: 

964 if self._providers: 

965 provider_name = next(iter(self._providers)) 

966 else: 

967 raise _hard_failure( 

968 "VALIDATION_ERROR", 

969 "No LLM provider configured and no provider specified in agent spec", 

970 ) 

971 

972 provider_cfg = self._providers.get(provider_name) 

973 if provider_cfg is None: 

974 raise _hard_failure( 

975 "VALIDATION_ERROR", 

976 f"Unknown LLM provider '{provider_name}'. " 

977 f"Available: {', '.join(sorted(self._providers.keys()))}", 

978 ) 

979 

980 model_override = model_spec.get("model") 

981 model = model_override if model_override else provider_cfg["model"] 

982 

983 # Budget check 

984 if not self.budget.try_consume(): 

985 return { 

986 **state, 

987 step_id: _with_failure_flags( 

988 {"error": f"budget:{self.budget.stop_reason}"} 

989 ), 

990 } 

991 

992 # ---- Build system + user messages -------------------------------- 

993 skills_text = load_skills(resolved_spec) 

994 step_params = resolved_step.get("params") or {} 

995 input_value = resolved_step.get("input") 

996 

997 # Render system_prompt template with params, then inject the loaded 

998 # skills after the rendered prompt (ADR-0036 D1/D6). 

999 prompt_ctx: dict[str, Any] = { 

1000 k: v for k, v in step_params.items() if isinstance(k, str) 

1001 } 

1002 rendered_prompt = _render( 

1003 str(agent_spec.get("system_prompt") or ""), 

1004 prompt_ctx, 

1005 ).strip() 

1006 communication = resolved_step.get(COMMUNICATION_CONTEXT_KEY) 

1007 communication_text = ( 

1008 render_communication_block(communication) if communication else "" 

1009 ) 

1010 envelope_text = ENVELOPE_CONTRACT_BLOCK 

1011 system_text = "\n\n".join( 

1012 part 

1013 for part in ( 

1014 rendered_prompt, 

1015 skills_text, 

1016 envelope_text, 

1017 communication_text, 

1018 ) 

1019 if part 

1020 ) 

1021 

1022 # User message: serialise input + step params 

1023 user_text = _format_user_message(input_value, step_params) 

1024 

1025 # Temperature: agent spec → provider default → step params 

1026 temperature = float( 

1027 model_spec.get("temperature", provider_cfg.get("temperature", 0.0)) 

1028 ) 

1029 if "temperature" in step_params: 

1030 try: 

1031 temperature = float(step_params["temperature"]) 

1032 except (TypeError, ValueError): 

1033 pass 

1034 

1035 messages = [ 

1036 {"role": "system", "content": system_text}, 

1037 {"role": "user", "content": user_text}, 

1038 ] 

1039 

1040 # ---- Context-integrity check (Phase 4 doc, ## Kontekstin eheys) -- 

1041 agent_name = (resolved_spec.get("metadata") or {}).get("name", "agent") 

1042 context_budget = _effective_context_budget(resolved_spec, agent_name) 

1043 context_tokens = count_context_tokens(messages) 

1044 if context_tokens > context_budget: 

1045 return _with_context_metrics( 

1046 state, 

1047 step_id, 

1048 _context_overflow_result(step_id, context_tokens, context_budget), 

1049 {"context_size_tokens": context_tokens, "context_budget_tokens": context_budget}, 

1050 ) 

1051 measurement = { 

1052 "context_size_tokens": context_tokens, 

1053 "context_budget_tokens": context_budget, 

1054 } 

1055 

1056 # ---- LLM call with Langfuse generation --------------------------- 

1057 gen_name = f"{step_id}:{agent_name}" 

1058 

1059 with self._semaphore: 

1060 obs = self.tracer.generation( 

1061 gen_name, 

1062 model=model, 

1063 temperature=temperature, 

1064 messages=messages, 

1065 metadata=measurement, 

1066 ) 

1067 content = "" 

1068 usage: dict[str, int] | None = None 

1069 err: str | None = None 

1070 tool_meta: dict[str, Any] | None = None 

1071 try: 

1072 if resolved_tools: 

1073 content, usage, tool_meta = self._stream_completion_with_tools( 

1074 messages, 

1075 temperature, 

1076 model, 

1077 provider_cfg["base_url"], 

1078 provider_cfg["api_key"], 

1079 project_root=resolve_workspace_root(state), 

1080 tools=resolved_tools, 

1081 state=state, 

1082 step_id=step_id, 

1083 ) 

1084 else: 

1085 content, usage = self._stream_completion(messages, temperature, model, provider_cfg["base_url"], provider_cfg["api_key"]) 

1086 except Exception as exc: # noqa: BLE001 

1087 err = f"{type(exc).__name__}: {exc}" 

1088 log.warning("[invoke] %s LLM call failed: %s", step_id, err) 

1089 try: 

1090 obs.update( 

1091 output=content[:8000] if len(content) > 8000 else content, 

1092 usage_details=usage, 

1093 ) 

1094 except Exception: # noqa: BLE001 

1095 pass 

1096 try: 

1097 obs.end() 

1098 except Exception: # noqa: BLE001 

1099 pass 

1100 self.tracer.flush() 

1101 

1102 # ---- Parse output ------------------------------------------------ 

1103 if err: 

1104 return _with_context_metrics( 

1105 state, 

1106 step_id, 

1107 _with_failure_flags({"error": err, "raw": content}), 

1108 measurement, 

1109 tool_meta, 

1110 ) 

1111 

1112 if tool_meta and tool_meta.get("tool_failure"): 

1113 return _with_context_metrics( 

1114 state, 

1115 step_id, 

1116 _tool_failure_result(step_id, tool_meta["tool_failure"]), 

1117 measurement, 

1118 tool_meta, 

1119 ) 

1120 

1121 parsed, perr = _try_parse_json(content) 

1122 if parsed is not None: 

1123 verr = validate_envelope(parsed) 

1124 if verr is not None: 

1125 return _with_context_metrics( 

1126 state, 

1127 step_id, 

1128 { 

1129 "status": "error", 

1130 "error": { 

1131 "code": ENVELOPE_ERROR_CODE, 

1132 "message": f"ENVELOPE_ERROR: {verr}", 

1133 }, 

1134 }, 

1135 measurement, 

1136 ) 

1137 return _with_context_metrics( 

1138 state, step_id, parsed, measurement, tool_meta 

1139 ) 

1140 # Strict parse: a non-JSON response is an error envelope, not raw text. 

1141 return _with_context_metrics( 

1142 state, 

1143 step_id, 

1144 { 

1145 "status": "error", 

1146 "error": {"code": ENVELOPE_ERROR_CODE, "message": perr}, 

1147 }, 

1148 measurement, 

1149 tool_meta, 

1150 ) 

1151 

1152 # ---- Streaming completion ------------------------------------------- 

1153 

1154 def _stream_completion( 

1155 self, 

1156 messages: list[dict[str, str]], 

1157 temperature: float, 

1158 model: str, 

1159 base_url: str, 

1160 api_key: str, 

1161 ) -> tuple[str, dict[str, int] | None]: 

1162 client = self._openai_client(base_url, api_key) 

1163 resp = client.chat.completions.create( 

1164 model=model, 

1165 temperature=temperature, 

1166 messages=messages, 

1167 stream=True, 

1168 stream_options={"include_usage": True}, 

1169 ) 

1170 content = "" 

1171 usage: dict[str, int] | None = None 

1172 for chunk in resp: 

1173 if chunk.choices and chunk.choices[0].delta.content: 

1174 content += chunk.choices[0].delta.content 

1175 if getattr(chunk, "usage", None): 

1176 u = chunk.usage 

1177 usage = { 

1178 "input": u.prompt_tokens or 0, 

1179 "output": u.completion_tokens or 0, 

1180 "total": u.total_tokens or 0, 

1181 } 

1182 return content, usage 

1183 

1184 def _run_file_tool( 

1185 self, 

1186 tool_call: Any, 

1187 tool: Any, 

1188 project_root: Path, 

1189 state: dict, 

1190 parent_step_id: str, 

1191 ) -> ReadFileResult | ListDirResult: 

1192 """Execute a read_file or list_dir call through its resolved `Tool`. 

1193 

1194 File tools keep the soft malformed-arguments contract and return their 

1195 module result shape, so they stay out of the dispatched-call record. 

1196 """ 

1197 name = getattr(tool_call.function, "name", "") 

1198 try: 

1199 args = json.loads(getattr(tool_call.function, "arguments", "{}")) 

1200 except (TypeError, json.JSONDecodeError) as exc: 

1201 return _invalid_tool_arguments_result(name, f"invalid arguments: {exc}") 

1202 if not isinstance(args, dict): 

1203 return _invalid_tool_arguments_result( 

1204 name, 

1205 f"invalid arguments: expected a JSON object, got {type(args).__name__}", 

1206 ) 

1207 context = ToolContext( 

1208 args=args, 

1209 state=state, 

1210 root=project_root, 

1211 step=_synthesize_call_step(parent_step_id, 0, name, args, state), 

1212 invoker=self, 

1213 ) 

1214 return _file_result_from_tool_result(name, tool.execute(context)) 

1215 

1216 def _execute_tool_call( 

1217 self, 

1218 tool_call: Any, 

1219 project_root: Path, 

1220 tools: list[Any], 

1221 state: dict, 

1222 step_id: str, 

1223 ) -> ReadFileResult | ListDirResult | DispatchedToolResult: 

1224 """Route a native tool call: declaration gate, then file-access first.""" 

1225 name = getattr(tool_call.function, "name", "") 

1226 resolved_by_name = {tool.name: tool for tool in tools} 

1227 if name not in resolved_by_name: 

1228 raise _ToolHardFailure( 

1229 "CALL_NOT_ALLOWED", 

1230 f"tool '{name}' is not among the agent's declared tools", 

1231 ) 

1232 if name in (_READ_FILE_TOOL_NAME, _LIST_DIR_TOOL_NAME): 

1233 return self._run_file_tool( 

1234 tool_call, resolved_by_name[name], project_root, state, step_id 

1235 ) 

1236 return self._execute_deterministic_tool( 

1237 tool_call, resolved_by_name[name], step_id, state 

1238 ) 

1239 

1240 def _execute_deterministic_tool( 

1241 self, tool_call: Any, tool: Any, parent_step_id: str, state: dict 

1242 ) -> DispatchedToolResult: 

1243 """Execute a declared deterministic tool through the `Tool` contract.""" 

1244 name = getattr(tool_call.function, "name", "") 

1245 args = _parse_deterministic_tool_arguments(name, tool_call) 

1246 context = ToolContext( 

1247 args=args, 

1248 state=state, 

1249 root=resolve_workspace_root(state), 

1250 step=_synthesize_call_step(parent_step_id, 0, name, args, state), 

1251 invoker=self, 

1252 ) 

1253 result = _execute_tool_or_call_failed(tool, context, parent_step_id, name) 

1254 state_patch = result.state_patch or {} 

1255 return DispatchedToolResult( 

1256 name, 

1257 args, 

1258 result.content, 

1259 truncated=bool(state_patch.get("truncated", False)), 

1260 error=result.error, 

1261 ) 

1262 

1263 # ---- Tool-looping completion (resolved tool entries) ---------------- 

1264 

1265 def _stream_completion_with_tools( 

1266 self, 

1267 messages: list[dict[str, str]], 

1268 temperature: float, 

1269 model: str, 

1270 base_url: str, 

1271 api_key: str, 

1272 *, 

1273 project_root: Path, 

1274 tools: list[Any], 

1275 state: dict, 

1276 step_id: str, 

1277 ) -> tuple[str, dict[str, int] | None, dict[str, Any]]: 

1278 """Non-streaming completion carrying the resolved tool loop. 

1279 

1280 The request-level `tools` are exactly the resolved tools' definitions in 

1281 declaration order, and a tool-declaring agent's request carries them on 

1282 every round. Each tool round consumes ONE additional inference through 

1283 `self.budget.try_consume`; the loop is bounded only by the run budget and 

1284 stops when the model returns no `tool_calls` or the budget is exhausted. 

1285 A technical tool failure is surfaced via `tool_meta["tool_failure"]` for 

1286 the caller to turn into a hard error. The no-tools path never reaches 

1287 here. 

1288 """ 

1289 request_tools = [tool.definition() for tool in tools] 

1290 client = self._openai_client(base_url, api_key) 

1291 working = list(messages) 

1292 content = "" 

1293 usage: dict[str, int] | None = None 

1294 tool_rounds = 0 

1295 truncated = False 

1296 tool_errors: list[str] = [] 

1297 tool_failure: dict | None = None 

1298 dispatched_calls: list[dict] = [] 

1299 stop_reason: str | None = None 

1300 

1301 while True: 

1302 resp = client.chat.completions.create( 

1303 model=model, 

1304 temperature=temperature, 

1305 messages=working, 

1306 tools=request_tools, 

1307 stream=False, 

1308 ) 

1309 usage = _usage_from_response(resp) or usage 

1310 msg = resp.choices[0].message 

1311 piece = getattr(msg, "content", None) or "" 

1312 tool_calls = getattr(msg, "tool_calls", None) 

1313 if not tool_calls: 

1314 content = piece 

1315 if contains_tool_call_markup(piece): 

1316 tool_failure = { 

1317 "code": _TOOL_FAILURE_ERROR_CODE, 

1318 "message": ( 

1319 "ADR-0041 D3: assistant expressed a tool call as " 

1320 "content markup while native tool_calls was absent" 

1321 ), 

1322 } 

1323 break 

1324 working.append( 

1325 { 

1326 "role": "assistant", 

1327 "content": piece or "", 

1328 "tool_calls": [ 

1329 { 

1330 "id": tc.id, 

1331 "type": "function", 

1332 "function": {"name": tc.function.name, "arguments": tc.function.arguments}, 

1333 } 

1334 for tc in tool_calls 

1335 ], 

1336 } 

1337 ) 

1338 for tc in tool_calls: 

1339 try: 

1340 result = self._execute_tool_call( 

1341 tc, project_root, tools, state, step_id 

1342 ) 

1343 except _ToolHardFailure as exc: 

1344 tool_failure = {"code": exc.code, "message": exc.message} 

1345 break 

1346 except Exception as exc: # noqa: BLE001 

1347 tool_failure = _tool_failure_from_exception(exc) 

1348 break 

1349 truncated = truncated or result.truncated 

1350 if result.error: 

1351 tool_errors.append(result.error) 

1352 working.append( 

1353 {"role": "tool", "tool_call_id": tc.id, "content": result.as_tool_message()} 

1354 ) 

1355 if isinstance(result, DispatchedToolResult): 

1356 dispatched_calls.append( 

1357 { 

1358 "name": result.name, 

1359 "arguments": result.arguments, 

1360 "result": result.result, 

1361 } 

1362 ) 

1363 if tool_failure is not None: 

1364 break 

1365 tool_rounds += 1 

1366 if not self.budget.try_consume(): 

1367 stop_reason = _TOOL_STOP_REASON_BUDGET 

1368 break 

1369 

1370 tool_meta: dict[str, Any] = { 

1371 "tool_rounds": tool_rounds, 

1372 "truncated": truncated, 

1373 "tool_stopped": stop_reason is not None, 

1374 "tool_stop_reason": stop_reason, 

1375 "tool_errors": tool_errors, 

1376 "dispatched_calls": dispatched_calls, 

1377 } 

1378 if tool_failure is not None: 

1379 tool_meta["tool_failure"] = tool_failure 

1380 return content, usage, tool_meta 

1381 

1382 

1383# --------------------------------------------------------------------------- 

1384# Helpers 

1385# --------------------------------------------------------------------------- 

1386 

1387 

1388def _hard_failure(code: str, message: str, details: dict | None = None) -> Any: 

1389 """Build the engine's RunHardFailure without a module-level circular import.""" 

1390 try: 

1391 from runner import RunHardFailure 

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

1393 from langgraph.runner import RunHardFailure 

1394 return RunHardFailure(code, message, details) 

1395 

1396 

1397def _usage_from_response(resp: Any) -> dict[str, int] | None: 

1398 """OpenAI-vastauksen usage-dict (input/output/total) tai None, kun 

1399 provider ei palauta usagea.""" 

1400 u = getattr(resp, "usage", None) 

1401 if not u: 

1402 return None 

1403 return { 

1404 "input": getattr(u, "prompt_tokens", 0) or 0, 

1405 "output": getattr(u, "completion_tokens", 0) or 0, 

1406 "total": getattr(u, "total_tokens", 0) or 0, 

1407 } 

1408 

1409 

1410def _effective_context_budget(resolved_spec: dict, agent_name: str) -> int: 

1411 """Efektiivinen kontekstibudjetti: spec.limits.context_budget (merge- 

1412 järjestyksen tulos) tai oletus 100 000 tokenia. Epäkelpo arvo on kova 

1413 virhe (ADR-0012) — ei hiljaista fallbackia.""" 

1414 limits = (resolved_spec.get("spec") or {}).get("limits") or {} 

1415 value = limits.get("context_budget") 

1416 if value is None: 

1417 return DEFAULT_CONTEXT_BUDGET_TOKENS 

1418 if not isinstance(value, int) or isinstance(value, bool) or value <= 0: 

1419 raise _hard_failure( 

1420 "VALIDATION_ERROR", 

1421 f"agent '{agent_name}': spec.limits.context_budget must be a " 

1422 f"positive integer, got {value!r} (ADR-0012)", 

1423 ) 

1424 return value 

1425 

1426 

1427def _context_overflow_result(step_id: str, context_tokens: int, context_budget: int) -> dict: 

1428 """Budjetin ylitys → kova virhe ennen mallikutsua (fail-fast).""" 

1429 return { 

1430 "error": ( 

1431 f"{_CONTEXT_BUDGET_ERROR_CODE}: step '{step_id}' assembled " 

1432 f"context is {context_tokens} tokens, exceeding the context " 

1433 f"budget of {context_budget} tokens" 

1434 ), 

1435 "details": { 

1436 "error_code": _CONTEXT_BUDGET_ERROR_CODE, 

1437 "step_id": step_id, 

1438 "assembled_tokens": context_tokens, 

1439 "context_budget": context_budget, 

1440 }, 

1441 } 

1442 

1443 

1444def _tool_failure_from_exception(exc: Exception) -> dict: 

1445 """Map a dispatch exception to the hard-failure dict, preserving its code. 

1446 

1447 The deterministic dispatch wraps execution errors in a RunHardFailure 

1448 carrying a CALL_* code (CALL_FAILED / BUILTIN_NAME_COLLISION). ``_llm`` 

1449 cannot import ``runner`` without a cycle, so the code is duck-typed: any 

1450 exception exposing a non-empty string ``.code`` keeps that code; anything 

1451 else stays the legacy technical TOOL_FAILURE. 

1452 """ 

1453 code = getattr(exc, "code", None) 

1454 if isinstance(code, str) and code: 

1455 message = getattr(exc, "message", None) or f"{type(exc).__name__}: {exc}" 

1456 return {"code": code, "message": message} 

1457 return { 

1458 "code": _TOOL_FAILURE_ERROR_CODE, 

1459 "message": f"{type(exc).__name__}: {exc}", 

1460 } 

1461 

1462 

1463def _tool_failure_result(step_id: str, failure: str | dict) -> dict: 

1464 """Työkaluvika → kova virhe (HARD_ERROR_CODES). 

1465 

1466 A dict failure carries the explicit hard code (CALL_NOT_ALLOWED / 

1467 CALL_INVALID_PARAMS / CALL_FAILED); a bare string stays the legacy 

1468 technical TOOL_FAILURE. Odotetut domain-virheet (not found, 

1469 path-traversal, decode, malformed file-tool args) palaavat mallille 

1470 näkyvinä työkalutuloksina eivätkä kulje tästä (ADR-0012, ADR-0030 D8). 

1471 """ 

1472 if isinstance(failure, dict): 

1473 code = failure.get("code") or _TOOL_FAILURE_ERROR_CODE 

1474 message = failure.get("message") or "" 

1475 else: 

1476 code = _TOOL_FAILURE_ERROR_CODE 

1477 message = failure 

1478 return { 

1479 "error": ( 

1480 f"{code}: step '{step_id}' tool execution failed: {message}" 

1481 ), 

1482 "details": { 

1483 "error_code": code, 

1484 "step_id": step_id, 

1485 }, 

1486 } 

1487 

1488 

1489def _with_failure_flags(value: dict) -> dict: 

1490 """Tag an LLM-failure step result with the keys strict condition gates read. 

1491 

1492 A failing step result carries `escalate`/`verdict` so a strict gate like 

1493 `{{ research.escalate }}` resolves instead of raising an unresolved- 

1494 reference ValueError, and the failure routes to the escalation/qb surface. 

1495 Returns a new dict; the caller's `error`/`raw`/`parse_error` are preserved. 

1496 """ 

1497 return {**value, "escalate": True, "verdict": "ERROR"} 

1498 

1499 

1500def _with_context_metrics( 

1501 state: dict, 

1502 step_id: str, 

1503 step_value: Any, 

1504 measurement: dict[str, int], 

1505 tool_meta: dict[str, Any] | None = None, 

1506) -> dict: 

1507 """Kirjoita step-tulos state[step_id]-polkuun sellaisenaan — sama arvo, 

1508 polku ja tyyppi kuin ennen Phase 5:tä, ei mittausavaimia sisällä (Phase 

1509 5b) — ja kontekstimittaus rinnakkaiseen step_context_metrics-envelope- 

1510 avaimen alle (step_id → {context_size_tokens, context_budget_tokens}). 

1511 

1512 Työkalusilmukan metadata kirjoitetaan samalla rinnakkaiseen step_tool_meta- 

1513 avaimeen (step_id → tool-meta-dict), jotta state[step_id] pysyy täsmälleen 

1514 output-envelopena eikä saa ylimääräistä top-level-avainta (ADR-0023 D2/D9). 

1515 Aiemmat step_tool_meta-kirjoitukset säilytetään.""" 

1516 metrics = dict(state.get("step_context_metrics") or {}) 

1517 metrics[step_id] = measurement 

1518 result = {**state, step_id: step_value, "step_context_metrics": metrics} 

1519 if tool_meta: 

1520 tool_metas = dict(state.get("step_tool_meta") or {}) 

1521 tool_metas[step_id] = tool_meta 

1522 result["step_tool_meta"] = tool_metas 

1523 return result 

1524 

1525 

1526def _format_user_message(input_value: Any, params: dict[str, Any]) -> str: 

1527 """ 

1528 Muotoile agentin user-message resolvoidusta inputista + paramseista. 

1529 """ 

1530 parts: list[str] = [] 

1531 

1532 # Inputin esitysmuoto: yritetään olla informatiivisia 

1533 if input_value is None: 

1534 pass 

1535 elif isinstance(input_value, str): 

1536 parts.append(input_value) 

1537 elif isinstance(input_value, dict): 

1538 # Doc-file form: {path, content, ...} → human-readable 

1539 if "path" in input_value and "content" in input_value: 

1540 path = input_value.get("path", "") 

1541 content = input_value.get("content", "") 

1542 parts.append(f"Path: {path}\n\n---\n{content}") 

1543 else: 

1544 parts.append(json.dumps(input_value, ensure_ascii=False, indent=2)) 

1545 elif isinstance(input_value, list): 

1546 parts.append(json.dumps(input_value, ensure_ascii=False, indent=2)) 

1547 else: 

1548 parts.append(str(input_value)) 

1549 

1550 # Lisää muut paramit (focus, finding jne.) jos eivät jo ole system_promptissa 

1551 extra: dict[str, Any] = {} 

1552 skip_keys = {"skills"} 

1553 for k, v in params.items(): 

1554 if k in skip_keys: 

1555 continue 

1556 # Älä toista pieniä skalaareja - ne yleensä jo system_promptissa 

1557 if isinstance(v, (dict, list)): 

1558 extra[k] = v 

1559 if extra: 

1560 parts.append("## Additional context\n\n" + json.dumps(extra, ensure_ascii=False, indent=2)) 

1561 

1562 return "\n\n".join(p for p in parts if p)