ltcai 9.9.3 → 9.9.5

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 (89) hide show
  1. package/README.md +39 -38
  2. package/docs/CHANGELOG.md +100 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/ONBOARDING.md +1 -1
  6. package/docs/OPERATIONS.md +1 -1
  7. package/docs/SURFACE_PARITY.md +53 -0
  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/lattice_brain/__init__.py +1 -1
  12. package/lattice_brain/graph/_kg_common.py +335 -0
  13. package/lattice_brain/graph/discovery_index.py +8 -1
  14. package/lattice_brain/graph/ingest.py +43 -9
  15. package/lattice_brain/graph/projection.py +221 -37
  16. package/lattice_brain/graph/rerank.py +160 -0
  17. package/lattice_brain/graph/retrieval.py +90 -455
  18. package/lattice_brain/graph/retrieval_policy.py +174 -0
  19. package/lattice_brain/graph/retrieval_reads.py +459 -0
  20. package/lattice_brain/graph/retrieval_vector.py +122 -2
  21. package/lattice_brain/graph/store.py +2 -0
  22. package/lattice_brain/portability.py +41 -12
  23. package/lattice_brain/quality.py +26 -6
  24. package/lattice_brain/runtime/multi_agent.py +1 -1
  25. package/latticeai/__init__.py +1 -1
  26. package/latticeai/api/chat.py +6 -0
  27. package/latticeai/api/chat_agent_http.py +262 -52
  28. package/latticeai/api/chat_contracts.py +1 -1
  29. package/latticeai/api/chat_intents.py +37 -18
  30. package/latticeai/api/chat_stream.py +76 -2
  31. package/latticeai/api/knowledge_graph.py +35 -1
  32. package/latticeai/core/agent.py +420 -27
  33. package/latticeai/core/enterprise.py +5 -0
  34. package/latticeai/core/file_generation.py +130 -5
  35. package/latticeai/core/legacy_compatibility.py +1 -1
  36. package/latticeai/core/marketplace.py +1 -1
  37. package/latticeai/core/run_store.py +245 -0
  38. package/latticeai/core/workspace_os.py +1 -1
  39. package/latticeai/integrations/telegram_bot.py +82 -23
  40. package/latticeai/models/router.py +25 -12
  41. package/latticeai/services/architecture_readiness.py +1 -1
  42. package/latticeai/services/command_center.py +90 -1
  43. package/latticeai/services/folder_watch.py +5 -0
  44. package/latticeai/services/funnel_metrics.py +9 -1
  45. package/latticeai/services/product_readiness.py +1 -1
  46. package/latticeai/services/search_service.py +43 -13
  47. package/latticeai/services/tool_dispatch.py +56 -0
  48. package/package.json +2 -1
  49. package/scripts/bench_agent_smoke.py +410 -0
  50. package/scripts/check_current_release_docs.mjs +1 -1
  51. package/scripts/funnel_soft_gate.py +192 -0
  52. package/scripts/run_sidecar_e2e.mjs +159 -0
  53. package/src-tauri/Cargo.lock +1 -1
  54. package/src-tauri/Cargo.toml +1 -1
  55. package/src-tauri/tauri.conf.json +1 -1
  56. package/static/app/asset-manifest.json +29 -28
  57. package/static/app/assets/Act-DmxfbqBp.js +1 -0
  58. package/static/app/assets/{Brain-DEY9jLVt.js → Brain-DK_zKCda.js} +2 -2
  59. package/static/app/assets/BrainHome-CCE_0hni.js +2 -0
  60. package/static/app/assets/BrainSignals-r_J68_zh.js +1 -0
  61. package/static/app/assets/{Capture-CVZ09QXi.js → Capture-CvxwzpJZ.js} +1 -1
  62. package/static/app/assets/{CommandPalette-DepwOQFv.js → CommandPalette-CVLiFIQC.js} +1 -1
  63. package/static/app/assets/{Library-Bp0n-HlW.js → Library-4As9VUvy.js} +1 -1
  64. package/static/app/assets/{LivingBrain-DxP4efJF.js → LivingBrain-BVWk58MW.js} +1 -1
  65. package/static/app/assets/{ProductFlow-DRbm7NEq.js → ProductFlow-B-Tz0Um4.js} +1 -1
  66. package/static/app/assets/{ReviewCard-C4HAO7A3.js → ReviewCard-Va3fPQuj.js} +1 -1
  67. package/static/app/assets/{System-ByQcmJW-.js → System-DSR2zKWd.js} +1 -1
  68. package/static/app/assets/{bot-BNDyZLR7.js → bot-VmP0kkeA.js} +1 -1
  69. package/static/app/assets/circle-pause-T9POo4qy.js +1 -0
  70. package/static/app/assets/{circle-play-BkhdcHgd.js → circle-play-Bz5iTD0p.js} +1 -1
  71. package/static/app/assets/{cpu-C6jjYm6i.js → cpu-CJhjRtNq.js} +1 -1
  72. package/static/app/assets/{folder-open-DjGIvDBQ.js → folder-open-B2K_22VI.js} +1 -1
  73. package/static/app/assets/{hard-drive-BlSbwSaT.js → hard-drive-CjJtYqHf.js} +1 -1
  74. package/static/app/assets/{index-Bge3DXW7.css → index-7FAfYm4v.css} +1 -1
  75. package/static/app/assets/{index-CHu7cgj3.js → index-CEu0Wqjl.js} +3 -3
  76. package/static/app/assets/{input-DVDI0YR3.js → input-CSi6OBJ9.js} +1 -1
  77. package/static/app/assets/{navigation-BddhEWA0.js → navigation-BPQqEQwr.js} +1 -1
  78. package/static/app/assets/{network-pYQt5oBu.js → network-Ck1nqc-v.js} +1 -1
  79. package/static/app/assets/{primitives-D7gCdEvS.js → primitives-B2f9N7Dh.js} +1 -1
  80. package/static/app/assets/{shield-alert-K9RKGQeg.js → shield-alert-yA_Y8lOB.js} +1 -1
  81. package/static/app/assets/{textarea-sqQmoBKL.js → textarea-C4wM0D-Z.js} +1 -1
  82. package/static/app/assets/{useFocusTrap-7EV9dFP2.js → useFocusTrap-CsnIhsDJ.js} +1 -1
  83. package/static/app/assets/utils-BqoznzGP.js +7 -0
  84. package/static/app/index.html +4 -4
  85. package/static/sw.js +1 -1
  86. package/static/app/assets/Act-DmdruVKV.js +0 -1
  87. package/static/app/assets/BrainHome-CeNaxjP1.js +0 -2
  88. package/static/app/assets/BrainSignals-CStjIqYi.js +0 -1
  89. package/static/app/assets/utils-uQYKXNeq.js +0 -7
@@ -32,7 +32,11 @@ from typing import Any, Awaitable, Callable, Dict, FrozenSet, List, Mapping, Opt
32
32
  from lattice_brain.runtime.hooks import dispatch_tool
33
33
  from lattice_brain.runtime.contracts import runtime_boundary_contract, single_agent_contract
34
34
  from latticeai.core.agent_trace import LoopTrace
35
- from latticeai.core.file_generation import infer_file_target, sanitize_write_content
35
+ from latticeai.core.file_generation import (
36
+ infer_file_target,
37
+ infer_project_manifest,
38
+ sanitize_write_content,
39
+ )
36
40
  from latticeai.core.tool_registry import SCOPED_KNOWLEDGE_TOOLS
37
41
  from latticeai.tools import ToolError
38
42
 
@@ -62,7 +66,8 @@ class AgentRunContext:
62
66
  """Mutable state carrier passed through all agent phases."""
63
67
  __slots__ = ("state", "plan", "transcript", "retry_count",
64
68
  "state_history", "corrections", "final_message", "rollback_log",
65
- "executing_model", "reviewing_model", "approved_by_human", "trace")
69
+ "executing_model", "reviewing_model", "approved_by_human", "trace",
70
+ "on_step")
66
71
 
67
72
  def __init__(self) -> None:
68
73
  self.state: AgentState = AgentState.IDLE
@@ -77,6 +82,10 @@ class AgentRunContext:
77
82
  self.executing_model: Optional[str] = None
78
83
  self.reviewing_model: Optional[str] = None
79
84
  self.approved_by_human: bool = False
85
+ # Per-run step observer (review Wave 1.1): the HTTP layer attaches a
86
+ # callback here so live SSE clients see progress while EXECUTING.
87
+ # Never serialized; a broken observer never breaks the loop.
88
+ self.on_step: Optional[Callable[[Dict[str, Any]], None]] = None
80
89
 
81
90
 
82
91
  _THINK_BLOCK_RE = re.compile(
@@ -142,6 +151,30 @@ def extract_action(raw: str) -> Dict:
142
151
  return action
143
152
 
144
153
 
154
+ _FILE_CREATE_PLAN_ACTIONS = frozenset({"write_file", "generate_file"})
155
+
156
+
157
+ def _plan_misses_manifest(steps: List[Dict[str, Any]], manifest: Dict[str, Any]) -> bool:
158
+ """True when a pure file-writing plan fails to cover the manifest's file types.
159
+
160
+ Only pure file-creation plans are candidates for rewriting — a plan with
161
+ read/search steps reflects real planner intent and stays untouched.
162
+ """
163
+ if any(s.get("action") not in _FILE_CREATE_PLAN_ACTIONS for s in steps):
164
+ return False
165
+
166
+ def _ext(path: Any) -> str:
167
+ text = str(path or "")
168
+ dot = text.rfind(".")
169
+ return text[dot:].lower() if dot >= 0 else ""
170
+
171
+ planned_exts = {
172
+ _ext((s.get("args") or {}).get("path")) for s in steps
173
+ }
174
+ manifest_exts = {_ext(spec.get("path")) for spec in manifest.get("files", [])}
175
+ return not manifest_exts.issubset(planned_exts)
176
+
177
+
145
178
  def normalize_plan(plan: Any, user_message: str) -> Tuple[Dict[str, Any], List[str]]:
146
179
  """Enforce the minimal plan schema so execution never starts adrift.
147
180
 
@@ -173,6 +206,27 @@ def normalize_plan(plan: Any, user_message: str) -> Tuple[Dict[str, Any], List[s
173
206
  ]
174
207
  if raw_steps and steps != raw_steps:
175
208
  fixes.append("steps_filtered")
209
+
210
+ # Manifest-aware planning (review Wave 0.4): when the request is a
211
+ # recognized multi-file project, the deterministic manifest — not the
212
+ # planner's improvisation — decides the file set, exactly like the direct
213
+ # chat path. Rewrites apply only when the plan is empty or is a pure
214
+ # file-writing plan that misses part of the manifest, so a planner that
215
+ # already covered every requested file type is left untouched.
216
+ manifest = infer_project_manifest(user_message)
217
+ if manifest:
218
+ manifest_steps = [{
219
+ "action": "write_file",
220
+ "args": {"path": spec["path"]},
221
+ "description": spec["brief"],
222
+ } for spec in manifest["files"]]
223
+ if not steps:
224
+ steps = manifest_steps
225
+ fixes.append("manifest_steps")
226
+ elif _plan_misses_manifest(steps, manifest):
227
+ steps = manifest_steps
228
+ fixes.append("manifest_rewrite")
229
+
176
230
  if not steps:
177
231
  inferred = infer_file_target(user_message)
178
232
  if inferred:
@@ -227,6 +281,168 @@ def filter_learnings(learnings: List[Any]) -> List[str]:
227
281
  return kept
228
282
 
229
283
 
284
+ def _truncate_strings(value: Any, limit: int) -> Any:
285
+ """Deep-copy ``value`` with every string capped at ``limit`` chars.
286
+
287
+ Long tool outputs (file bodies, command output) dominate executor prompt
288
+ size without adding decision-relevant signal. The cap keeps the head of
289
+ each string and names how much was dropped, so the model still sees what
290
+ the value was — never a silent hole.
291
+ """
292
+ if isinstance(value, str):
293
+ if len(value) <= limit:
294
+ return value
295
+ return value[:limit] + f"…[+{len(value) - limit} chars]"
296
+ if isinstance(value, dict):
297
+ return {k: _truncate_strings(v, limit) for k, v in value.items()}
298
+ if isinstance(value, list):
299
+ return [_truncate_strings(v, limit) for v in value]
300
+ return value
301
+
302
+
303
+ def compact_transcript(
304
+ transcript: List[Dict[str, Any]],
305
+ *,
306
+ window: int = 8,
307
+ result_chars: int = 700,
308
+ ) -> List[Dict[str, Any]]:
309
+ """Bounded executor view of a transcript (review Wave 0.3).
310
+
311
+ The executor prompt previously embedded the *entire* transcript JSON every
312
+ step — O(steps²) token growth that starved :class:`PhaseBudgets` on long
313
+ runs and buried weak models in stale detail. This view keeps the most
314
+ recent ``window`` steps in full (with string values capped at
315
+ ``result_chars``) and reduces every older step to a one-line summary, so
316
+ the prompt stays bounded while no step disappears entirely.
317
+ """
318
+ steps = list(transcript or [])
319
+ if len(steps) <= window:
320
+ return [_truncate_strings(step, result_chars) for step in steps]
321
+ older, recent = steps[:-window], steps[-window:]
322
+ summarized: List[Dict[str, Any]] = [{
323
+ "summarized_older_steps": len(older),
324
+ "note": "older steps compacted — full detail retained in the run record",
325
+ }]
326
+ for step in older:
327
+ entry: Dict[str, Any] = {"state": step.get("state")}
328
+ for key in ("action", "verdict", "retry_attempt"):
329
+ if step.get(key) is not None:
330
+ entry[key] = step.get(key)
331
+ if step.get("error"):
332
+ entry["error"] = str(step["error"])[:160]
333
+ elif isinstance(step.get("result"), dict):
334
+ entry["ok"] = True
335
+ path = step["result"].get("path") or (step.get("args") or {}).get("path")
336
+ if path:
337
+ entry["path"] = str(path)
338
+ summarized.append(entry)
339
+ summarized.extend(_truncate_strings(step, result_chars) for step in recent)
340
+ return summarized
341
+
342
+
343
+ def files_written(
344
+ transcript: List[Dict[str, Any]],
345
+ file_create_actions: FrozenSet[str],
346
+ ) -> List[str]:
347
+ """Ordered unique paths of files this run successfully wrote (review L5).
348
+
349
+ Later executor steps get this as explicit context, so "만들고 이어서
350
+ 설명해" multi-step work sees its own output instead of a stale
351
+ workspace picture.
352
+ """
353
+ seen: List[str] = []
354
+ for step in transcript:
355
+ if step.get("state") != AgentState.EXECUTING.value:
356
+ continue
357
+ if step.get("action") not in file_create_actions:
358
+ continue
359
+ if not isinstance(step.get("result"), dict):
360
+ continue
361
+ path = step["result"].get("path") or (step.get("args") or {}).get("path")
362
+ if path and str(path) not in seen:
363
+ seen.append(str(path))
364
+ return seen
365
+
366
+
367
+ def artifact_checklist(
368
+ transcript: List[Dict[str, Any]],
369
+ file_create_actions: FrozenSet[str],
370
+ ) -> List[Dict[str, Any]]:
371
+ """Deterministic artifact facts for the critic (review L4).
372
+
373
+ The critic previously judged file work from prose alone; this surfaces
374
+ the sanitize/repair honesty flags per written file so a repaired
375
+ placeholder can never pass as a fulfilled request unchecked.
376
+ """
377
+ checklist: List[Dict[str, Any]] = []
378
+ for step in transcript:
379
+ if step.get("state") != AgentState.EXECUTING.value:
380
+ continue
381
+ if step.get("action") not in file_create_actions:
382
+ continue
383
+ if not isinstance(step.get("result"), dict):
384
+ continue
385
+ path = step["result"].get("path") or (step.get("args") or {}).get("path")
386
+ if not path:
387
+ continue
388
+ sanitize_meta = step.get("content_sanitize") or {}
389
+ checklist.append({
390
+ "path": str(path),
391
+ "sanitized": bool(sanitize_meta.get("sanitized")),
392
+ "repaired": bool(sanitize_meta.get("repaired")),
393
+ })
394
+ return checklist
395
+
396
+
397
+ def _format_artifact_checklist(checklist: List[Dict[str, Any]]) -> str:
398
+ lines = []
399
+ for item in checklist:
400
+ state = (
401
+ "auto-REPAIRED scaffold" if item["repaired"]
402
+ else ("sanitized model output" if item["sanitized"] else "written as produced")
403
+ )
404
+ lines.append(f"- {item['path']}: {state}")
405
+ return (
406
+ "Artifact checklist (deterministic, from the transcript):\n"
407
+ + "\n".join(lines)
408
+ + "\nVerify each artifact actually fulfills the user's request. An "
409
+ "auto-repaired scaffold is NOT completion unless its content "
410
+ "satisfies what was asked."
411
+ )
412
+
413
+
414
+ @dataclass(frozen=True)
415
+ class TranscriptBudget:
416
+ """Executor/critic prompt shaping caps (review Wave 0.3).
417
+
418
+ ``window`` full recent steps for the executor; per-string caps keep tool
419
+ output bodies from dominating either prompt. Overridable through the same
420
+ ``Config.from_env`` pattern as :class:`PhaseBudgets`.
421
+ """
422
+
423
+ window: int = 8
424
+ result_chars: int = 700
425
+ verify_chars: int = 1200
426
+
427
+ @classmethod
428
+ def from_env(cls, env: Optional[Mapping[str, str]] = None) -> "TranscriptBudget":
429
+ from latticeai.core.config import _int
430
+
431
+ if env is None:
432
+ import os
433
+
434
+ env = os.environ
435
+
436
+ def cap(key: str, default: int, floor: int) -> int:
437
+ return max(floor, _int(env, key, default))
438
+
439
+ return cls(
440
+ window=cap("LATTICEAI_AGENT_TRANSCRIPT_WINDOW", cls.window, 2),
441
+ result_chars=cap("LATTICEAI_AGENT_TRANSCRIPT_CHARS", cls.result_chars, 120),
442
+ verify_chars=cap("LATTICEAI_AGENT_VERIFY_CHARS", cls.verify_chars, 200),
443
+ )
444
+
445
+
230
446
  @dataclass(frozen=True)
231
447
  class PhaseBudgets:
232
448
  """Per-phase token budgets for the agent loop.
@@ -305,6 +521,16 @@ class AgentDeps:
305
521
  # state machine does not shell out directly. Tests can pass a recorder.
306
522
  rollback_file: Optional[Callable[[str], Dict[str, Any]]] = None
307
523
 
524
+ # ── snapshot rollback ports (optional, review L7) ────────────────
525
+ # git-only rollback left non-git workspaces and newly created files
526
+ # unrecoverable. ``snapshot_file(path)`` captures pre-write state
527
+ # ({"existed", "content", "too_large"}) before a file-create action;
528
+ # ``restore_snapshot(path, content)`` restores it (content=None deletes
529
+ # a file the run created). Both are production-wired with workspace
530
+ # path safety; tests pass recorders.
531
+ snapshot_file: Optional[Callable[[str], Dict[str, Any]]] = None
532
+ restore_snapshot: Optional[Callable[[str, Optional[str]], Dict[str, Any]]] = None
533
+
308
534
  # ── lifecycle hooks port (optional) ──────────────────────────────
309
535
  # When present, every tool execution fires the shared pre_tool/post_tool
310
536
  # lifecycle, so the agent tool path no longer bypasses hooks.
@@ -329,6 +555,16 @@ class AgentDeps:
329
555
  # environment once at first use; tests inject a fixed PhaseBudgets.
330
556
  phase_budgets: Optional[PhaseBudgets] = None
331
557
 
558
+ # ── transcript shaping (optional) ────────────────────────────────
559
+ # Executor/critic prompt window caps. None reads the environment once;
560
+ # tests inject a fixed TranscriptBudget.
561
+ transcript_budget: Optional["TranscriptBudget"] = None
562
+
563
+ # ── step observer port (optional) ────────────────────────────────
564
+ # Default per-runtime observer for live step events; a per-run observer
565
+ # can also be attached on AgentRunContext.on_step. Both are advisory.
566
+ on_step: Optional[Callable[[Dict[str, Any]], None]] = None
567
+
332
568
 
333
569
  class SingleAgentRuntime:
334
570
  """Drives the agent state machine over injected :class:`AgentDeps`."""
@@ -336,6 +572,7 @@ class SingleAgentRuntime:
336
572
  def __init__(self, deps: AgentDeps) -> None:
337
573
  self.deps = deps
338
574
  self._env_phase_budgets: Optional[PhaseBudgets] = None
575
+ self._env_transcript_budget: Optional[TranscriptBudget] = None
339
576
 
340
577
  @property
341
578
  def phase_budgets(self) -> PhaseBudgets:
@@ -348,6 +585,34 @@ class SingleAgentRuntime:
348
585
  self._env_phase_budgets = PhaseBudgets.from_env()
349
586
  return self._env_phase_budgets
350
587
 
588
+ @property
589
+ def transcript_budget(self) -> TranscriptBudget:
590
+ injected = getattr(self.deps, "transcript_budget", None)
591
+ if injected is not None:
592
+ return injected
593
+ if getattr(self, "_env_transcript_budget", None) is None:
594
+ self._env_transcript_budget = TranscriptBudget.from_env()
595
+ return self._env_transcript_budget
596
+
597
+ def _emit_step(self, ctx: AgentRunContext, phase: str, event: str, **details: Any) -> None:
598
+ """Fire the per-run / deps step observers (review Wave 1.1).
599
+
600
+ Observers power the live step timeline in the UI. They are pure
601
+ telemetry: any observer failure is logged and swallowed — the loop
602
+ itself must never notice.
603
+ """
604
+ payload: Dict[str, Any] = {"phase": phase, "event": event}
605
+ for key, value in details.items():
606
+ if value is not None:
607
+ payload[key] = value
608
+ for observer in (getattr(ctx, "on_step", None), getattr(self.deps, "on_step", None)):
609
+ if observer is None:
610
+ continue
611
+ try:
612
+ observer(dict(payload))
613
+ except Exception as exc: # noqa: BLE001 — observers are advisory
614
+ logging.warning("agent step observer failed: %s", exc)
615
+
351
616
  def boundary(self) -> Dict[str, Any]:
352
617
  return runtime_boundary_contract(
353
618
  name="SingleAgentRuntime",
@@ -412,6 +677,12 @@ class SingleAgentRuntime:
412
677
  "estimated_steps": plan.get("estimated_steps", 1),
413
678
  **({"plan_fixes": plan_fixes} if plan_fixes else {}),
414
679
  })
680
+ self._emit_step(
681
+ ctx, "plan", "planned",
682
+ goal=str(plan.get("goal") or "")[:200],
683
+ steps=len(plan.get("steps") or []),
684
+ requires_approval=bool(plan.get("requires_approval", False)),
685
+ )
415
686
  ctx.state = AgentState.WAITING_APPROVAL
416
687
 
417
688
  # ── APPROVAL ─────────────────────────────────────────────────────
@@ -467,6 +738,7 @@ class SingleAgentRuntime:
467
738
  })
468
739
  decision = "human_approved" if requires and approved_by_human else ("blocked_pending_approval" if requires else "auto_approved")
469
740
  ctx.trace.decision("approve", decision=decision, non_auto_steps=len(non_auto))
741
+ self._emit_step(ctx, "approval", "decision", decision=decision)
470
742
  d.audit(
471
743
  "agent_approval", user_email=current_user,
472
744
  requires_approval=requires,
@@ -530,6 +802,7 @@ class SingleAgentRuntime:
530
802
  "state": AgentState.EXECUTING.value, "action": "final", "thoughts": thoughts,
531
803
  })
532
804
  ctx.trace.decision("execute", decision="final")
805
+ self._emit_step(ctx, "execute", "final")
533
806
  ctx.state = AgentState.VERIFYING
534
807
  return
535
808
 
@@ -540,6 +813,7 @@ class SingleAgentRuntime:
540
813
  "error": "LOOP_DETECTED: identical action+args repeated — halted.",
541
814
  })
542
815
  ctx.trace.decision("execute", decision="loop_detected", tool=name)
816
+ self._emit_step(ctx, "execute", "blocked", action=name, reason="loop_detected")
543
817
  break
544
818
 
545
819
  if name == "clear_history":
@@ -548,6 +822,7 @@ class SingleAgentRuntime:
548
822
  "state": AgentState.EXECUTING.value, "action": name,
549
823
  "thoughts": thoughts, "args": args, "result": result,
550
824
  })
825
+ self._emit_step(ctx, "execute", "tool", action=name, ok=True)
551
826
  continue
552
827
 
553
828
  policy = d.policy_for(name, args)
@@ -576,10 +851,13 @@ class SingleAgentRuntime:
576
851
  ) -> str:
577
852
  """Assemble one executor turn's prompt (plan, corrections, recent chat)."""
578
853
  d = self.deps
854
+ # Only the latest corrections steer the next attempt — stale hints
855
+ # from earlier retries dilute weak models (review Wave 0.3).
856
+ active_corrections = ctx.corrections[-3:]
579
857
  corrections_hint = (
580
858
  "\n\nCritic corrections from previous attempt:\n"
581
- + "\n".join(f"- {c}" for c in ctx.corrections)
582
- ) if ctx.corrections else ""
859
+ + "\n".join(f"- {c}" for c in active_corrections)
860
+ ) if active_corrections else ""
583
861
 
584
862
  recent_kwargs = {
585
863
  "conversation_id": req.conversation_id,
@@ -588,14 +866,27 @@ class SingleAgentRuntime:
588
866
  if request_workspace is not None:
589
867
  recent_kwargs["workspace_id"] = request_workspace
590
868
  recent_conversation = d.recent_chat_context(**recent_kwargs) or "(none)"
869
+ budget = self.transcript_budget
870
+ bounded_transcript = compact_transcript(
871
+ ctx.transcript,
872
+ window=budget.window,
873
+ result_chars=budget.result_chars,
874
+ )
875
+ # Mid-run workspace awareness (review L5): later steps must see what
876
+ # this run already produced instead of a stale workspace picture.
877
+ written = files_written(ctx.transcript, d.file_create_actions)
878
+ written_hint = (
879
+ "\n\nFiles written by this run so far (they exist in the workspace now):\n"
880
+ + "\n".join(f"- {path}" for path in written)
881
+ ) if written else ""
591
882
  return (
592
883
  f"{d.executor_prompt}\n\n"
593
884
  f"[LANGUAGE HINT: {lang_hint}]\n"
594
885
  f"Workspace root: {d.agent_root}\n\n"
595
- f"PLAN:\n{json.dumps(ctx.plan, ensure_ascii=False)}\n\n"
886
+ f"PLAN:\n{json.dumps(ctx.plan, ensure_ascii=False)}{written_hint}\n\n"
596
887
  f"Recent conversation:\n{recent_conversation}\n\n"
597
888
  f"User request: {req.message}{corrections_hint}\n\n"
598
- f"Execution transcript:\n{json.dumps(ctx.transcript, ensure_ascii=False, indent=2)}"
889
+ f"Execution transcript:\n{json.dumps(bounded_transcript, ensure_ascii=False, indent=2)}"
599
890
  )
600
891
 
601
892
  def _note_parse_failure(
@@ -608,8 +899,10 @@ class SingleAgentRuntime:
608
899
  })
609
900
  if parse_failures >= 3:
610
901
  ctx.trace.parse_error("execute", error=str(exc), recovered=False)
902
+ self._emit_step(ctx, "execute", "parse_error", recovered=False)
611
903
  return True
612
904
  ctx.trace.parse_error("execute", error=str(exc), recovered=True)
905
+ self._emit_step(ctx, "execute", "parse_error", recovered=True)
613
906
  # Weak models often need one concrete reminder of the wire
614
907
  # format; feed it through the corrections channel and retry
615
908
  # instead of aborting the whole run on the first slip.
@@ -665,6 +958,7 @@ class SingleAgentRuntime:
665
958
  if verdict is not None and verdict.get("decision") == "proposed":
666
959
  proposal = verdict.get("proposal") or {}
667
960
  ctx.trace.tool("execute", name=name, outcome="proposed", risk=risk)
961
+ self._emit_step(ctx, "execute", "proposed", action=name)
668
962
  ctx.transcript.append({
669
963
  "state": AgentState.EXECUTING.value, "action": name,
670
964
  "thoughts": thoughts, "args": {k: v for k, v in args.items() if k != "content"},
@@ -691,6 +985,7 @@ class SingleAgentRuntime:
691
985
  d = self.deps
692
986
  if policy["risk"] == "destructive":
693
987
  ctx.trace.tool("execute", name=name, outcome="blocked_destructive", risk=risk)
988
+ self._emit_step(ctx, "execute", "blocked", action=name, reason="destructive")
694
989
  ctx.transcript.append({
695
990
  "state": AgentState.EXECUTING.value, "action": name,
696
991
  "thoughts": thoughts, "args": args, "risk": risk,
@@ -713,6 +1008,7 @@ class SingleAgentRuntime:
713
1008
  args={k: v for k, v in args.items() if k != "content"},
714
1009
  )
715
1010
  ctx.trace.tool("execute", name=name, outcome="blocked_approval", risk=risk)
1011
+ self._emit_step(ctx, "execute", "blocked", action=name, reason="approval")
716
1012
  ctx.transcript.append({
717
1013
  "state": AgentState.EXECUTING.value, "action": name,
718
1014
  "thoughts": thoughts, "args": args, "risk": risk,
@@ -748,6 +1044,27 @@ class SingleAgentRuntime:
748
1044
  "artifact_repair" if meta.get("repaired") else "artifact_sanitize"
749
1045
  ],
750
1046
  )
1047
+ step_index = 1 + sum(
1048
+ 1 for s in ctx.transcript
1049
+ if s.get("state") == AgentState.EXECUTING.value
1050
+ and s.get("action") not in (None, "final", "parse_error")
1051
+ )
1052
+ if (
1053
+ name in d.file_create_actions
1054
+ and d.snapshot_file is not None
1055
+ and args.get("path")
1056
+ ):
1057
+ # Pre-write snapshot (review L7): the first capture per path is
1058
+ # the true pre-run state — later writes to the same path must
1059
+ # not overwrite it. Best-effort: a snapshot failure never
1060
+ # blocks the write, it only narrows rollback options.
1061
+ path_str = str(args["path"])
1062
+ if not any(entry.get("path") == path_str for entry in ctx.rollback_log):
1063
+ try:
1064
+ pre = d.snapshot_file(path_str)
1065
+ ctx.rollback_log.append({"path": path_str, **(pre or {})})
1066
+ except Exception as exc: # noqa: BLE001
1067
+ logging.warning("pre-write snapshot failed for %s: %s", path_str, exc)
751
1068
  try:
752
1069
  d.check_role(name, current_user)
753
1070
  # Shared tool lifecycle: pre_tool (may block) → execute → post_tool.
@@ -763,6 +1080,10 @@ class SingleAgentRuntime:
763
1080
  "risk": risk, "governance": dict(policy), "result": result,
764
1081
  **({"content_sanitize": sanitize_meta} if sanitize_meta else {}),
765
1082
  })
1083
+ self._emit_step(
1084
+ ctx, "execute", "tool", action=name, ok=True, step=step_index,
1085
+ path=str(args.get("path")) if args.get("path") else None,
1086
+ )
766
1087
  except (ToolError, KeyError, TypeError, PermissionError) as exc:
767
1088
  ctx.trace.tool("execute", name=name, outcome="error", risk=risk)
768
1089
  ctx.transcript.append({
@@ -770,6 +1091,10 @@ class SingleAgentRuntime:
770
1091
  "thoughts": thoughts, "args": args,
771
1092
  "risk": risk, "governance": dict(policy), "error": str(exc),
772
1093
  })
1094
+ self._emit_step(
1095
+ ctx, "execute", "tool", action=name, ok=False, step=step_index,
1096
+ path=str(args.get("path")) if args.get("path") else None,
1097
+ )
773
1098
 
774
1099
  # ── VERIFY ───────────────────────────────────────────────────────
775
1100
  def _has_execution_evidence(self, ctx: AgentRunContext) -> bool:
@@ -798,12 +1123,24 @@ class SingleAgentRuntime:
798
1123
  NEEDS_REVIEW so the user is told to check the result themselves.
799
1124
  """
800
1125
  d = self.deps
1126
+ # The critic must see every step (evidence completeness), but not
1127
+ # every byte of tool output — long bodies are capped per string so
1128
+ # verification stays affordable on long runs (review Wave 0.3).
1129
+ verify_transcript = _truncate_strings(
1130
+ ctx.transcript, self.transcript_budget.verify_chars
1131
+ )
1132
+ # Deterministic artifact facts (review L4): the critic sees the
1133
+ # sanitize/repair honesty flags per written file, not just prose.
1134
+ checklist = artifact_checklist(ctx.transcript, d.file_create_actions)
1135
+ checklist_hint = (
1136
+ f"\n\n{_format_artifact_checklist(checklist)}" if checklist else ""
1137
+ )
801
1138
  context = (
802
1139
  f"{d.critic_prompt}\n\n"
803
1140
  f"[LANGUAGE HINT: {lang_hint}]\n\n"
804
1141
  f"Original request: {req.message}\n"
805
- f"Plan goal: {ctx.plan.get('goal', req.message)}\n\n"
806
- f"Full transcript:\n{json.dumps(ctx.transcript, ensure_ascii=False, indent=2)}"
1142
+ f"Plan goal: {ctx.plan.get('goal', req.message)}{checklist_hint}\n\n"
1143
+ f"Full transcript:\n{json.dumps(verify_transcript, ensure_ascii=False, indent=2)}"
807
1144
  )
808
1145
  raw = await d.generate_as(
809
1146
  model_id,
@@ -857,6 +1194,7 @@ class SingleAgentRuntime:
857
1194
  "verify", decision="verification_unavailable",
858
1195
  verifier_available=False, verdict_valid=False, evidence=has_evidence,
859
1196
  )
1197
+ self._emit_step(ctx, "verify", "verdict", verdict="UNAVAILABLE")
860
1198
  ctx.final_message = (
861
1199
  "검증을 완료하지 못했습니다 — 검증 모델의 응답을 해석할 수 없었습니다. "
862
1200
  "실행 결과를 직접 확인해 주시고, 필요하면 다시 시도해 주세요."
@@ -885,6 +1223,10 @@ class SingleAgentRuntime:
885
1223
  "verify", decision=str(verdict.get("verdict", "")), next_state=next_s,
886
1224
  verifier_available=True, verdict_valid=True, evidence=has_evidence,
887
1225
  )
1226
+ self._emit_step(
1227
+ ctx, "verify", "verdict",
1228
+ verdict=str(verdict.get("verdict", "")), next_state=next_s,
1229
+ )
888
1230
  if verdict.get("verdict") == "PASS":
889
1231
  # DONE requires both: a validly parsed PASS verdict AND
890
1232
  # deterministic execution evidence in the transcript. A PASS over
@@ -930,52 +1272,97 @@ class SingleAgentRuntime:
930
1272
  ctx.state = AgentState.FAILED
931
1273
 
932
1274
  # ── ROLLBACK ─────────────────────────────────────────────────────
1275
+ def _snapshot_for(self, ctx: AgentRunContext, path: str) -> Optional[Dict[str, Any]]:
1276
+ for entry in ctx.rollback_log:
1277
+ if entry.get("path") == path:
1278
+ return entry
1279
+ return None
1280
+
1281
+ def _rollback_one(self, ctx: AgentRunContext, path: str, gov: Dict[str, Any]) -> Dict[str, Any]:
1282
+ """Recover one path: git when governed and available, else the
1283
+ pre-write snapshot, else an honest ``mode="none"`` (review L7)."""
1284
+ d = self.deps
1285
+ if gov.get("rollback") == "git" and d.rollback_file is not None:
1286
+ try:
1287
+ result = dict(d.rollback_file(str(path)))
1288
+ except Exception as exc: # noqa: BLE001
1289
+ result = {"path": path, "ok": False, "error": str(exc)}
1290
+ if result.get("ok"):
1291
+ result["mode"] = "git"
1292
+ return result
1293
+ snapshot = self._snapshot_for(ctx, str(path))
1294
+ if snapshot is not None and d.restore_snapshot is not None and not snapshot.get("too_large"):
1295
+ content = snapshot.get("content") if snapshot.get("existed") else None
1296
+ try:
1297
+ restored = dict(d.restore_snapshot(str(path), content))
1298
+ except Exception as exc: # noqa: BLE001
1299
+ restored = {"path": path, "ok": False, "error": str(exc)}
1300
+ restored.setdefault("path", path)
1301
+ restored["mode"] = "snapshot"
1302
+ return restored
1303
+ return {
1304
+ "path": path, "ok": False, "mode": "none",
1305
+ "error": "no rollback available (git not applicable, no usable snapshot)",
1306
+ }
1307
+
933
1308
  def rollback(self, ctx: AgentRunContext, current_user: str) -> None:
934
- """ROLLBACK: attempt git checkout for each edited file, then FAILED."""
1309
+ """ROLLBACK: recover written files (git snapshot none), then FAILED."""
935
1310
  d = self.deps
936
1311
  rolled: List[dict] = []
1312
+ seen_paths: set = set()
937
1313
  for step in ctx.transcript:
938
1314
  if step.get("state") != AgentState.EXECUTING.value:
939
1315
  continue
940
- gov = step.get("governance", {})
941
- if gov.get("rollback") != "git":
1316
+ if not isinstance(step.get("result"), dict):
942
1317
  continue
943
- result = step.get("result", {})
944
- if not isinstance(result, dict):
945
- result = {}
946
- path = result.get("path") or (step.get("args") or {}).get("path", "")
947
- if not path:
1318
+ gov = step.get("governance", {}) or {}
1319
+ path = step["result"].get("path") or (step.get("args") or {}).get("path", "")
1320
+ if not path or str(path) in seen_paths:
948
1321
  continue
949
- if d.rollback_file is None:
950
- rolled.append({"path": path, "ok": False, "error": "rollback_file port is not configured"})
1322
+ if gov.get("rollback") != "git" and step.get("action") not in d.file_create_actions:
951
1323
  continue
952
- try:
953
- rolled.append(d.rollback_file(str(path)))
954
- except Exception as exc:
955
- rolled.append({"path": path, "ok": False, "error": str(exc)})
1324
+ seen_paths.add(str(path))
1325
+ rolled.append(self._rollback_one(ctx, str(path), gov))
956
1326
 
957
1327
  ctx.transcript.append({"state": AgentState.ROLLBACK.value, "rolled_back": rolled})
958
1328
  ctx.trace.decision(
959
1329
  "rollback", decision="rolled_back",
960
1330
  attempted=len(rolled), recovered=sum(1 for r in rolled if r.get("ok")),
961
1331
  )
962
- recovered = [r["path"] for r in rolled if r.get("ok")]
1332
+ recovered = [f"{r['path']} ({r.get('mode')})" for r in rolled if r.get("ok")]
963
1333
  ctx.final_message = (
964
1334
  f"실행 실패로 롤백했습니다. 복구 파일: {recovered}"
965
1335
  if recovered
966
- else "롤백을 시도했으나 복구할 파일이 없거나 git 초기화되지 않았습니다."
1336
+ else "롤백을 시도했으나 복구할 파일이 없거나 git/스냅샷 복구 수단이 없습니다."
967
1337
  )
968
1338
  d.audit("agent_rollback", user_email=current_user, rolled_back=rolled)
1339
+ self._emit_step(ctx, "rollback", "rolled_back", recovered=len(recovered))
969
1340
  # Rollback is a recovery from a failed verification — terminal state is FAILED
970
1341
  ctx.state = AgentState.FAILED
971
1342
 
972
1343
  # ── MEMORY ───────────────────────────────────────────────────────
973
1344
  async def memory_update(self, ctx: AgentRunContext, req: Any, current_user: str) -> None:
974
- """Background: Memory Updater role extracts learnings after DONE."""
1345
+ """Background: Memory Updater role extracts learnings from a terminal run.
1346
+
1347
+ Terminal-state learning policy (review §4.2 L6): DONE runs record what
1348
+ worked; FAILED / NEEDS_REVIEW runs record what went wrong — failure is
1349
+ exactly the experience worth remembering. The run status stored with
1350
+ the experience is the *actual* terminal state, never a blanket "ok".
1351
+ """
975
1352
  d = self.deps
1353
+ terminal = ctx.state.value if ctx.state in AGENT_TERMINAL_STATES else "UNKNOWN"
1354
+ outcome_hint = (
1355
+ "The task completed successfully."
1356
+ if ctx.state == AgentState.DONE
1357
+ else (
1358
+ f"The task ended as {terminal} — extract what went wrong and "
1359
+ "what to do differently next time, not a success story."
1360
+ )
1361
+ )
976
1362
  context = (
977
1363
  f"{d.memory_updater_prompt}\n\n"
978
- f"Completed task: {req.message}\n\n"
1364
+ f"Task: {req.message}\n"
1365
+ f"Terminal status: {terminal}. {outcome_hint}\n\n"
979
1366
  f"Last 5 transcript steps:\n{json.dumps(ctx.transcript[-5:], ensure_ascii=False)}"
980
1367
  )
981
1368
  try:
@@ -987,6 +1374,11 @@ class SingleAgentRuntime:
987
1374
  kept_learnings = filter_learnings(mem.get("learnings") or [])
988
1375
  if mem.get("save_to_knowledge") and kept_learnings:
989
1376
  learnings = "\n".join(kept_learnings)
1377
+ status_label = {
1378
+ AgentState.DONE: "ok",
1379
+ AgentState.NEEDS_REVIEW: "needs_review",
1380
+ AgentState.FAILED: "failed",
1381
+ }.get(ctx.state, "unknown")
990
1382
  if d.brain_memory is not None:
991
1383
  # This runtime is LLM-driven — its learnings are real
992
1384
  # experiences and enter the brain with provenance.
@@ -995,7 +1387,7 @@ class SingleAgentRuntime:
995
1387
  learnings,
996
1388
  run={
997
1389
  "mode": "llm",
998
- "status": "ok",
1390
+ "status": status_label,
999
1391
  "agent_id": "agent:executor",
1000
1392
  "steps": len(ctx.transcript),
1001
1393
  },
@@ -1036,3 +1428,4 @@ class SingleAgentRuntime:
1036
1428
  ctx.state = AgentState.FAILED
1037
1429
 
1038
1430
  ctx.state_history.append(ctx.state.value)
1431
+ self._emit_step(ctx, "terminal", "state", state=ctx.state.value)