ltcai 9.5.0 → 9.7.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 +67 -43
- package/auto_setup.py +11 -1
- package/docs/CHANGELOG.md +86 -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/PERFORMANCE.md +106 -0
- 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/kg_schema.py +11 -1
- package/knowledge_graph.py +12 -2
- package/knowledge_graph_api.py +11 -1
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/graph/proactive.py +583 -0
- package/lattice_brain/graph/retrieval.py +211 -7
- package/lattice_brain/graph/retrieval_vector.py +102 -0
- package/lattice_brain/ingestion.py +360 -4
- package/lattice_brain/quality.py +44 -9
- package/lattice_brain/runtime/multi_agent.py +38 -2
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/brain_intelligence.py +27 -2
- package/latticeai/api/change_proposals.py +89 -0
- package/latticeai/api/chat_agent_http.py +2 -0
- package/latticeai/api/review_queue.py +66 -2
- package/latticeai/app_factory.py +23 -0
- package/latticeai/cli/entrypoint.py +3 -1
- package/latticeai/core/agent.py +273 -101
- package/latticeai/core/agent_eval.py +411 -0
- package/latticeai/core/agent_trace.py +104 -0
- package/latticeai/core/legacy_compatibility.py +15 -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/runtime/router_registration.py +2 -0
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/brain_intelligence.py +97 -1
- package/latticeai/services/change_proposals.py +322 -0
- package/latticeai/services/product_readiness.py +1 -1
- package/latticeai/services/review_queue.py +47 -3
- package/latticeai/tools/__init__.py +6 -1
- package/llm_router.py +10 -1
- package/local_knowledge_api.py +10 -1
- package/ltcai_cli.py +11 -1
- package/mcp_registry.py +10 -1
- package/p_reinforce.py +10 -1
- package/package.json +1 -1
- package/scripts/agent_eval.py +46 -0
- package/scripts/brain_quality_eval.py +1 -1
- package/scripts/check_current_release_docs.mjs +2 -1
- package/scripts/profile_kg.py +360 -0
- package/setup_wizard.py +10 -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-Cdfqx4wN.js → Act-B6c39ays.js} +2 -1
- package/static/app/assets/{Brain-w_tAuyg6.js → Brain-D7Qg4k6M.js} +1 -1
- package/static/app/assets/{Capture-iJwi9LmS.js → Capture-VF_di68r.js} +1 -1
- package/static/app/assets/{Library-Do-jjhzi.js → Library-D_Gis2PA.js} +1 -1
- package/static/app/assets/{System-DFg_q3E6.js → System-C5s5H2ov.js} +1 -1
- package/static/app/assets/{index-BN6HIWVC.css → index-85wQvEie.css} +1 -1
- package/static/app/assets/index-DJC_2oub.js +18 -0
- package/static/app/assets/{primitives-ZTUlU7pR.js → primitives-DL4Nip8C.js} +1 -1
- package/static/app/assets/{textarea-CMfhqPhE.js → textarea-woZfCXHy.js} +1 -1
- package/static/app/index.html +2 -2
- package/static/sw.js +1 -1
- package/static/app/assets/index-aJuRi-Xo.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 = (
|
|
@@ -264,53 +319,21 @@ class SingleAgentRuntime:
|
|
|
264
319
|
parse_failures = 0
|
|
265
320
|
|
|
266
321
|
for _ in range(budget):
|
|
267
|
-
corrections_hint = (
|
|
268
|
-
"\n\nCritic corrections from previous attempt:\n"
|
|
269
|
-
+ "\n".join(f"- {c}" for c in ctx.corrections)
|
|
270
|
-
) if ctx.corrections else ""
|
|
271
|
-
|
|
272
322
|
request_workspace = getattr(req, "workspace_id", None)
|
|
273
|
-
|
|
274
|
-
"conversation_id": req.conversation_id,
|
|
275
|
-
"user_email": current_user or None,
|
|
276
|
-
}
|
|
277
|
-
if request_workspace is not None:
|
|
278
|
-
recent_kwargs["workspace_id"] = request_workspace
|
|
279
|
-
recent_conversation = d.recent_chat_context(**recent_kwargs) or "(none)"
|
|
280
|
-
context = (
|
|
281
|
-
f"{d.executor_prompt}\n\n"
|
|
282
|
-
f"[LANGUAGE HINT: {lang_hint}]\n"
|
|
283
|
-
f"Workspace root: {d.agent_root}\n\n"
|
|
284
|
-
f"PLAN:\n{json.dumps(ctx.plan, ensure_ascii=False)}\n\n"
|
|
285
|
-
f"Recent conversation:\n{recent_conversation}\n\n"
|
|
286
|
-
f"User request: {req.message}{corrections_hint}\n\n"
|
|
287
|
-
f"Execution transcript:\n{json.dumps(ctx.transcript, ensure_ascii=False, indent=2)}"
|
|
288
|
-
)
|
|
323
|
+
context = self._executor_context(ctx, req, lang_hint, current_user, request_workspace)
|
|
289
324
|
raw = await d.generate_as(
|
|
290
325
|
model_id,
|
|
291
326
|
message="Execute the next step.",
|
|
292
327
|
context=context, max_tokens=4096, temperature=req.temperature,
|
|
293
328
|
)
|
|
329
|
+
ctx.trace.llm_call("execute", model=model_id)
|
|
294
330
|
try:
|
|
295
|
-
action =
|
|
331
|
+
action, exec_repairs = extract_action_details(str(raw))
|
|
332
|
+
ctx.trace.repair("execute", repairs=exec_repairs)
|
|
296
333
|
except ValueError as exc:
|
|
297
334
|
parse_failures += 1
|
|
298
|
-
|
|
299
|
-
"state": AgentState.EXECUTING.value, "action": "parse_error",
|
|
300
|
-
"raw": str(raw)[:400], "error": str(exc),
|
|
301
|
-
})
|
|
302
|
-
if parse_failures >= 3:
|
|
335
|
+
if self._note_parse_failure(ctx, raw, exc, parse_failures):
|
|
303
336
|
break
|
|
304
|
-
# Weak models often need one concrete reminder of the wire
|
|
305
|
-
# format; feed it through the corrections channel and retry
|
|
306
|
-
# instead of aborting the whole run on the first slip.
|
|
307
|
-
hint = (
|
|
308
|
-
'Your last reply was not a single JSON action object. Reply with '
|
|
309
|
-
'EXACTLY one JSON object like {"thoughts": "...", "action": '
|
|
310
|
-
'"tool_name", "args": {...}} and nothing else.'
|
|
311
|
-
)
|
|
312
|
-
if hint not in ctx.corrections:
|
|
313
|
-
ctx.corrections.append(hint)
|
|
314
337
|
continue
|
|
315
338
|
|
|
316
339
|
name = action.get("action")
|
|
@@ -329,22 +352,17 @@ class SingleAgentRuntime:
|
|
|
329
352
|
ctx.transcript.append({
|
|
330
353
|
"state": AgentState.EXECUTING.value, "action": "final", "thoughts": thoughts,
|
|
331
354
|
})
|
|
355
|
+
ctx.trace.decision("execute", decision="final")
|
|
332
356
|
ctx.state = AgentState.VERIFYING
|
|
333
357
|
return
|
|
334
358
|
|
|
335
359
|
# Loop guard
|
|
336
|
-
|
|
337
|
-
last = exec_steps[-1] if exec_steps else None
|
|
338
|
-
if (
|
|
339
|
-
name in d.file_create_actions and last
|
|
340
|
-
and last.get("action") == name
|
|
341
|
-
and (last.get("args") or {}) == args
|
|
342
|
-
and "result" in last
|
|
343
|
-
):
|
|
360
|
+
if self._is_repeated_create(ctx, name, args):
|
|
344
361
|
ctx.transcript.append({
|
|
345
362
|
"state": AgentState.EXECUTING.value, "action": name,
|
|
346
363
|
"error": "LOOP_DETECTED: identical action+args repeated — halted.",
|
|
347
364
|
})
|
|
365
|
+
ctx.trace.decision("execute", decision="loop_detected", tool=name)
|
|
348
366
|
break
|
|
349
367
|
|
|
350
368
|
if name == "clear_history":
|
|
@@ -358,58 +376,203 @@ class SingleAgentRuntime:
|
|
|
358
376
|
policy = d.policy_for(name, args)
|
|
359
377
|
risk = d.risk_level(policy)
|
|
360
378
|
|
|
361
|
-
|
|
362
|
-
ctx
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
"error": f"BLOCKED: destructive action '{name}' not permitted in agent mode.",
|
|
367
|
-
})
|
|
368
|
-
d.audit(
|
|
369
|
-
"agent_blocked", user_email=current_user, source=getattr(req, "source", None) or "agent",
|
|
370
|
-
action=name, reason="destructive", governance=dict(policy),
|
|
371
|
-
)
|
|
379
|
+
proposed, governor_allows_additive = self._governor_review(
|
|
380
|
+
ctx, name, thoughts, args, policy, risk, current_user, request_workspace,
|
|
381
|
+
conversation_id=getattr(req, "conversation_id", None),
|
|
382
|
+
)
|
|
383
|
+
if proposed:
|
|
372
384
|
continue
|
|
373
385
|
|
|
374
|
-
if
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
shell=policy["shell"], network=policy["network"],
|
|
379
|
-
destructive=policy["destructive"], sandbox=policy["sandbox"],
|
|
380
|
-
rollback=policy["rollback"],
|
|
381
|
-
args={k: v for k, v in args.items() if k != "content"},
|
|
382
|
-
)
|
|
383
|
-
ctx.transcript.append({
|
|
384
|
-
"state": AgentState.EXECUTING.value, "action": name,
|
|
385
|
-
"thoughts": thoughts, "args": args, "risk": risk,
|
|
386
|
-
"governance": dict(policy),
|
|
387
|
-
"error": f"BLOCKED: action '{name}' requires explicit approval.",
|
|
388
|
-
})
|
|
386
|
+
if self._blocked_by_gates(
|
|
387
|
+
ctx, req, name, thoughts, args, policy, risk,
|
|
388
|
+
current_user, governor_allows_additive,
|
|
389
|
+
):
|
|
389
390
|
continue
|
|
390
391
|
|
|
391
|
-
|
|
392
|
-
d.check_role(name, current_user)
|
|
393
|
-
# Shared tool lifecycle: pre_tool (may block) → execute → post_tool.
|
|
394
|
-
result = dispatch_tool(
|
|
395
|
-
d.hooks, name, args,
|
|
396
|
-
lambda: d.execute_tool(name, args),
|
|
397
|
-
user_email=current_user, source="agent",
|
|
398
|
-
)
|
|
399
|
-
ctx.transcript.append({
|
|
400
|
-
"state": AgentState.EXECUTING.value, "action": name,
|
|
401
|
-
"thoughts": thoughts, "args": args,
|
|
402
|
-
"risk": risk, "governance": dict(policy), "result": result,
|
|
403
|
-
})
|
|
404
|
-
except (ToolError, KeyError, TypeError, PermissionError) as exc:
|
|
405
|
-
ctx.transcript.append({
|
|
406
|
-
"state": AgentState.EXECUTING.value, "action": name,
|
|
407
|
-
"thoughts": thoughts, "args": args,
|
|
408
|
-
"risk": risk, "governance": dict(policy), "error": str(exc),
|
|
409
|
-
})
|
|
392
|
+
self._dispatch_step(ctx, name, thoughts, args, policy, risk, current_user)
|
|
410
393
|
|
|
411
394
|
ctx.state = AgentState.VERIFYING
|
|
412
395
|
|
|
396
|
+
def _executor_context(
|
|
397
|
+
self, ctx: AgentRunContext, req: Any, lang_hint: str,
|
|
398
|
+
current_user: str, request_workspace: Optional[str],
|
|
399
|
+
) -> str:
|
|
400
|
+
"""Assemble one executor turn's prompt (plan, corrections, recent chat)."""
|
|
401
|
+
d = self.deps
|
|
402
|
+
corrections_hint = (
|
|
403
|
+
"\n\nCritic corrections from previous attempt:\n"
|
|
404
|
+
+ "\n".join(f"- {c}" for c in ctx.corrections)
|
|
405
|
+
) if ctx.corrections else ""
|
|
406
|
+
|
|
407
|
+
recent_kwargs = {
|
|
408
|
+
"conversation_id": req.conversation_id,
|
|
409
|
+
"user_email": current_user or None,
|
|
410
|
+
}
|
|
411
|
+
if request_workspace is not None:
|
|
412
|
+
recent_kwargs["workspace_id"] = request_workspace
|
|
413
|
+
recent_conversation = d.recent_chat_context(**recent_kwargs) or "(none)"
|
|
414
|
+
return (
|
|
415
|
+
f"{d.executor_prompt}\n\n"
|
|
416
|
+
f"[LANGUAGE HINT: {lang_hint}]\n"
|
|
417
|
+
f"Workspace root: {d.agent_root}\n\n"
|
|
418
|
+
f"PLAN:\n{json.dumps(ctx.plan, ensure_ascii=False)}\n\n"
|
|
419
|
+
f"Recent conversation:\n{recent_conversation}\n\n"
|
|
420
|
+
f"User request: {req.message}{corrections_hint}\n\n"
|
|
421
|
+
f"Execution transcript:\n{json.dumps(ctx.transcript, ensure_ascii=False, indent=2)}"
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
def _note_parse_failure(
|
|
425
|
+
self, ctx: AgentRunContext, raw: Any, exc: ValueError, parse_failures: int,
|
|
426
|
+
) -> bool:
|
|
427
|
+
"""Record one executor parse slip; True when the run should stop retrying."""
|
|
428
|
+
ctx.transcript.append({
|
|
429
|
+
"state": AgentState.EXECUTING.value, "action": "parse_error",
|
|
430
|
+
"raw": str(raw)[:400], "error": str(exc),
|
|
431
|
+
})
|
|
432
|
+
if parse_failures >= 3:
|
|
433
|
+
ctx.trace.parse_error("execute", error=str(exc), recovered=False)
|
|
434
|
+
return True
|
|
435
|
+
ctx.trace.parse_error("execute", error=str(exc), recovered=True)
|
|
436
|
+
# Weak models often need one concrete reminder of the wire
|
|
437
|
+
# format; feed it through the corrections channel and retry
|
|
438
|
+
# instead of aborting the whole run on the first slip.
|
|
439
|
+
hint = (
|
|
440
|
+
'Your last reply was not a single JSON action object. Reply with '
|
|
441
|
+
'EXACTLY one JSON object like {"thoughts": "...", "action": '
|
|
442
|
+
'"tool_name", "args": {...}} and nothing else.'
|
|
443
|
+
)
|
|
444
|
+
if parse_failures >= 2:
|
|
445
|
+
# Escalate: name the valid tools so the model stops
|
|
446
|
+
# inventing action names or prose.
|
|
447
|
+
valid = ", ".join(sorted(self.deps.tool_governance.keys()))
|
|
448
|
+
hint = (
|
|
449
|
+
f"{hint} Valid action values are: {valid}, final. "
|
|
450
|
+
'Use {"action": "final", "message": "..."} to finish.'
|
|
451
|
+
)
|
|
452
|
+
if hint not in ctx.corrections:
|
|
453
|
+
ctx.corrections.append(hint)
|
|
454
|
+
ctx.trace.correction("execute", hint=hint)
|
|
455
|
+
return False
|
|
456
|
+
|
|
457
|
+
def _is_repeated_create(self, ctx: AgentRunContext, name: Any, args: dict) -> bool:
|
|
458
|
+
"""Loop guard: the same file-create action+args re-issued right after a result."""
|
|
459
|
+
exec_steps = [s for s in ctx.transcript if s.get("state") == AgentState.EXECUTING.value]
|
|
460
|
+
last = exec_steps[-1] if exec_steps else None
|
|
461
|
+
return bool(
|
|
462
|
+
name in self.deps.file_create_actions and last
|
|
463
|
+
and last.get("action") == name
|
|
464
|
+
and (last.get("args") or {}) == args
|
|
465
|
+
and "result" in last
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
def _governor_review(
|
|
469
|
+
self, ctx: AgentRunContext, name: str, thoughts: str, args: dict,
|
|
470
|
+
policy: dict, risk: str, current_user: str, request_workspace: Optional[str],
|
|
471
|
+
conversation_id: Optional[str] = None,
|
|
472
|
+
) -> Tuple[bool, bool]:
|
|
473
|
+
"""Central change-class governance: create-new runs with minimal
|
|
474
|
+
friction, change/delete-existing becomes a review proposal.
|
|
475
|
+
|
|
476
|
+
Returns ``(proposed, governor_allows_additive)``: ``proposed`` means the
|
|
477
|
+
step was staged as a proposal (skip execution); ``allows_additive`` lets
|
|
478
|
+
an additive create pass the classic approval gate.
|
|
479
|
+
"""
|
|
480
|
+
d = self.deps
|
|
481
|
+
if d.change_governor is None:
|
|
482
|
+
return False, False
|
|
483
|
+
verdict = d.change_governor.review(
|
|
484
|
+
name, args, policy=dict(policy),
|
|
485
|
+
user_email=current_user, workspace_id=request_workspace,
|
|
486
|
+
conversation_id=conversation_id,
|
|
487
|
+
)
|
|
488
|
+
if verdict is not None and verdict.get("decision") == "proposed":
|
|
489
|
+
proposal = verdict.get("proposal") or {}
|
|
490
|
+
ctx.trace.tool("execute", name=name, outcome="proposed", risk=risk)
|
|
491
|
+
ctx.transcript.append({
|
|
492
|
+
"state": AgentState.EXECUTING.value, "action": name,
|
|
493
|
+
"thoughts": thoughts, "args": {k: v for k, v in args.items() if k != "content"},
|
|
494
|
+
"risk": risk, "governance": dict(policy),
|
|
495
|
+
"result": {
|
|
496
|
+
"proposed": True,
|
|
497
|
+
"proposal_id": proposal.get("id"),
|
|
498
|
+
"note": "기존 내용을 바꾸는 작업이라 변경 제안으로 저장했습니다. 검토함에서 승인하면 적용됩니다.",
|
|
499
|
+
},
|
|
500
|
+
})
|
|
501
|
+
d.audit(
|
|
502
|
+
"agent_change_proposed", user_email=current_user,
|
|
503
|
+
action=name, proposal_id=proposal.get("id"),
|
|
504
|
+
change_class=(verdict.get("classification") or {}).get("change_class"),
|
|
505
|
+
)
|
|
506
|
+
return True, False
|
|
507
|
+
return False, (verdict is not None and verdict.get("decision") == "allow_additive")
|
|
508
|
+
|
|
509
|
+
def _blocked_by_gates(
|
|
510
|
+
self, ctx: AgentRunContext, req: Any, name: str, thoughts: str, args: dict,
|
|
511
|
+
policy: dict, risk: str, current_user: str, governor_allows_additive: bool,
|
|
512
|
+
) -> bool:
|
|
513
|
+
"""Classic destructive / explicit-approval gates; True when the step was blocked."""
|
|
514
|
+
d = self.deps
|
|
515
|
+
if policy["risk"] == "destructive":
|
|
516
|
+
ctx.trace.tool("execute", name=name, outcome="blocked_destructive", risk=risk)
|
|
517
|
+
ctx.transcript.append({
|
|
518
|
+
"state": AgentState.EXECUTING.value, "action": name,
|
|
519
|
+
"thoughts": thoughts, "args": args, "risk": risk,
|
|
520
|
+
"governance": dict(policy),
|
|
521
|
+
"error": f"BLOCKED: destructive action '{name}' not permitted in agent mode.",
|
|
522
|
+
})
|
|
523
|
+
d.audit(
|
|
524
|
+
"agent_blocked", user_email=current_user, source=getattr(req, "source", None) or "agent",
|
|
525
|
+
action=name, reason="destructive", governance=dict(policy),
|
|
526
|
+
)
|
|
527
|
+
return True
|
|
528
|
+
|
|
529
|
+
if not policy["auto_approve"] and not ctx.approved_by_human and not governor_allows_additive:
|
|
530
|
+
d.audit(
|
|
531
|
+
"agent_exec", user_email=current_user, source=getattr(req, "source", None) or "agent",
|
|
532
|
+
state=AgentState.EXECUTING.value, action=name, risk=risk,
|
|
533
|
+
shell=policy["shell"], network=policy["network"],
|
|
534
|
+
destructive=policy["destructive"], sandbox=policy["sandbox"],
|
|
535
|
+
rollback=policy["rollback"],
|
|
536
|
+
args={k: v for k, v in args.items() if k != "content"},
|
|
537
|
+
)
|
|
538
|
+
ctx.trace.tool("execute", name=name, outcome="blocked_approval", risk=risk)
|
|
539
|
+
ctx.transcript.append({
|
|
540
|
+
"state": AgentState.EXECUTING.value, "action": name,
|
|
541
|
+
"thoughts": thoughts, "args": args, "risk": risk,
|
|
542
|
+
"governance": dict(policy),
|
|
543
|
+
"error": f"BLOCKED: action '{name}' requires explicit approval.",
|
|
544
|
+
})
|
|
545
|
+
return True
|
|
546
|
+
return False
|
|
547
|
+
|
|
548
|
+
def _dispatch_step(
|
|
549
|
+
self, ctx: AgentRunContext, name: str, thoughts: str, args: dict,
|
|
550
|
+
policy: dict, risk: str, current_user: str,
|
|
551
|
+
) -> None:
|
|
552
|
+
"""Role check + shared tool lifecycle, recorded on the transcript either way."""
|
|
553
|
+
d = self.deps
|
|
554
|
+
try:
|
|
555
|
+
d.check_role(name, current_user)
|
|
556
|
+
# Shared tool lifecycle: pre_tool (may block) → execute → post_tool.
|
|
557
|
+
result = dispatch_tool(
|
|
558
|
+
d.hooks, name, args,
|
|
559
|
+
lambda: d.execute_tool(name, args),
|
|
560
|
+
user_email=current_user, source="agent",
|
|
561
|
+
)
|
|
562
|
+
ctx.trace.tool("execute", name=name, outcome="ok", risk=risk)
|
|
563
|
+
ctx.transcript.append({
|
|
564
|
+
"state": AgentState.EXECUTING.value, "action": name,
|
|
565
|
+
"thoughts": thoughts, "args": args,
|
|
566
|
+
"risk": risk, "governance": dict(policy), "result": result,
|
|
567
|
+
})
|
|
568
|
+
except (ToolError, KeyError, TypeError, PermissionError) as exc:
|
|
569
|
+
ctx.trace.tool("execute", name=name, outcome="error", risk=risk)
|
|
570
|
+
ctx.transcript.append({
|
|
571
|
+
"state": AgentState.EXECUTING.value, "action": name,
|
|
572
|
+
"thoughts": thoughts, "args": args,
|
|
573
|
+
"risk": risk, "governance": dict(policy), "error": str(exc),
|
|
574
|
+
})
|
|
575
|
+
|
|
413
576
|
# ── VERIFY ───────────────────────────────────────────────────────
|
|
414
577
|
async def verify(
|
|
415
578
|
self, ctx: AgentRunContext, req: Any, lang_hint: str, current_user: str,
|
|
@@ -429,9 +592,12 @@ class SingleAgentRuntime:
|
|
|
429
592
|
message="Review the execution transcript and return your verdict JSON.",
|
|
430
593
|
context=context, max_tokens=512, temperature=0.1,
|
|
431
594
|
)
|
|
595
|
+
ctx.trace.llm_call("verify", model=model_id)
|
|
432
596
|
try:
|
|
433
|
-
verdict =
|
|
434
|
-
|
|
597
|
+
verdict, verdict_repairs = extract_action_details(str(raw))
|
|
598
|
+
ctx.trace.repair("verify", repairs=verdict_repairs)
|
|
599
|
+
except ValueError as exc:
|
|
600
|
+
ctx.trace.parse_error("verify", error=str(exc), recovered=True)
|
|
435
601
|
verdict = {"action": "verdict", "verdict": "PASS", "next_state": "DONE",
|
|
436
602
|
"reason": "Critic parse failed — assuming pass.", "corrections": [], "confidence": 0.7}
|
|
437
603
|
|
|
@@ -449,6 +615,7 @@ class SingleAgentRuntime:
|
|
|
449
615
|
"next_state": next_s,
|
|
450
616
|
})
|
|
451
617
|
|
|
618
|
+
ctx.trace.decision("verify", decision=str(verdict.get("verdict", "PASS")), next_state=next_s)
|
|
452
619
|
if verdict.get("verdict") == "PASS" or next_s == "DONE":
|
|
453
620
|
if not ctx.final_message:
|
|
454
621
|
ctx.final_message = verdict.get("reason", "작업이 완료되었습니다.")
|
|
@@ -461,6 +628,7 @@ class SingleAgentRuntime:
|
|
|
461
628
|
ctx.state = AgentState.FAILED
|
|
462
629
|
else:
|
|
463
630
|
ctx.retry_count += 1
|
|
631
|
+
ctx.trace.retry("verify", attempt=ctx.retry_count)
|
|
464
632
|
ctx.transcript.append({
|
|
465
633
|
"state": AgentState.EXECUTING.value,
|
|
466
634
|
"retry_attempt": ctx.retry_count,
|
|
@@ -497,6 +665,10 @@ class SingleAgentRuntime:
|
|
|
497
665
|
rolled.append({"path": path, "ok": False, "error": str(exc)})
|
|
498
666
|
|
|
499
667
|
ctx.transcript.append({"state": AgentState.ROLLBACK.value, "rolled_back": rolled})
|
|
668
|
+
ctx.trace.decision(
|
|
669
|
+
"rollback", decision="rolled_back",
|
|
670
|
+
attempted=len(rolled), recovered=sum(1 for r in rolled if r.get("ok")),
|
|
671
|
+
)
|
|
500
672
|
recovered = [r["path"] for r in rolled if r.get("ok")]
|
|
501
673
|
ctx.final_message = (
|
|
502
674
|
f"실행 실패로 롤백했습니다. 복구 파일: {recovered}"
|