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.
- package/README.md +63 -40
- package/docs/CHANGELOG.md +57 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +2 -2
- package/docs/DEVELOPMENT.md +8 -8
- package/docs/LEGACY_COMPATIBILITY.md +1 -1
- package/docs/ONBOARDING.md +2 -2
- package/docs/OPERATIONS.md +1 -1
- package/docs/TRUST_MODEL.md +1 -1
- package/docs/WHY_LATTICE.md +1 -1
- package/docs/kg-schema.md +1 -1
- package/docs/mcp-tools.md +1 -1
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/change_proposals.py +60 -0
- package/latticeai/api/chat_agent_http.py +2 -0
- package/latticeai/api/command_center.py +51 -0
- package/latticeai/app_factory.py +39 -0
- package/latticeai/cli/entrypoint.py +3 -1
- package/latticeai/core/agent.py +127 -13
- package/latticeai/core/agent_eval.py +260 -0
- package/latticeai/core/agent_trace.py +104 -0
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/tool_governor.py +101 -0
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/change_proposals.py +270 -0
- package/latticeai/services/command_center.py +433 -0
- package/latticeai/services/product_readiness.py +1 -1
- package/latticeai/services/review_queue.py +4 -1
- package/latticeai/tools/__init__.py +6 -1
- package/package.json +1 -1
- package/scripts/agent_eval.py +46 -0
- package/scripts/check_current_release_docs.mjs +2 -1
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +11 -11
- package/static/app/assets/{Act-t9oveJO7.js → Act-BkOEmwBi.js} +1 -1
- package/static/app/assets/{Brain-DP-gpcEJ.js → Brain-C9ITUsQ_.js} +1 -1
- package/static/app/assets/{Capture-DYknDKy8.js → Capture-C-ppTeud.js} +1 -1
- package/static/app/assets/{Library-DLyc_g8c.js → Library-CGQbgWTu.js} +1 -1
- package/static/app/assets/{System-BZgJ7tGu.js → System-djmj0n2_.js} +1 -1
- package/static/app/assets/{index-Cl4S_9Id.css → index-85wQvEie.css} +1 -1
- package/static/app/assets/index-AF0-4XVv.js +18 -0
- package/static/app/assets/{primitives-DawfkPR4.js → primitives-jbb2qv4Q.js} +1 -1
- package/static/app/assets/{textarea-a4Ir3SZS.js → textarea-CFoo0OxJ.js} +1 -1
- package/static/app/index.html +2 -2
- package/static/sw.js +1 -1
- package/static/app/assets/index-BeQ77vPs.js +0 -17
package/latticeai/core/agent.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 =
|
|
207
|
-
|
|
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 = [
|
|
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=
|
|
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 =
|
|
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 =
|
|
434
|
-
|
|
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"]
|