ltcai 9.4.0 → 9.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +63 -40
  2. package/docs/CHANGELOG.md +57 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +2 -2
  4. package/docs/DEVELOPMENT.md +8 -8
  5. package/docs/LEGACY_COMPATIBILITY.md +1 -1
  6. package/docs/ONBOARDING.md +2 -2
  7. package/docs/OPERATIONS.md +1 -1
  8. package/docs/TRUST_MODEL.md +1 -1
  9. package/docs/WHY_LATTICE.md +1 -1
  10. package/docs/kg-schema.md +1 -1
  11. package/docs/mcp-tools.md +1 -1
  12. package/lattice_brain/__init__.py +1 -1
  13. package/lattice_brain/runtime/multi_agent.py +1 -1
  14. package/latticeai/__init__.py +1 -1
  15. package/latticeai/api/change_proposals.py +60 -0
  16. package/latticeai/api/chat_agent_http.py +2 -0
  17. package/latticeai/api/command_center.py +51 -0
  18. package/latticeai/app_factory.py +39 -0
  19. package/latticeai/cli/entrypoint.py +3 -1
  20. package/latticeai/core/agent.py +127 -13
  21. package/latticeai/core/agent_eval.py +260 -0
  22. package/latticeai/core/agent_trace.py +104 -0
  23. package/latticeai/core/legacy_compatibility.py +1 -1
  24. package/latticeai/core/marketplace.py +1 -1
  25. package/latticeai/core/tool_governor.py +101 -0
  26. package/latticeai/core/workspace_os.py +1 -1
  27. package/latticeai/services/architecture_readiness.py +1 -1
  28. package/latticeai/services/change_proposals.py +270 -0
  29. package/latticeai/services/command_center.py +433 -0
  30. package/latticeai/services/product_readiness.py +1 -1
  31. package/latticeai/services/review_queue.py +4 -1
  32. package/latticeai/tools/__init__.py +6 -1
  33. package/package.json +1 -1
  34. package/scripts/agent_eval.py +46 -0
  35. package/scripts/check_current_release_docs.mjs +2 -1
  36. package/src-tauri/Cargo.lock +1 -1
  37. package/src-tauri/Cargo.toml +1 -1
  38. package/src-tauri/tauri.conf.json +1 -1
  39. package/static/app/asset-manifest.json +11 -11
  40. package/static/app/assets/{Act-t9oveJO7.js → Act-BkOEmwBi.js} +1 -1
  41. package/static/app/assets/{Brain-DP-gpcEJ.js → Brain-C9ITUsQ_.js} +1 -1
  42. package/static/app/assets/{Capture-DYknDKy8.js → Capture-C-ppTeud.js} +1 -1
  43. package/static/app/assets/{Library-DLyc_g8c.js → Library-CGQbgWTu.js} +1 -1
  44. package/static/app/assets/{System-BZgJ7tGu.js → System-djmj0n2_.js} +1 -1
  45. package/static/app/assets/{index-Cl4S_9Id.css → index-85wQvEie.css} +1 -1
  46. package/static/app/assets/index-AF0-4XVv.js +18 -0
  47. package/static/app/assets/{primitives-DawfkPR4.js → primitives-jbb2qv4Q.js} +1 -1
  48. package/static/app/assets/{textarea-a4Ir3SZS.js → textarea-CFoo0OxJ.js} +1 -1
  49. package/static/app/index.html +2 -2
  50. package/static/sw.js +1 -1
  51. package/static/app/assets/index-BeQ77vPs.js +0 -17
@@ -20,16 +20,18 @@ only owns the state machine.
20
20
 
21
21
  from __future__ import annotations
22
22
 
23
+ import ast
23
24
  import json
24
25
  import logging
25
26
  import re
26
27
  from dataclasses import dataclass
27
28
  from enum import Enum
28
29
  from pathlib import Path
29
- from typing import Any, Awaitable, Callable, Dict, FrozenSet, List, Optional
30
+ from typing import Any, Awaitable, Callable, Dict, FrozenSet, List, Optional, Tuple
30
31
 
31
32
  from lattice_brain.runtime.hooks import dispatch_tool
32
33
  from lattice_brain.runtime.contracts import runtime_boundary_contract, single_agent_contract
34
+ from latticeai.core.agent_trace import LoopTrace
33
35
  from latticeai.core.tool_registry import SCOPED_KNOWLEDGE_TOOLS
34
36
  from latticeai.tools import ToolError
35
37
 
@@ -53,10 +55,11 @@ class AgentRunContext:
53
55
  """Mutable state carrier passed through all agent phases."""
54
56
  __slots__ = ("state", "plan", "transcript", "retry_count",
55
57
  "state_history", "corrections", "final_message", "rollback_log",
56
- "executing_model", "reviewing_model", "approved_by_human")
58
+ "executing_model", "reviewing_model", "approved_by_human", "trace")
57
59
 
58
60
  def __init__(self) -> None:
59
61
  self.state: AgentState = AgentState.IDLE
62
+ self.trace: LoopTrace = LoopTrace()
60
63
  self.plan: dict = {}
61
64
  self.transcript: list = []
62
65
  self.retry_count: int = 0
@@ -74,20 +77,31 @@ _THINK_BLOCK_RE = re.compile(
74
77
  )
75
78
 
76
79
 
77
- def extract_action(raw: str) -> Dict:
78
- """Parse one JSON action object out of an LLM response (tolerant of fences/prose)."""
80
+ def extract_action_details(raw: str) -> Tuple[Dict, List[str]]:
81
+ """Parse one JSON action object out of an LLM response (tolerant of fences/prose).
82
+
83
+ Returns ``(action, repairs)`` where ``repairs`` names every tolerance that
84
+ was needed — the loop trace and the weak-model robustness harness consume
85
+ it to measure how much help a given model needs.
86
+ """
87
+ repairs: List[str] = []
79
88
  # Small local models often prepend <think>...</think> reasoning that can
80
89
  # itself contain braces — drop it before locating the action object.
81
90
  text = _THINK_BLOCK_RE.sub("", raw).strip()
91
+ if text != str(raw).strip():
92
+ repairs.append("think_strip")
82
93
  fenced = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, flags=re.DOTALL)
83
94
  if fenced:
84
95
  text = fenced.group(1).strip()
96
+ repairs.append("fence")
85
97
  elif not text.startswith("{"):
86
98
  start = text.find("{")
87
99
  end = text.rfind("}")
88
100
  if start >= 0 and end > start:
89
101
  text = text[start : end + 1]
102
+ repairs.append("slice")
90
103
 
104
+ action: Any = None
91
105
  try:
92
106
  action = json.loads(text)
93
107
  except json.JSONDecodeError:
@@ -96,11 +110,28 @@ def extract_action(raw: str) -> Dict:
96
110
  repaired = re.sub(r",\s*([}\]])", r"\1", text)
97
111
  try:
98
112
  action = json.loads(repaired)
113
+ repairs.append("trailing_comma")
99
114
  except json.JSONDecodeError as exc:
100
- raise ValueError(f"Agent did not return valid JSON: {exc}") from exc
115
+ # Last chance: weak models sometimes emit a Python dict literal
116
+ # (single quotes, True/False/None). ast.literal_eval parses that
117
+ # deterministically without evaluating code.
118
+ try:
119
+ literal = ast.literal_eval(text)
120
+ except (ValueError, SyntaxError):
121
+ raise ValueError(f"Agent did not return valid JSON: {exc}") from exc
122
+ if not isinstance(literal, dict):
123
+ raise ValueError(f"Agent did not return valid JSON: {exc}") from exc
124
+ action = literal
125
+ repairs.append("python_literal")
101
126
 
102
127
  if not isinstance(action, dict) or "action" not in action:
103
128
  raise ValueError("Agent JSON must include an action field.")
129
+ return action, repairs
130
+
131
+
132
+ def extract_action(raw: str) -> Dict:
133
+ """Back-compat wrapper over :func:`extract_action_details`."""
134
+ action, _ = extract_action_details(raw)
104
135
  return action
105
136
 
106
137
 
@@ -155,6 +186,14 @@ class AgentDeps:
155
186
  # the vault markdown dump.
156
187
  brain_memory: Any = None
157
188
 
189
+ # ── change governor port (optional) ──────────────────────────────
190
+ # When present, file writes are classified centrally: additive creates
191
+ # run with minimal friction, while mutations/deletions of existing
192
+ # content are staged as review proposals instead of applied. The port is
193
+ # ``review(name, args, policy=..., user_email=..., workspace_id=...)``
194
+ # returning None (fall through to the classic gates) or a verdict dict.
195
+ change_governor: Any = None
196
+
158
197
 
159
198
  class SingleAgentRuntime:
160
199
  """Drives the agent state machine over injected :class:`AgentDeps`."""
@@ -202,9 +241,12 @@ class SingleAgentRuntime:
202
241
  message="Produce a JSON execution plan for this request.",
203
242
  context=context, max_tokens=1024, temperature=0.1,
204
243
  )
244
+ ctx.trace.llm_call("plan", model=model_id)
205
245
  try:
206
- plan = extract_action(str(raw))
207
- except ValueError:
246
+ plan, plan_repairs = extract_action_details(str(raw))
247
+ ctx.trace.repair("plan", repairs=plan_repairs)
248
+ except ValueError as exc:
249
+ ctx.trace.parse_error("plan", error=str(exc), recovered=True)
208
250
  plan = {
209
251
  "action": "plan", "state": "PLAN",
210
252
  "goal": req.message, "steps": [],
@@ -226,8 +268,19 @@ class SingleAgentRuntime:
226
268
  """APPROVAL: Check governance, log decision, auto-approve (future: UI prompt)."""
227
269
  d = self.deps
228
270
  auto_approve_tools = {name for name, p in d.tool_governance.items() if p["auto_approve"]}
271
+ # Governor-managed tools never hard-block the plan: each call is
272
+ # classified at execution time — additive creates run, mutations and
273
+ # deletions of existing content become review proposals.
274
+ governed_tools = (
275
+ frozenset(getattr(d.change_governor, "governed_tools", frozenset()))
276
+ if d.change_governor is not None else frozenset()
277
+ )
229
278
  steps = ctx.plan.get("steps", [])
230
- non_auto = [s.get("action") for s in steps if s.get("action") not in auto_approve_tools]
279
+ non_auto = [
280
+ s.get("action") for s in steps
281
+ if s.get("action") not in auto_approve_tools
282
+ and s.get("action") not in governed_tools
283
+ ]
231
284
  requires = ctx.plan.get("requires_approval", False) or bool(non_auto)
232
285
 
233
286
  ctx.transcript.append({
@@ -236,11 +289,13 @@ class SingleAgentRuntime:
236
289
  "non_auto_approve_steps": non_auto,
237
290
  "decision": "human_approved" if requires and approved_by_human else ("blocked_pending_approval" if requires else "auto_approved"),
238
291
  })
292
+ decision = "human_approved" if requires and approved_by_human else ("blocked_pending_approval" if requires else "auto_approved")
293
+ ctx.trace.decision("approve", decision=decision, non_auto_steps=len(non_auto))
239
294
  d.audit(
240
295
  "agent_approval", user_email=current_user,
241
296
  requires_approval=requires,
242
297
  non_auto_steps=non_auto,
243
- decision="human_approved" if requires and approved_by_human else ("blocked_pending_approval" if requires else "auto_approved"),
298
+ decision=decision,
244
299
  )
245
300
  if requires and not approved_by_human:
246
301
  ctx.final_message = (
@@ -291,8 +346,10 @@ class SingleAgentRuntime:
291
346
  message="Execute the next step.",
292
347
  context=context, max_tokens=4096, temperature=req.temperature,
293
348
  )
349
+ ctx.trace.llm_call("execute", model=model_id)
294
350
  try:
295
- action = extract_action(str(raw))
351
+ action, exec_repairs = extract_action_details(str(raw))
352
+ ctx.trace.repair("execute", repairs=exec_repairs)
296
353
  except ValueError as exc:
297
354
  parse_failures += 1
298
355
  ctx.transcript.append({
@@ -300,7 +357,9 @@ class SingleAgentRuntime:
300
357
  "raw": str(raw)[:400], "error": str(exc),
301
358
  })
302
359
  if parse_failures >= 3:
360
+ ctx.trace.parse_error("execute", error=str(exc), recovered=False)
303
361
  break
362
+ ctx.trace.parse_error("execute", error=str(exc), recovered=True)
304
363
  # Weak models often need one concrete reminder of the wire
305
364
  # format; feed it through the corrections channel and retry
306
365
  # instead of aborting the whole run on the first slip.
@@ -309,8 +368,17 @@ class SingleAgentRuntime:
309
368
  'EXACTLY one JSON object like {"thoughts": "...", "action": '
310
369
  '"tool_name", "args": {...}} and nothing else.'
311
370
  )
371
+ if parse_failures >= 2:
372
+ # Escalate: name the valid tools so the model stops
373
+ # inventing action names or prose.
374
+ valid = ", ".join(sorted(d.tool_governance.keys()))
375
+ hint = (
376
+ f"{hint} Valid action values are: {valid}, final. "
377
+ 'Use {"action": "final", "message": "..."} to finish.'
378
+ )
312
379
  if hint not in ctx.corrections:
313
380
  ctx.corrections.append(hint)
381
+ ctx.trace.correction("execute", hint=hint)
314
382
  continue
315
383
 
316
384
  name = action.get("action")
@@ -329,6 +397,7 @@ class SingleAgentRuntime:
329
397
  ctx.transcript.append({
330
398
  "state": AgentState.EXECUTING.value, "action": "final", "thoughts": thoughts,
331
399
  })
400
+ ctx.trace.decision("execute", decision="final")
332
401
  ctx.state = AgentState.VERIFYING
333
402
  return
334
403
 
@@ -345,6 +414,7 @@ class SingleAgentRuntime:
345
414
  "state": AgentState.EXECUTING.value, "action": name,
346
415
  "error": "LOOP_DETECTED: identical action+args repeated — halted.",
347
416
  })
417
+ ctx.trace.decision("execute", decision="loop_detected", tool=name)
348
418
  break
349
419
 
350
420
  if name == "clear_history":
@@ -358,7 +428,39 @@ class SingleAgentRuntime:
358
428
  policy = d.policy_for(name, args)
359
429
  risk = d.risk_level(policy)
360
430
 
431
+ # Central change-class governance: create-new runs with minimal
432
+ # friction, change/delete-existing becomes a review proposal.
433
+ governor_allows_additive = False
434
+ if d.change_governor is not None:
435
+ verdict = d.change_governor.review(
436
+ name, args, policy=dict(policy),
437
+ user_email=current_user, workspace_id=request_workspace,
438
+ )
439
+ if verdict is not None and verdict.get("decision") == "proposed":
440
+ proposal = verdict.get("proposal") or {}
441
+ ctx.trace.tool("execute", name=name, outcome="proposed", risk=risk)
442
+ ctx.transcript.append({
443
+ "state": AgentState.EXECUTING.value, "action": name,
444
+ "thoughts": thoughts, "args": {k: v for k, v in args.items() if k != "content"},
445
+ "risk": risk, "governance": dict(policy),
446
+ "result": {
447
+ "proposed": True,
448
+ "proposal_id": proposal.get("id"),
449
+ "note": "기존 내용을 바꾸는 작업이라 변경 제안으로 저장했습니다. 검토함에서 승인하면 적용됩니다.",
450
+ },
451
+ })
452
+ d.audit(
453
+ "agent_change_proposed", user_email=current_user,
454
+ action=name, proposal_id=proposal.get("id"),
455
+ change_class=(verdict.get("classification") or {}).get("change_class"),
456
+ )
457
+ continue
458
+ governor_allows_additive = (
459
+ verdict is not None and verdict.get("decision") == "allow_additive"
460
+ )
461
+
361
462
  if policy["risk"] == "destructive":
463
+ ctx.trace.tool("execute", name=name, outcome="blocked_destructive", risk=risk)
362
464
  ctx.transcript.append({
363
465
  "state": AgentState.EXECUTING.value, "action": name,
364
466
  "thoughts": thoughts, "args": args, "risk": risk,
@@ -371,7 +473,7 @@ class SingleAgentRuntime:
371
473
  )
372
474
  continue
373
475
 
374
- if not policy["auto_approve"] and not ctx.approved_by_human:
476
+ if not policy["auto_approve"] and not ctx.approved_by_human and not governor_allows_additive:
375
477
  d.audit(
376
478
  "agent_exec", user_email=current_user, source=getattr(req, "source", None) or "agent",
377
479
  state=AgentState.EXECUTING.value, action=name, risk=risk,
@@ -380,6 +482,7 @@ class SingleAgentRuntime:
380
482
  rollback=policy["rollback"],
381
483
  args={k: v for k, v in args.items() if k != "content"},
382
484
  )
485
+ ctx.trace.tool("execute", name=name, outcome="blocked_approval", risk=risk)
383
486
  ctx.transcript.append({
384
487
  "state": AgentState.EXECUTING.value, "action": name,
385
488
  "thoughts": thoughts, "args": args, "risk": risk,
@@ -396,12 +499,14 @@ class SingleAgentRuntime:
396
499
  lambda: d.execute_tool(name, args),
397
500
  user_email=current_user, source="agent",
398
501
  )
502
+ ctx.trace.tool("execute", name=name, outcome="ok", risk=risk)
399
503
  ctx.transcript.append({
400
504
  "state": AgentState.EXECUTING.value, "action": name,
401
505
  "thoughts": thoughts, "args": args,
402
506
  "risk": risk, "governance": dict(policy), "result": result,
403
507
  })
404
508
  except (ToolError, KeyError, TypeError, PermissionError) as exc:
509
+ ctx.trace.tool("execute", name=name, outcome="error", risk=risk)
405
510
  ctx.transcript.append({
406
511
  "state": AgentState.EXECUTING.value, "action": name,
407
512
  "thoughts": thoughts, "args": args,
@@ -429,9 +534,12 @@ class SingleAgentRuntime:
429
534
  message="Review the execution transcript and return your verdict JSON.",
430
535
  context=context, max_tokens=512, temperature=0.1,
431
536
  )
537
+ ctx.trace.llm_call("verify", model=model_id)
432
538
  try:
433
- verdict = extract_action(str(raw))
434
- except ValueError:
539
+ verdict, verdict_repairs = extract_action_details(str(raw))
540
+ ctx.trace.repair("verify", repairs=verdict_repairs)
541
+ except ValueError as exc:
542
+ ctx.trace.parse_error("verify", error=str(exc), recovered=True)
435
543
  verdict = {"action": "verdict", "verdict": "PASS", "next_state": "DONE",
436
544
  "reason": "Critic parse failed — assuming pass.", "corrections": [], "confidence": 0.7}
437
545
 
@@ -449,6 +557,7 @@ class SingleAgentRuntime:
449
557
  "next_state": next_s,
450
558
  })
451
559
 
560
+ ctx.trace.decision("verify", decision=str(verdict.get("verdict", "PASS")), next_state=next_s)
452
561
  if verdict.get("verdict") == "PASS" or next_s == "DONE":
453
562
  if not ctx.final_message:
454
563
  ctx.final_message = verdict.get("reason", "작업이 완료되었습니다.")
@@ -461,6 +570,7 @@ class SingleAgentRuntime:
461
570
  ctx.state = AgentState.FAILED
462
571
  else:
463
572
  ctx.retry_count += 1
573
+ ctx.trace.retry("verify", attempt=ctx.retry_count)
464
574
  ctx.transcript.append({
465
575
  "state": AgentState.EXECUTING.value,
466
576
  "retry_attempt": ctx.retry_count,
@@ -497,6 +607,10 @@ class SingleAgentRuntime:
497
607
  rolled.append({"path": path, "ok": False, "error": str(exc)})
498
608
 
499
609
  ctx.transcript.append({"state": AgentState.ROLLBACK.value, "rolled_back": rolled})
610
+ ctx.trace.decision(
611
+ "rollback", decision="rolled_back",
612
+ attempted=len(rolled), recovered=sum(1 for r in rolled if r.get("ok")),
613
+ )
500
614
  recovered = [r["path"] for r in rolled if r.get("ok")]
501
615
  ctx.final_message = (
502
616
  f"실행 실패로 롤백했습니다. 복구 파일: {recovered}"
@@ -0,0 +1,260 @@
1
+ """Deterministic agent-loop evaluation harness (v9.6.0).
2
+
3
+ The Brain has a safety harness (governance, approval gates, audit) but until
4
+ 9.6.0 no *evaluation* harness: nothing measured whether the reasoning loop
5
+ actually completes tasks, recovers from weak-model formatting slips, or
6
+ respects its guards. This module closes that gap without any model:
7
+
8
+ * every scenario scripts the exact model replies (including the malformed
9
+ outputs small local models really produce — think blocks, Python dict
10
+ literals, trailing commas, prose) and drives the real
11
+ :class:`~latticeai.core.agent.SingleAgentRuntime` state machine over fake
12
+ ports;
13
+ * expectations are asserted against the loop's own :class:`LoopTrace`
14
+ summary, so the harness measures the same observability surface the API
15
+ exposes;
16
+ * the result is a scoreboard (`scenarios`, `passed`, `success_rate`,
17
+ aggregate recovery stats) consumed by ``scripts/agent_eval.py`` as a
18
+ release gate.
19
+
20
+ Deterministic by construction: no model, no network, no filesystem writes
21
+ (the tool port records calls in memory).
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import asyncio
27
+ from dataclasses import dataclass, field
28
+ from pathlib import Path
29
+ from typing import Any, Dict, List
30
+
31
+ from latticeai.core.agent import (
32
+ AgentDeps,
33
+ AgentRunContext,
34
+ AgentState,
35
+ SingleAgentRuntime,
36
+ )
37
+
38
+ _AUTO_POLICY = {
39
+ "auto_approve": True, "risk": "low", "shell": False, "network": False,
40
+ "destructive": False, "sandbox": False, "rollback": "none",
41
+ }
42
+ _DESTRUCTIVE_POLICY = {
43
+ "auto_approve": False, "risk": "destructive", "shell": True, "network": False,
44
+ "destructive": True, "sandbox": False, "rollback": "none",
45
+ }
46
+
47
+
48
+ @dataclass
49
+ class Scenario:
50
+ """One scripted conversation through the real agent state machine."""
51
+
52
+ name: str
53
+ replies: List[str]
54
+ expect_state: str = "DONE"
55
+ # Each key is compared against the LoopTrace summary with >= / == / <=
56
+ expect_min: Dict[str, int] = field(default_factory=dict)
57
+ expect_exact: Dict[str, Any] = field(default_factory=dict)
58
+ expect_tool_outcomes: Dict[str, int] = field(default_factory=dict)
59
+ max_steps: int = 8
60
+
61
+
62
+ class _Req:
63
+ conversation_id = None
64
+ temperature = 0.2
65
+ workspace_id = None
66
+ source = "agent_eval"
67
+
68
+ def __init__(self, message: str) -> None:
69
+ self.message = message
70
+
71
+
72
+ def _build_deps(replies: List[str], tool_log: List[Dict[str, Any]]) -> AgentDeps:
73
+ queue = list(replies)
74
+
75
+ async def generate_as(model_id, message, context, max_tokens, temperature):
76
+ if not queue:
77
+ # A scenario that exhausts its script means the loop asked for
78
+ # more turns than expected — end it deterministically.
79
+ return '{"action": "verdict", "verdict": "PASS", "next_state": "DONE", "reason": "script exhausted"}'
80
+ return queue.pop(0)
81
+
82
+ async def generate(**kwargs):
83
+ return '{"action": "noop"}'
84
+
85
+ def execute_tool(name: str, args: dict) -> dict:
86
+ tool_log.append({"name": name, "args": args})
87
+ return {"ok": True, "path": args.get("path", "")}
88
+
89
+ def policy_for(name: str, args: dict) -> dict:
90
+ if name == "delete_everything":
91
+ return dict(_DESTRUCTIVE_POLICY)
92
+ return dict(_AUTO_POLICY)
93
+
94
+ return AgentDeps(
95
+ generate_as=generate_as,
96
+ generate=generate,
97
+ execute_tool=execute_tool,
98
+ policy_for=policy_for,
99
+ risk_level=lambda p: p["risk"],
100
+ check_role=lambda name, user: None,
101
+ tool_governance={
102
+ "write_file": dict(_AUTO_POLICY),
103
+ "read_file": dict(_AUTO_POLICY),
104
+ "delete_everything": dict(_DESTRUCTIVE_POLICY),
105
+ },
106
+ file_create_actions=frozenset({"write_file"}),
107
+ recent_chat_context=lambda **kw: "",
108
+ clear_history=lambda keep: {"ok": True},
109
+ knowledge_save=lambda *a, **kw: None,
110
+ audit=lambda *a, **kw: None,
111
+ planner_prompt="plan", executor_prompt="exec", critic_prompt="critic",
112
+ memory_updater_prompt="mem", agent_root=Path("/tmp/agent-eval"),
113
+ )
114
+
115
+
116
+ _PLAN = '{"action": "plan", "goal": "task", "steps": [{"action": "write_file"}]}'
117
+ _WRITE = '{"action": "write_file", "args": {"path": "note.txt", "content": "hi"}}'
118
+ _FINAL = '{"action": "final", "message": "done"}'
119
+ _PASS = '{"action": "verdict", "verdict": "PASS", "next_state": "DONE", "reason": "ok"}'
120
+
121
+
122
+ def default_scenarios() -> List[Scenario]:
123
+ return [
124
+ Scenario(
125
+ name="happy-path",
126
+ replies=[_PLAN, _WRITE, _FINAL, _PASS],
127
+ expect_exact={"parse_errors": 0},
128
+ expect_tool_outcomes={"ok": 1},
129
+ ),
130
+ Scenario(
131
+ name="weak-model-format-gauntlet",
132
+ replies=[
133
+ "<think>let me plan</think>```json\n" + _PLAN + "\n```",
134
+ "Sure! Here is the action:\n" + _WRITE,
135
+ "{'action': 'final', 'message': 'done', 'happy': True}",
136
+ '{"action": "verdict", "verdict": "PASS", "next_state": "DONE",}',
137
+ ],
138
+ expect_exact={"parse_errors": 0},
139
+ expect_min={"llm_calls": 4},
140
+ expect_tool_outcomes={"ok": 1},
141
+ ),
142
+ Scenario(
143
+ name="prose-slip-recovers-with-correction",
144
+ replies=[_PLAN, "I will now write the file for you.", _WRITE, _FINAL, _PASS],
145
+ expect_min={"parse_errors": 1, "parse_recovered": 1, "corrections": 1},
146
+ expect_tool_outcomes={"ok": 1},
147
+ ),
148
+ Scenario(
149
+ name="double-slip-escalates-tool-list",
150
+ replies=[
151
+ _PLAN,
152
+ "chatty non-json reply",
153
+ "another chatty reply",
154
+ _WRITE,
155
+ _FINAL,
156
+ _PASS,
157
+ ],
158
+ expect_min={"parse_errors": 2, "corrections": 2},
159
+ expect_tool_outcomes={"ok": 1},
160
+ ),
161
+ Scenario(
162
+ name="destructive-action-blocked",
163
+ replies=[
164
+ _PLAN,
165
+ '{"action": "delete_everything", "args": {}}',
166
+ _FINAL,
167
+ _PASS,
168
+ ],
169
+ expect_tool_outcomes={"blocked_destructive": 1},
170
+ ),
171
+ Scenario(
172
+ name="identical-action-loop-detected",
173
+ replies=[_PLAN, _WRITE, _WRITE, _PASS],
174
+ expect_tool_outcomes={"ok": 1},
175
+ expect_min={"llm_calls": 4},
176
+ ),
177
+ Scenario(
178
+ name="critic-retry-then-done",
179
+ replies=[
180
+ _PLAN,
181
+ _WRITE,
182
+ _FINAL,
183
+ '{"action": "verdict", "verdict": "FAIL", "next_state": "EXECUTING", "corrections": ["also mention the date"]}',
184
+ _FINAL,
185
+ _PASS,
186
+ ],
187
+ expect_min={"retries": 1},
188
+ ),
189
+ Scenario(
190
+ name="unrecoverable-garbage-still-terminates",
191
+ replies=[_PLAN, "garbage", "more garbage", "still garbage", _PASS],
192
+ expect_state="DONE",
193
+ expect_min={"parse_errors": 3},
194
+ expect_exact={"tool_outcomes": {}},
195
+ ),
196
+ ]
197
+
198
+
199
+ async def _run_scenario(scenario: Scenario) -> Dict[str, Any]:
200
+ tool_log: List[Dict[str, Any]] = []
201
+ deps = _build_deps(scenario.replies, tool_log)
202
+ runtime = SingleAgentRuntime(deps)
203
+ ctx = AgentRunContext()
204
+ ctx.state = AgentState.PLANNING
205
+ req = _Req("agent eval task")
206
+
207
+ await runtime.plan(ctx, req, "en", "eval@local")
208
+ runtime.approve(ctx, "eval@local")
209
+ if ctx.state == AgentState.EXECUTING:
210
+ await runtime.run_to_completion(
211
+ ctx, req, "en", "eval@local", max_steps=scenario.max_steps, max_retry=2
212
+ )
213
+
214
+ summary = ctx.trace.summary()
215
+ failures: List[str] = []
216
+ if ctx.state.value != scenario.expect_state:
217
+ failures.append(f"state={ctx.state.value} expected={scenario.expect_state}")
218
+ for key, minimum in scenario.expect_min.items():
219
+ if int(summary.get(key) or 0) < minimum:
220
+ failures.append(f"{key}={summary.get(key)} < {minimum}")
221
+ for key, exact in scenario.expect_exact.items():
222
+ if summary.get(key) != exact:
223
+ failures.append(f"{key}={summary.get(key)} != {exact}")
224
+ for outcome, count in scenario.expect_tool_outcomes.items():
225
+ if summary["tool_outcomes"].get(outcome, 0) != count:
226
+ failures.append(
227
+ f"tool_outcomes[{outcome}]={summary['tool_outcomes'].get(outcome, 0)} != {count}"
228
+ )
229
+ return {
230
+ "name": scenario.name,
231
+ "ok": not failures,
232
+ "failures": failures,
233
+ "final_state": ctx.state.value,
234
+ "summary": summary,
235
+ "tool_calls": len(tool_log),
236
+ }
237
+
238
+
239
+ def run_agent_eval(scenarios: List[Scenario] | None = None) -> Dict[str, Any]:
240
+ """Run every scenario and reduce to a release-gate scoreboard."""
241
+
242
+ async def _run_all() -> List[Dict[str, Any]]:
243
+ return [await _run_scenario(s) for s in (scenarios or default_scenarios())]
244
+
245
+ results = asyncio.run(_run_all())
246
+ passed = [r for r in results if r["ok"]]
247
+ total_parse_errors = sum(r["summary"]["parse_errors"] for r in results)
248
+ total_recovered = sum(r["summary"]["parse_recovered"] for r in results)
249
+ return {
250
+ "scenarios": len(results),
251
+ "passed": len(passed),
252
+ "success_rate": round(len(passed) / len(results), 4) if results else 0.0,
253
+ "parse_errors": total_parse_errors,
254
+ "parse_recovered": total_recovered,
255
+ "recovery_rate": round(total_recovered / total_parse_errors, 4) if total_parse_errors else 1.0,
256
+ "results": results,
257
+ }
258
+
259
+
260
+ __all__ = ["Scenario", "default_scenarios", "run_agent_eval"]