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

643 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-08 02:20 +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- yhdistää AgentInstance-spec:n tiedoista täydellisen system_promptin 

8 (skill-tiedostot luettu + `{{ skills }}` korvattu + params-substituutio) 

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

10- ajaa OpenAI-streamin DeepSeek-yhteensopivalla endpointilla 

11- raportoi Langfuse-generationina pipeline-trace alle 

12- parsii vastauksen JSON-objektiksi jos mahdollista (audit/triage/review), 

13 muuten palauttaa raw-markdownin (ticket writer/refiner) 

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

15 

16API: 

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

18 new_state = invoker(resolved_spec, resolved_step, state) 

19""" 

20from __future__ import annotations 

21 

22import contextvars 

23import json 

24import logging 

25import os 

26import re 

27import subprocess 

28import threading 

29import time 

30from dataclasses import dataclass, field 

31from pathlib import Path 

32from typing import Any 

33 

34log = logging.getLogger(__name__) 

35 

36# Files-output helper (output.type: files) — sama dual-import-kuvio kuin 

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

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

39try: 

40 from _files_output import ( 

41 FilesOutputError, 

42 resolve_workspace_root, 

43 validate_and_write_files, 

44 ) 

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

46 from langgraph._files_output import ( 

47 FilesOutputError, 

48 resolve_workspace_root, 

49 validate_and_write_files, 

50 ) 

51 

52# Shared structural-skip set for the list_dir tool (ADR-0012). Imported from 

53# _builtins to avoid duplicating the 8-entry set; _builtins does not import 

54# _llm (only stdlib + exec_runner + _files_output), so this is cycle-safe. 

55try: 

56 from _builtins import _DEFAULT_SKIP_DIRS 

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

58 from langgraph._builtins import _DEFAULT_SKIP_DIRS 

59 

60SKILL_DIR = Path("/skills") 

61 

62 

63class SkillLoadError(ValueError): 

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

65 

66 Erottaa skill-resoluution virheet muista ValueErrorista: runnerin 

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

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

69 """ 

70 

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

72# koottu konteksti toimitetaan mallikutsulle kokonaisuudessaan tai step 

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

74DEFAULT_CONTEXT_BUDGET_TOKENS = 100_000 

75_CONTEXT_BUDGET_ERROR_CODE = "VALIDATION_ERROR" 

76_FILES_OUTPUT_ERROR_CODE = "FILES_OUTPUT_ERROR" 

77_TIKTOKEN_ENCODING = "cl100k_base" 

78 

79# --- Opt-in file-access tool (agent spec.file_access: true) --- 

80# One bounded read_file tool + a tool-execution loop. A tool round = one 

81# additional inference call consumed through the run budget. 

82_READ_FILE_TOOL_NAME = "read_file" 

83_LIST_DIR_TOOL_NAME = "list_dir" 

84_MAX_TOOL_ROUNDS = 3 

85_READ_FILE_MAX_BYTES = 200_000 

86_READ_FILE_MAX_LINES = 2000 

87_LIST_DIR_MAX_ENTRIES = 512 

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

89 

90_LANGFUSE_FLUSH_EVERY = 8 

91 

92 

93# --------------------------------------------------------------------------- 

94# RunBudget — deterministinen pipeline-ajon suoritusbudjetti 

95# --------------------------------------------------------------------------- 

96 

97 

98@dataclass 

99class RunBudget: 

100 max_llm_calls: int = 48 

101 run_timeout_seconds: int = 240 

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

103 llm_calls: int = 0 

104 stopped: bool = False 

105 stop_reason: str = "" 

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

107 

108 def try_consume(self) -> bool: 

109 with self._lock: 

110 if self.stopped: 

111 return False 

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

113 self.stopped = True 

114 self.stop_reason = "run_timeout" 

115 return False 

116 if self.llm_calls >= self.max_llm_calls: 

117 self.stopped = True 

118 self.stop_reason = "max_llm_calls" 

119 return False 

120 self.llm_calls += 1 

121 return True 

122 

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

124 return { 

125 "llm_calls": self.llm_calls, 

126 "budget_stopped": self.stopped, 

127 "budget_stop_reason": self.stop_reason or None, 

128 } 

129 

130 

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

132# Skill loading 

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

134 

135 

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

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

138 

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

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

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

142 """ 

143 raw = os.getenv("SKILL_REPOS") 

144 if not raw: 

145 return [] 

146 try: 

147 repos = json.loads(raw) 

148 except json.JSONDecodeError as exc: 

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

150 if not isinstance(repos, list): 

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

152 for idx, repo in enumerate(repos): 

153 if not isinstance(repo, dict): 

154 raise SkillLoadError( 

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

156 ) 

157 name = repo.get("name") 

158 url = repo.get("url") 

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

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

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

162 raise SkillLoadError( 

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

164 ) 

165 return repos 

166 

167 

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

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

170 

171 Ajaa hardcodetun find-komennon (ei parametrisoitava): 

172 

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

174 -o -type d -name skills -print 

175 

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

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

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

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

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

181 ohitusta. 

182 """ 

183 cmd = [ 

184 "find", 

185 str(repo_mount), 

186 "-mindepth", 

187 "1", 

188 "-type", 

189 "d", 

190 "-name", 

191 ".git", 

192 "-prune", 

193 "-o", 

194 "-type", 

195 "d", 

196 "-name", 

197 "skills", 

198 "-print", 

199 ] 

200 try: 

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

202 except OSError as exc: 

203 raise SkillLoadError( 

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

205 ) from exc 

206 if proc.returncode != 0: 

207 raise SkillLoadError( 

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

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

210 ) 

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

212 if not hits: 

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

214 if len(hits) > 1: 

215 raise SkillLoadError( 

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

217 ) 

218 return Path(hits[0]) 

219 

220 

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

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

223 

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

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

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

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

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

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

230 koodirepon puuttuvan skills/-kansion hiljainen ohitus pysyy ennallaan 

231 (resoluutio ohittaa puuttuvan kirjaston). 

232 """ 

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

234 return [project_root / "skills"] + [ 

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

236 ] 

237 

238 

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

240 """ 

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

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

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

244 ei hiljaista katkaisua. 

245 

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

247 injektoidaan otsakkeilla (SKILL.md ensin, sitten loput 

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

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

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

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

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

253 

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

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

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

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

258 ohitetaan hiljaa. 

259 

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

261 etsityt kirjastopolut ja saatavilla olevat skillit. Ei hiljaista 

262 ohitusta. 

263 """ 

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

265 if not skills: 

266 return "" 

267 libs = _normalize_skill_libs() 

268 parts: list[str] = [] 

269 for entry in skills: 

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

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

272 

273 

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

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

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

277 

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

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

280 eivät ole sallittuja). 

281 """ 

282 if not entry.strip(): 

283 raise SkillLoadError( 

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

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

286 ) 

287 if entry.startswith("/"): 

288 raise SkillLoadError( 

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

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

291 ) 

292 parts = entry.split("/") 

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

294 raise SkillLoadError( 

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

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

297 ) 

298 if len(parts) == 1: 

299 return entry, None 

300 if len(parts) > 2: 

301 raise SkillLoadError( 

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

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

304 ) 

305 skill, filename = parts 

306 return skill, filename 

307 

308 

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

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

311 

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

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

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

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

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

317 

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

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

320 tiedosto-entrylle kansion löytyminen ilman kohdetiedostoa ohittaa 

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

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

323 """ 

324 name, filename = _parse_skill_entry(entry) 

325 for lib in libs: 

326 folder = lib / name 

327 if filename is None: 

328 if folder.is_dir(): 

329 parts: list[str] = [] 

330 for path in _skill_folder_files(folder): 

331 relpath = path.relative_to(folder) 

332 parts.append( 

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

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

335 ) 

336 return parts 

337 if folder.is_file(): 

338 raise SkillLoadError( 

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

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

341 ) 

342 else: 

343 file_path = folder / filename 

344 if file_path.is_file(): 

345 return [ 

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

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

348 ] 

349 if folder.is_dir(): 

350 continue 

351 raise SkillLoadError(_unresolved_skill_error(entry, libs)) 

352 

353 

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

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

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

357 aakkosjärjestyksessä.""" 

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

359 files.sort( 

360 key=lambda p: ( 

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

362 str(p.relative_to(folder)), 

363 ) 

364 ) 

365 return files 

366 

367 

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

369 try: 

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

371 except OSError as exc: 

372 raise SkillLoadError( 

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

374 ) from exc 

375 if len(text) > max_chars: 

376 raise SkillLoadError( 

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

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

379 ) 

380 return text 

381 

382 

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

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

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

386 names: set[str] = set() 

387 for lib in libs: 

388 if not lib.is_dir(): 

389 continue 

390 for child in lib.iterdir(): 

391 if child.is_dir(): 

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

393 names.add(child.name) 

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

395 names.add(child.name) 

396 return sorted(names) 

397 

398 

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

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

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

402 return ( 

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

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

405 ) 

406 

407 

408# --------------------------------------------------------------------------- 

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

410# --------------------------------------------------------------------------- 

411 

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

413 

414 

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

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

417 return text 

418 

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

420 cur: Any = ctx 

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

422 if isinstance(cur, dict): 

423 cur = cur.get(part) 

424 else: 

425 return "" 

426 if cur is None: 

427 return "" 

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

429 

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

431 

432 

433# --------------------------------------------------------------------------- 

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

435# --------------------------------------------------------------------------- 

436 

437_tokenizer_encoding: Any | None = None 

438_tokenizer_lock = threading.Lock() 

439 

440 

441def _tokenizer() -> Any: 

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

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

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

445 global _tokenizer_encoding 

446 if _tokenizer_encoding is None: 

447 with _tokenizer_lock: 

448 if _tokenizer_encoding is None: 

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

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

451 from tiktoken import get_encoding 

452 

453 _tokenizer_encoding = get_encoding(_TIKTOKEN_ENCODING) 

454 return _tokenizer_encoding 

455 

456 

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

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

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

460 enc = _tokenizer() 

461 total = 0 

462 for message in messages: 

463 content = message.get("content") 

464 if isinstance(content, str): 

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

466 return total 

467 

468 

469# --------------------------------------------------------------------------- 

470# Langfuse trace helpers 

471# --------------------------------------------------------------------------- 

472 

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

474 "langfuse_parent_stack", default=() 

475) 

476 

477 

478class LangfuseTracer: 

479 """ 

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

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

482 """ 

483 

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

485 self.project_slug = project_slug 

486 self.pipeline_name = pipeline_name 

487 self.run_id = run_id or "unknown" 

488 self._lf: Any = None 

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

490 self._flush_counter = 0 

491 self._init_done = False 

492 

493 def _client(self) -> Any: 

494 if self._lf is not None: 

495 return self._lf 

496 try: 

497 from langfuse import Langfuse # type: ignore 

498 except ImportError: 

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

500 return None 

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

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

503 try: 

504 self._lf = Langfuse() 

505 except Exception as exc: # noqa: BLE001 

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

507 self._lf = None 

508 finally: 

509 if prev is None: 

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

511 else: 

512 os.environ["LANGFUSE_TRACING_ENABLED"] = prev 

513 return self._lf 

514 

515 @property 

516 def trace_name(self) -> str: 

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

518 

519 @property 

520 def session_id(self) -> str: 

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

522 

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

524 lf = self._client() 

525 if lf is None: 

526 return None 

527 if self._trace_ctx is None: 

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

529 return self._trace_ctx 

530 

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

532 ctx = self._ctx() 

533 if ctx is None: 

534 return None 

535 stack = _PARENT_STACK.get() 

536 if stack: 

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

538 return ctx 

539 

540 def open_parent_observation( 

541 self, 

542 *, 

543 name: str, 

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

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

546 ) -> Any: 

547 lf = self._client() 

548 ctx = self._ctx_with_parent() 

549 if lf is None or ctx is None: 

550 return _NoopObservation() 

551 try: 

552 obs = lf.start_observation( 

553 trace_context=ctx, 

554 name=name, 

555 as_type="span", 

556 input=input or {}, 

557 metadata=metadata or {}, 

558 ) 

559 except Exception as exc: # noqa: BLE001 

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

561 return _NoopObservation() 

562 stack = _PARENT_STACK.get() 

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

564 return obs 

565 

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

567 if isinstance(obs, _NoopObservation): 

568 return 

569 stack = _PARENT_STACK.get() 

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

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

572 obs.end() 

573 

574 def init_trace(self) -> None: 

575 if self._init_done: 

576 return 

577 lf = self._client() 

578 ctx = self._ctx() 

579 if lf is None or ctx is None: 

580 self._init_done = True 

581 return 

582 try: 

583 obs = lf.start_observation( 

584 trace_context=ctx, 

585 name=self.trace_name, 

586 as_type="span", 

587 input={ 

588 "pipeline": self.pipeline_name, 

589 "project": self.project_slug, 

590 "run_id": self.run_id, 

591 }, 

592 ) 

593 self._apply_meta(obs) 

594 obs.end() 

595 self.flush(force=True) 

596 except Exception as exc: # noqa: BLE001 

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

598 self._init_done = True 

599 

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

601 try: 

602 obs.update_trace( 

603 name=self.trace_name, 

604 user_id=self.project_slug, 

605 session_id=self.session_id, 

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

607 ) 

608 except Exception: # noqa: BLE001 

609 pass 

610 

611 def generation( 

612 self, 

613 name: str, 

614 *, 

615 model: str, 

616 temperature: float, 

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

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

619 ) -> Any: 

620 lf = self._client() 

621 ctx = self._ctx() 

622 if lf is None or ctx is None: 

623 return _NoopObservation() 

624 try: 

625 obs = lf.start_observation( 

626 trace_context=self._ctx_with_parent(), 

627 name=name, 

628 as_type="generation", 

629 model=model, 

630 model_parameters={"temperature": temperature}, 

631 input=messages, 

632 metadata=metadata, 

633 ) 

634 self._apply_meta(obs) 

635 return obs 

636 except Exception as exc: # noqa: BLE001 

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

638 return _NoopObservation() 

639 

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

641 lf = self._client() 

642 if lf is None: 

643 return 

644 self._flush_counter += 1 

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

646 try: 

647 lf.flush() 

648 except Exception: # noqa: BLE001 

649 pass 

650 

651 

652class _NoopObservation: 

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

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

655 

656 

657# --------------------------------------------------------------------------- 

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

659# --------------------------------------------------------------------------- 

660 

661 

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

663 """Yritä parsia JSON. Palauttaa (data, error). data=None jos epäonnistui.""" 

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

665 return None, "empty" 

666 raw = text.strip() 

667 fence = re.search(r"```(?:json)?\s*([\s\S]*?)```", raw) 

668 if fence: 

669 raw = fence.group(1).strip() 

670 else: 

671 m = re.search(r"\{[\s\S]*\}|\[[\s\S]*\]", raw) 

672 if m: 

673 raw = m.group(0) 

674 try: 

675 return json.loads(raw), None 

676 except json.JSONDecodeError as e: 

677 # Yritys: korjaa katkennut JSON 

678 repaired = raw 

679 if repaired.count('"') % 2 == 1: 

680 repaired = repaired.rsplit('"', 1)[0] + '"' 

681 repaired = re.sub(r",\s*$", "", repaired.rstrip()) 

682 for suffix in ("", "}", "]}", "\"]}"): 

683 try: 

684 return json.loads(repaired + suffix), None 

685 except json.JSONDecodeError: 

686 continue 

687 return None, str(e) 

688 

689 

690# --------------------------------------------------------------------------- 

691# File-access tool (agent spec.file_access: true) 

692# --------------------------------------------------------------------------- 

693 

694 

695@dataclass 

696class ReadFileResult: 

697 content: str 

698 truncated: bool 

699 error: str | None 

700 

701 def as_tool_message(self) -> str: 

702 payload: dict[str, Any] = { 

703 "name": _READ_FILE_TOOL_NAME, 

704 "ok": self.error is None, 

705 "content": self.content, 

706 "truncated": self.truncated, 

707 } 

708 if self.error is not None: 

709 payload["error"] = self.error 

710 return json.dumps(payload, ensure_ascii=False) 

711 

712 

713_READ_FILE_TOOL: dict[str, Any] = { 

714 "type": "function", 

715 "function": { 

716 "name": _READ_FILE_TOOL_NAME, 

717 "description": ( 

718 "Read a text file from the project root. The path is resolved " 

719 "against the project root; any read that resolves outside the " 

720 "project root is rejected. Content is truncated and flagged when " 

721 "it exceeds the caps, and can be windowed by offset/limit (lines)." 

722 ), 

723 "parameters": { 

724 "type": "object", 

725 "properties": { 

726 "path": { 

727 "type": "string", 

728 "description": "Path to the file, relative to the project root.", 

729 }, 

730 "offset": { 

731 "type": "integer", 

732 "description": "Number of lines to skip (0-based). Optional.", 

733 }, 

734 "limit": { 

735 "type": "integer", 

736 "description": "Max number of lines to return. Optional.", 

737 }, 

738 }, 

739 "required": ["path"], 

740 "additionalProperties": False, 

741 }, 

742 }, 

743} 

744 

745 

746def _is_within(root: Path, candidate: Path) -> bool: 

747 return candidate == root or root in candidate.parents 

748 

749 

750def _read_file( 

751 project_root: Path, 

752 path: str, 

753 offset: int | None, 

754 limit: int | None, 

755) -> ReadFileResult: 

756 """Realpath-bound read of a text file inside the project root (ADR-0012). 

757 

758 Every rejected read returns a clear error — no silent fallback. Truncation 

759 over `_READ_FILE_MAX_BYTES` / `_READ_FILE_MAX_LINES` is explicit via 

760 `truncated`. 

761 """ 

762 if not path or not str(path).strip(): 

763 return ReadFileResult("", False, f"{_READ_FILE_TOOL_NAME}: 'path' is required (ADR-0012)") 

764 try: 

765 root = project_root.resolve(strict=False) 

766 except OSError as exc: 

767 return ReadFileResult("", False, f"{_READ_FILE_TOOL_NAME}: cannot resolve project root: {exc}") 

768 try: 

769 candidate = (root / path).resolve(strict=False) 

770 except OSError as exc: 

771 return ReadFileResult("", False, f"{_READ_FILE_TOOL_NAME}: cannot resolve '{path}': {exc}") 

772 if not _is_within(root, candidate): 

773 return ReadFileResult( 

774 "", 

775 False, 

776 f"{_READ_FILE_TOOL_NAME}: path '{path}' resolves outside project root (ADR-0012)", 

777 ) 

778 if not candidate.is_file(): 

779 return ReadFileResult("", False, f"{_READ_FILE_TOOL_NAME}: '{path}' is not a file or does not exist") 

780 try: 

781 text = candidate.read_text(encoding="utf-8") 

782 except (OSError, UnicodeDecodeError) as exc: 

783 return ReadFileResult("", False, f"{_READ_FILE_TOOL_NAME}: cannot read '{path}': {exc}") 

784 

785 lines = text.splitlines() 

786 total_lines = len(lines) 

787 try: 

788 start = int(offset) if offset is not None and int(offset) > 0 else 0 

789 except (TypeError, ValueError): 

790 start = 0 

791 if limit is not None: 

792 # A requested window is not an engine cap — no line-level truncation flag. 

793 try: 

794 max_lines = int(limit) 

795 except (TypeError, ValueError): 

796 max_lines = _READ_FILE_MAX_LINES 

797 truncated = False 

798 end = start + max_lines if max_lines >= 0 else total_lines 

799 else: 

800 max_lines = _READ_FILE_MAX_LINES 

801 truncated = (start + max_lines) < total_lines 

802 end = start + max_lines 

803 content = "\n".join(lines[start:end]) 

804 

805 if len(content.encode("utf-8")) > _READ_FILE_MAX_BYTES: 

806 content = content[:_READ_FILE_MAX_BYTES] 

807 truncated = True 

808 return ReadFileResult(content, truncated, None) 

809 

810 

811@dataclass 

812class ListDirEntry: 

813 path: str 

814 name: str 

815 size: int | None 

816 kind: str # "dir" | "file" 

817 

818 

819@dataclass 

820class ListDirResult: 

821 entries: list[ListDirEntry] 

822 truncated: bool 

823 root: str 

824 error: str | None 

825 

826 def as_tool_message(self) -> str: 

827 payload: dict[str, Any] = { 

828 "name": _LIST_DIR_TOOL_NAME, 

829 "ok": self.error is None, 

830 "entries": [ 

831 {"path": e.path, "name": e.name, "size": e.size, "kind": e.kind} 

832 for e in self.entries 

833 ], 

834 "truncated": self.truncated, 

835 "root": self.root, 

836 } 

837 if self.error is not None: 

838 payload["error"] = self.error 

839 return json.dumps(payload, ensure_ascii=False) 

840 

841 

842_LIST_DIR_TOOL: dict[str, Any] = { 

843 "type": "function", 

844 "function": { 

845 "name": _LIST_DIR_TOOL_NAME, 

846 "description": ( 

847 "List ONE level of a directory inside the project root. The path is " 

848 "resolved against the project root; any listing that resolves " 

849 "outside the project root is rejected. Non-recursive: nested " 

850 "subdirectories are not expanded. Only structural skip dirs are " 

851 "omitted. The listing is capped and flagged when it exceeds the cap." 

852 ), 

853 "parameters": { 

854 "type": "object", 

855 "properties": { 

856 "path": { 

857 "type": "string", 

858 "description": "Directory path, relative to the project root.", 

859 }, 

860 }, 

861 "required": ["path"], 

862 "additionalProperties": False, 

863 }, 

864 }, 

865} 

866 

867 

868def _execute_list_dir_tool(project_root: Path, path: str) -> ListDirResult: 

869 """Realpath-bound ONE-level directory listing inside the project root 

870 (ADR-0012). Every rejected listing returns a clear error — no silent 

871 fallback. Only structural skip dirs are omitted; entries over 

872 `_LIST_DIR_MAX_ENTRIES` are capped and flagged via `truncated`. 

873 """ 

874 if not path or not str(path).strip(): 

875 return ListDirResult( 

876 [], False, "", f"{_LIST_DIR_TOOL_NAME}: 'path' is required (ADR-0012)" 

877 ) 

878 try: 

879 root = project_root.resolve(strict=False) 

880 except OSError as exc: 

881 return ListDirResult( 

882 [], False, "", f"{_LIST_DIR_TOOL_NAME}: cannot resolve project root: {exc}" 

883 ) 

884 try: 

885 candidate = (root / str(path)).resolve(strict=False) 

886 except OSError as exc: 

887 return ListDirResult( 

888 [], False, "", f"{_LIST_DIR_TOOL_NAME}: cannot resolve '{path}': {exc}" 

889 ) 

890 if not _is_within(root, candidate): 

891 return ListDirResult( 

892 [], 

893 False, 

894 "", 

895 f"{_LIST_DIR_TOOL_NAME}: path '{path}' resolves outside project root (ADR-0012)", 

896 ) 

897 if not candidate.is_dir(): 

898 return ListDirResult( 

899 [], 

900 False, 

901 "", 

902 f"{_LIST_DIR_TOOL_NAME}: '{path}' is not a directory or does not exist", 

903 ) 

904 try: 

905 children = sorted(candidate.iterdir(), key=lambda p: p.name) 

906 except OSError as exc: 

907 return ListDirResult( 

908 [], False, "", f"{_LIST_DIR_TOOL_NAME}: cannot list '{path}': {exc}" 

909 ) 

910 

911 entries: list[ListDirEntry] = [] 

912 for child in children: 

913 if child.name in _DEFAULT_SKIP_DIRS: 

914 continue 

915 is_dir = child.is_dir() 

916 size: int | None = None 

917 if not is_dir: 

918 try: 

919 size = child.stat().st_size 

920 except OSError: 

921 size = None 

922 entries.append( 

923 ListDirEntry( 

924 path=str(child.relative_to(root)), 

925 name=child.name, 

926 size=size, 

927 kind="dir" if is_dir else "file", 

928 ) 

929 ) 

930 truncated = len(entries) > _LIST_DIR_MAX_ENTRIES 

931 if truncated: 

932 entries = entries[:_LIST_DIR_MAX_ENTRIES] 

933 return ListDirResult(entries, truncated, str(root), None) 

934 

935 

936# --------------------------------------------------------------------------- 

937# OpenAIInvoker 

938# --------------------------------------------------------------------------- 

939 

940 

941class OpenAIInvoker: 

942 """ 

943 Default LLM invoker for agent-platform pipelines. 

944 

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

946 """ 

947 

948 def __init__( 

949 self, 

950 *, 

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

952 max_parallel: int, 

953 budget: RunBudget, 

954 tracer: LangfuseTracer, 

955 ) -> None: 

956 self._providers = providers 

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

958 self.budget = budget 

959 self.tracer = tracer 

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

961 

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

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

964 if key not in self._clients: 

965 from openai import OpenAI # type: ignore 

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

967 return self._clients[key] 

968 

969 # ---- Public callable ------------------------------------------------- 

970 

971 def __call__( 

972 self, 

973 resolved_spec: dict, 

974 resolved_step: dict, 

975 state: dict, 

976 ) -> dict: 

977 """ 

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

979 

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

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

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

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

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

985 """ 

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

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

988 

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

990 provider_name = model_spec.get("provider") 

991 

992 if not provider_name: 

993 if self._providers: 

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

995 else: 

996 raise ValueError("No LLM provider configured and no provider specified in agent spec") 

997 

998 provider_cfg = self._providers.get(provider_name) 

999 if provider_cfg is None: 

1000 raise ValueError( 

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

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

1003 ) 

1004 

1005 model_override = model_spec.get("model") 

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

1007 

1008 # Budget check 

1009 if not self.budget.try_consume(): 

1010 return { 

1011 **state, 

1012 step_id: _with_failure_flags( 

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

1014 ), 

1015 } 

1016 

1017 # ---- Build system + user messages -------------------------------- 

1018 skills_text = load_skills(resolved_spec) 

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

1020 input_value = resolved_step.get("input") 

1021 

1022 # Auto-feature: path verification block (filled by builtin path-verifier 

1023 # earlier in pipeline, or empty string if not present). 

1024 if "path_verification_block" not in step_params: 

1025 step_params = {**step_params, "path_verification_block": ""} 

1026 

1027 # Render system_prompt template with skills + params 

1028 prompt_ctx: dict[str, Any] = { 

1029 "skills": skills_text, 

1030 **{k: v for k, v in step_params.items() if isinstance(k, str)}, 

1031 } 

1032 system_text = _render( 

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

1034 prompt_ctx, 

1035 ).strip() 

1036 

1037 # User message: serialise input + step params 

1038 user_text = _format_user_message(input_value, step_params) 

1039 

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

1041 temperature = float( 

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

1043 ) 

1044 if "temperature" in step_params: 

1045 try: 

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

1047 except (TypeError, ValueError): 

1048 pass 

1049 

1050 messages = [ 

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

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

1053 ] 

1054 

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

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

1057 context_budget = _effective_context_budget(resolved_spec, agent_name) 

1058 context_tokens = count_context_tokens(messages) 

1059 if context_tokens > context_budget: 

1060 return _with_context_metrics( 

1061 state, 

1062 step_id, 

1063 _context_overflow_result(step_id, context_tokens, context_budget), 

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

1065 ) 

1066 measurement = { 

1067 "context_size_tokens": context_tokens, 

1068 "context_budget_tokens": context_budget, 

1069 } 

1070 

1071 # ---- LLM call with Langfuse generation --------------------------- 

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

1073 

1074 with self._semaphore: 

1075 obs = self.tracer.generation( 

1076 gen_name, 

1077 model=model, 

1078 temperature=temperature, 

1079 messages=messages, 

1080 metadata=measurement, 

1081 ) 

1082 content = "" 

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

1084 err: str | None = None 

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

1086 try: 

1087 if bool(agent_spec.get("file_access")): 

1088 content, usage, tool_meta = self._stream_completion_with_tools( 

1089 messages, 

1090 temperature, 

1091 model, 

1092 provider_cfg["base_url"], 

1093 provider_cfg["api_key"], 

1094 project_root=resolve_workspace_root(state), 

1095 ) 

1096 else: 

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

1098 except Exception as exc: # noqa: BLE001 

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

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

1101 try: 

1102 obs.update( 

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

1104 usage_details=usage, 

1105 ) 

1106 except Exception: # noqa: BLE001 

1107 pass 

1108 try: 

1109 obs.end() 

1110 except Exception: # noqa: BLE001 

1111 pass 

1112 self.tracer.flush() 

1113 

1114 # ---- Parse output ------------------------------------------------ 

1115 if err: 

1116 return _with_context_metrics( 

1117 state, 

1118 step_id, 

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

1120 measurement, 

1121 ) 

1122 

1123 output_type = ((agent_spec.get("output") or {}).get("type") or "").lower() 

1124 if output_type == "files": 

1125 return _handle_files_output( 

1126 state, step_id, content, measurement, agent_spec, tool_meta 

1127 ) 

1128 

1129 output_format = ((agent_spec.get("output") or {}).get("format") or "").lower() 

1130 if output_format == "markdown": 

1131 return _with_context_metrics( 

1132 state, 

1133 step_id, 

1134 _apply_tool_meta({"output": content.strip()}, tool_meta), 

1135 measurement, 

1136 ) 

1137 

1138 parsed, perr = _try_parse_json(content) 

1139 if parsed is not None: 

1140 return _with_context_metrics( 

1141 state, step_id, _apply_tool_meta(parsed, tool_meta), measurement 

1142 ) 

1143 # Fallback: raw text 

1144 return _with_context_metrics( 

1145 state, 

1146 step_id, 

1147 _apply_tool_meta( 

1148 _with_failure_flags({"output": content.strip(), "parse_error": perr}), 

1149 tool_meta, 

1150 ), 

1151 measurement, 

1152 ) 

1153 

1154 # ---- Streaming completion ------------------------------------------- 

1155 

1156 def _stream_completion( 

1157 self, 

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

1159 temperature: float, 

1160 model: str, 

1161 base_url: str, 

1162 api_key: str, 

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

1164 client = self._openai_client(base_url, api_key) 

1165 resp = client.chat.completions.create( 

1166 model=model, 

1167 temperature=temperature, 

1168 messages=messages, 

1169 stream=True, 

1170 stream_options={"include_usage": True}, 

1171 ) 

1172 content = "" 

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

1174 for chunk in resp: 

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

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

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

1178 u = chunk.usage 

1179 usage = { 

1180 "input": u.prompt_tokens or 0, 

1181 "output": u.completion_tokens or 0, 

1182 "total": u.total_tokens or 0, 

1183 } 

1184 return content, usage 

1185 

1186 def _execute_file_tool( 

1187 self, tool_call: Any, project_root: Path 

1188 ) -> ReadFileResult | ListDirResult: 

1189 """Execute a read_file or list_dir tool call against the project root.""" 

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

1191 try: 

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

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

1194 if name == _LIST_DIR_TOOL_NAME: 

1195 return ListDirResult([], False, "", f"{_LIST_DIR_TOOL_NAME}: invalid arguments: {exc}") 

1196 return ReadFileResult("", False, f"{_READ_FILE_TOOL_NAME}: invalid arguments: {exc}") 

1197 if name == _LIST_DIR_TOOL_NAME: 

1198 return _execute_list_dir_tool(project_root, args.get("path")) 

1199 return _read_file(project_root, args.get("path"), args.get("offset"), args.get("limit")) 

1200 

1201 # ---- Tool-looping completion (file_access: true) -------------------- 

1202 

1203 def _stream_completion_with_tools( 

1204 self, 

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

1206 temperature: float, 

1207 model: str, 

1208 base_url: str, 

1209 api_key: str, 

1210 *, 

1211 project_root: Path, 

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

1213 """Non-streaming completion carrying the read_file tool loop. 

1214 

1215 Each tool round consumes ONE additional inference through 

1216 `self.budget.try_consume`; the loop is hard-capped at `_MAX_TOOL_ROUNDS` 

1217 and stops instead of looping indefinitely. The non-flagged path never 

1218 reaches here. 

1219 """ 

1220 client = self._openai_client(base_url, api_key) 

1221 working = list(messages) 

1222 content = "" 

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

1224 tool_rounds = 0 

1225 truncated = False 

1226 tool_stopped = False 

1227 tool_errors: list[str] = [] 

1228 

1229 while True: 

1230 resp = client.chat.completions.create( 

1231 model=model, 

1232 temperature=temperature, 

1233 messages=working, 

1234 tools=[_READ_FILE_TOOL, _LIST_DIR_TOOL], 

1235 stream=False, 

1236 ) 

1237 if getattr(resp, "usage", None): 

1238 u = resp.usage 

1239 usage = { 

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

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

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

1243 } 

1244 msg = resp.choices[0].message 

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

1246 if piece: 

1247 content += piece 

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

1249 if not tool_calls: 

1250 break 

1251 working.append( 

1252 { 

1253 "role": "assistant", 

1254 "content": piece or "", 

1255 "tool_calls": [ 

1256 { 

1257 "id": tc.id, 

1258 "type": "function", 

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

1260 } 

1261 for tc in tool_calls 

1262 ], 

1263 } 

1264 ) 

1265 for tc in tool_calls: 

1266 result = self._execute_file_tool(tc, project_root) 

1267 truncated = truncated or result.truncated 

1268 if result.error: 

1269 tool_errors.append(result.error) 

1270 working.append( 

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

1272 ) 

1273 tool_rounds += 1 

1274 if tool_rounds >= _MAX_TOOL_ROUNDS: 

1275 tool_stopped = True 

1276 break 

1277 if not self.budget.try_consume(): 

1278 tool_stopped = True 

1279 break 

1280 

1281 tool_meta: dict[str, Any] = { 

1282 "tool_rounds": tool_rounds, 

1283 "truncated": truncated, 

1284 "tool_stopped": tool_stopped, 

1285 "tool_errors": tool_errors, 

1286 } 

1287 return content, usage, tool_meta 

1288 

1289 

1290# --------------------------------------------------------------------------- 

1291# Helpers 

1292# --------------------------------------------------------------------------- 

1293 

1294 

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

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

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

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

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

1300 value = limits.get("context_budget") 

1301 if value is None: 

1302 return DEFAULT_CONTEXT_BUDGET_TOKENS 

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

1304 raise ValueError( 

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

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

1307 ) 

1308 return value 

1309 

1310 

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

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

1313 return { 

1314 "error": ( 

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

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

1317 f"budget of {context_budget} tokens" 

1318 ), 

1319 "details": { 

1320 "error_code": _CONTEXT_BUDGET_ERROR_CODE, 

1321 "step_id": step_id, 

1322 "assembled_tokens": context_tokens, 

1323 "context_budget": context_budget, 

1324 }, 

1325 } 

1326 

1327 

1328def _handle_files_output( 

1329 state: dict, 

1330 step_id: str, 

1331 content: str, 

1332 measurement: dict[str, int], 

1333 agent_spec: dict, 

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

1335) -> dict: 

1336 """Agentin tiedostotuloste (output.type: files): parsii {path, content} 

1337 -listan ja kirjoittaa sen workspaceen jaetun helperin kautta (atominen 

1338 validointi, fail-fast, ADR-0012). Parsittu lista jää state[step_id] 

1339 -arvoksi — sama semantiikka kuin JSON-tuloksella.""" 

1340 parsed, perr = _try_parse_json(content) 

1341 if parsed is None: 

1342 return _with_context_metrics( 

1343 state, 

1344 step_id, 

1345 { 

1346 "error": ( 

1347 f"{_FILES_OUTPUT_ERROR_CODE}: step '{step_id}' files output " 

1348 f"is not valid JSON ({perr})" 

1349 ), 

1350 "details": { 

1351 "error_code": _FILES_OUTPUT_ERROR_CODE, 

1352 "step_id": step_id, 

1353 }, 

1354 }, 

1355 measurement, 

1356 ) 

1357 try: 

1358 validate_and_write_files( 

1359 parsed, 

1360 workspace_root=resolve_workspace_root(state), 

1361 target_path=(agent_spec.get("output") or {}).get("path"), 

1362 ) 

1363 except FilesOutputError as exc: 

1364 return _with_context_metrics( 

1365 state, 

1366 step_id, 

1367 { 

1368 "error": f"{_FILES_OUTPUT_ERROR_CODE}: {exc}", 

1369 "details": { 

1370 "error_code": _FILES_OUTPUT_ERROR_CODE, 

1371 "step_id": step_id, 

1372 }, 

1373 }, 

1374 measurement, 

1375 ) 

1376 return _with_context_metrics( 

1377 state, step_id, _apply_tool_meta(parsed, tool_meta), measurement 

1378 ) 

1379 

1380 

1381def _apply_tool_meta(value: Any, tool_meta: dict[str, Any] | None) -> Any: 

1382 """Attach tool-loop metadata onto a dict-valued step result. 

1383 

1384 The non-flagged path passes `tool_meta=None` and returns `value` unchanged 

1385 (byte-identical). List-valued results (e.g. files output) are left as-is so 

1386 the shape contract is preserved; tool_meta is simply not surfaced there. 

1387 """ 

1388 if not tool_meta: 

1389 return value 

1390 if isinstance(value, dict): 

1391 return {**value, "tool_meta": tool_meta} 

1392 return value 

1393 

1394 

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

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

1397 

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

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

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

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

1402 """ 

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

1404 

1405 

1406def _with_context_metrics( 

1407 state: dict, 

1408 step_id: str, 

1409 step_value: Any, 

1410 measurement: dict[str, int], 

1411) -> dict: 

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

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

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

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

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

1417 metrics[step_id] = measurement 

1418 return {**state, step_id: step_value, "step_context_metrics": metrics} 

1419 

1420 

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

1422 """ 

1423 Muotoile agentin user-message resolvoidusta inputista + paramseista. 

1424 """ 

1425 parts: list[str] = [] 

1426 

1427 # Inputin esitysmuoto: yritetään olla informatiivisia 

1428 if input_value is None: 

1429 pass 

1430 elif isinstance(input_value, str): 

1431 parts.append(input_value) 

1432 elif isinstance(input_value, dict): 

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

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

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

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

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

1438 else: 

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

1440 elif isinstance(input_value, list): 

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

1442 else: 

1443 parts.append(str(input_value)) 

1444 

1445 # Append path verification block (auto-feature) jos asetettu 

1446 pvb = params.get("path_verification_block") or "" 

1447 if pvb: 

1448 parts.append(str(pvb)) 

1449 

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

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

1452 skip_keys = {"path_verification_block", "skills"} 

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

1454 if k in skip_keys: 

1455 continue 

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

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

1458 extra[k] = v 

1459 if extra: 

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

1461 

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