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.
Files changed (71) hide show
  1. package/README.md +67 -43
  2. package/auto_setup.py +11 -1
  3. package/docs/CHANGELOG.md +86 -0
  4. package/docs/COMMUNITY_AND_PLUGINS.md +2 -2
  5. package/docs/DEVELOPMENT.md +8 -8
  6. package/docs/LEGACY_COMPATIBILITY.md +1 -1
  7. package/docs/ONBOARDING.md +2 -2
  8. package/docs/OPERATIONS.md +1 -1
  9. package/docs/PERFORMANCE.md +106 -0
  10. package/docs/TRUST_MODEL.md +1 -1
  11. package/docs/WHY_LATTICE.md +1 -1
  12. package/docs/kg-schema.md +1 -1
  13. package/docs/mcp-tools.md +1 -1
  14. package/kg_schema.py +11 -1
  15. package/knowledge_graph.py +12 -2
  16. package/knowledge_graph_api.py +11 -1
  17. package/lattice_brain/__init__.py +1 -1
  18. package/lattice_brain/graph/proactive.py +583 -0
  19. package/lattice_brain/graph/retrieval.py +211 -7
  20. package/lattice_brain/graph/retrieval_vector.py +102 -0
  21. package/lattice_brain/ingestion.py +360 -4
  22. package/lattice_brain/quality.py +44 -9
  23. package/lattice_brain/runtime/multi_agent.py +38 -2
  24. package/latticeai/__init__.py +1 -1
  25. package/latticeai/api/brain_intelligence.py +27 -2
  26. package/latticeai/api/change_proposals.py +89 -0
  27. package/latticeai/api/chat_agent_http.py +2 -0
  28. package/latticeai/api/review_queue.py +66 -2
  29. package/latticeai/app_factory.py +23 -0
  30. package/latticeai/cli/entrypoint.py +3 -1
  31. package/latticeai/core/agent.py +273 -101
  32. package/latticeai/core/agent_eval.py +411 -0
  33. package/latticeai/core/agent_trace.py +104 -0
  34. package/latticeai/core/legacy_compatibility.py +15 -1
  35. package/latticeai/core/marketplace.py +1 -1
  36. package/latticeai/core/tool_governor.py +101 -0
  37. package/latticeai/core/workspace_os.py +1 -1
  38. package/latticeai/runtime/router_registration.py +2 -0
  39. package/latticeai/services/architecture_readiness.py +1 -1
  40. package/latticeai/services/brain_intelligence.py +97 -1
  41. package/latticeai/services/change_proposals.py +322 -0
  42. package/latticeai/services/product_readiness.py +1 -1
  43. package/latticeai/services/review_queue.py +47 -3
  44. package/latticeai/tools/__init__.py +6 -1
  45. package/llm_router.py +10 -1
  46. package/local_knowledge_api.py +10 -1
  47. package/ltcai_cli.py +11 -1
  48. package/mcp_registry.py +10 -1
  49. package/p_reinforce.py +10 -1
  50. package/package.json +1 -1
  51. package/scripts/agent_eval.py +46 -0
  52. package/scripts/brain_quality_eval.py +1 -1
  53. package/scripts/check_current_release_docs.mjs +2 -1
  54. package/scripts/profile_kg.py +360 -0
  55. package/setup_wizard.py +10 -1
  56. package/src-tauri/Cargo.lock +1 -1
  57. package/src-tauri/Cargo.toml +1 -1
  58. package/src-tauri/tauri.conf.json +1 -1
  59. package/static/app/asset-manifest.json +11 -11
  60. package/static/app/assets/{Act-Cdfqx4wN.js → Act-B6c39ays.js} +2 -1
  61. package/static/app/assets/{Brain-w_tAuyg6.js → Brain-D7Qg4k6M.js} +1 -1
  62. package/static/app/assets/{Capture-iJwi9LmS.js → Capture-VF_di68r.js} +1 -1
  63. package/static/app/assets/{Library-Do-jjhzi.js → Library-D_Gis2PA.js} +1 -1
  64. package/static/app/assets/{System-DFg_q3E6.js → System-C5s5H2ov.js} +1 -1
  65. package/static/app/assets/{index-BN6HIWVC.css → index-85wQvEie.css} +1 -1
  66. package/static/app/assets/index-DJC_2oub.js +18 -0
  67. package/static/app/assets/{primitives-ZTUlU7pR.js → primitives-DL4Nip8C.js} +1 -1
  68. package/static/app/assets/{textarea-CMfhqPhE.js → textarea-woZfCXHy.js} +1 -1
  69. package/static/app/index.html +2 -2
  70. package/static/sw.js +1 -1
  71. package/static/app/assets/index-aJuRi-Xo.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 = (
@@ -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
- recent_kwargs = {
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 = extract_action(str(raw))
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
- ctx.transcript.append({
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
- exec_steps = [s for s in ctx.transcript if s.get("state") == AgentState.EXECUTING.value]
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
- if policy["risk"] == "destructive":
362
- ctx.transcript.append({
363
- "state": AgentState.EXECUTING.value, "action": name,
364
- "thoughts": thoughts, "args": args, "risk": risk,
365
- "governance": dict(policy),
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 not policy["auto_approve"] and not ctx.approved_by_human:
375
- d.audit(
376
- "agent_exec", user_email=current_user, source=getattr(req, "source", None) or "agent",
377
- state=AgentState.EXECUTING.value, action=name, risk=risk,
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
- try:
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 = extract_action(str(raw))
434
- except ValueError:
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}"