ltcai 9.9.2 → 9.9.4

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 (109) hide show
  1. package/README.md +44 -39
  2. package/docs/CHANGELOG.md +109 -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 +47 -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/curator.py +141 -0
  14. package/lattice_brain/graph/discovery_index.py +8 -1
  15. package/lattice_brain/graph/documents.py +111 -0
  16. package/lattice_brain/graph/fusion.py +179 -0
  17. package/lattice_brain/graph/ingest.py +43 -9
  18. package/lattice_brain/graph/projection.py +382 -37
  19. package/lattice_brain/graph/retrieval.py +71 -4
  20. package/lattice_brain/graph/retrieval_policy.py +174 -0
  21. package/lattice_brain/graph/retrieval_vector.py +122 -2
  22. package/lattice_brain/ingestion.py +66 -0
  23. package/lattice_brain/portability.py +41 -12
  24. package/lattice_brain/runtime/multi_agent.py +1 -1
  25. package/latticeai/__init__.py +1 -1
  26. package/latticeai/api/automation_intelligence.py +159 -0
  27. package/latticeai/api/browser.py +15 -4
  28. package/latticeai/api/chat.py +33 -2
  29. package/latticeai/api/chat_agent_http.py +363 -6
  30. package/latticeai/api/chat_contracts.py +7 -1
  31. package/latticeai/api/chat_helpers.py +147 -0
  32. package/latticeai/api/chat_intents.py +217 -10
  33. package/latticeai/api/chat_stream.py +93 -3
  34. package/latticeai/api/funnel_metrics.py +31 -0
  35. package/latticeai/api/knowledge_graph.py +65 -1
  36. package/latticeai/api/local_files.py +90 -0
  37. package/latticeai/api/setup.py +134 -1
  38. package/latticeai/api/tools.py +33 -3
  39. package/latticeai/app_factory.py +14 -0
  40. package/latticeai/core/agent.py +355 -18
  41. package/latticeai/core/agent_eval.py +107 -0
  42. package/latticeai/core/enterprise.py +5 -0
  43. package/latticeai/core/file_generation.py +404 -2
  44. package/latticeai/core/legacy_compatibility.py +1 -1
  45. package/latticeai/core/marketplace.py +1 -1
  46. package/latticeai/core/run_store.py +243 -0
  47. package/latticeai/core/workspace_os.py +1 -1
  48. package/latticeai/models/router.py +25 -12
  49. package/latticeai/runtime/persistence_runtime.py +19 -1
  50. package/latticeai/runtime/router_registration.py +8 -1
  51. package/latticeai/services/app_context.py +3 -0
  52. package/latticeai/services/architecture_readiness.py +1 -1
  53. package/latticeai/services/automation_execution.py +266 -0
  54. package/latticeai/services/automation_intelligence.py +7 -0
  55. package/latticeai/services/command_center.py +110 -2
  56. package/latticeai/services/folder_watch.py +396 -0
  57. package/latticeai/services/funnel_metrics.py +210 -0
  58. package/latticeai/services/product_readiness.py +1 -1
  59. package/latticeai/services/search_service.py +51 -5
  60. package/latticeai/setup/demo_corpus.py +98 -0
  61. package/latticeai/tools/filesystem.py +38 -1
  62. package/package.json +1 -1
  63. package/scripts/bench_agent_smoke.py +410 -0
  64. package/scripts/bench_models.py +234 -5
  65. package/scripts/check_current_release_docs.mjs +1 -1
  66. package/scripts/funnel_soft_gate.py +192 -0
  67. package/src-tauri/Cargo.lock +1 -1
  68. package/src-tauri/Cargo.toml +1 -1
  69. package/src-tauri/tauri.conf.json +1 -1
  70. package/static/app/asset-manifest.json +29 -27
  71. package/static/app/assets/Act-BCmTU0E2.js +1 -0
  72. package/static/app/assets/{Brain-BnGbytHa.js → Brain-CTnjox7w.js} +2 -2
  73. package/static/app/assets/BrainHome-BJ3sFNX0.js +2 -0
  74. package/static/app/assets/BrainSignals-C52lwZVD.js +1 -0
  75. package/static/app/assets/{Capture-CAKnLcb0.js → Capture-B6vBhFa3.js} +1 -1
  76. package/static/app/assets/CommandPalette-90u9FWFH.js +1 -0
  77. package/static/app/assets/Library-pKCK0_tk.js +1 -0
  78. package/static/app/assets/{LivingBrain-C3VbogVo.js → LivingBrain-Jlf2wFqI.js} +1 -1
  79. package/static/app/assets/{ProductFlow-BLau7c7N.js → ProductFlow-yg1fKP1P.js} +1 -1
  80. package/static/app/assets/ReviewCard-DWvD7n9h.js +3 -0
  81. package/static/app/assets/{System-DRHc_6Su.js → System-BAEuHqNY.js} +1 -1
  82. package/static/app/assets/{bot-DZkDdh-A.js → bot-nB_buEZD.js} +1 -1
  83. package/static/app/assets/circle-pause-DLNw6Ucp.js +1 -0
  84. package/static/app/assets/circle-play-B-IsFL1y.js +1 -0
  85. package/static/app/assets/{cpu-DyvsDBbj.js → cpu-CEPBHaBl.js} +1 -1
  86. package/static/app/assets/{folder-open-CffoIr9t.js → folder-open-hmN0N9cX.js} +1 -1
  87. package/static/app/assets/{hard-drive-C1oojrHb.js → hard-drive-CBV_B_Yd.js} +1 -1
  88. package/static/app/assets/index-7FAfYm4v.css +2 -0
  89. package/static/app/assets/{index-BS_3aN1n.js → index-DrmOCySv.js} +3 -3
  90. package/static/app/assets/{input-B_U7zvbR.js → input-gtVCg-ll.js} +1 -1
  91. package/static/app/assets/{navigation-xG-gyjbe.js → navigation-Bot0hvuv.js} +1 -1
  92. package/static/app/assets/{network-BF13HJJF.js → network-jE42eKfT.js} +1 -1
  93. package/static/app/assets/{primitives-DsuKh3M8.js → primitives-CX2Komon.js} +1 -1
  94. package/static/app/assets/shield-alert-BftATuAA.js +1 -0
  95. package/static/app/assets/{textarea-DsDL4ET8.js → textarea-CiMJfOSI.js} +1 -1
  96. package/static/app/assets/useFocusTrap-B7RPGfFy.js +1 -0
  97. package/static/app/assets/utils-SJUNVOj5.js +7 -0
  98. package/static/app/index.html +4 -4
  99. package/static/sw.js +1 -1
  100. package/static/app/assets/Act-Cx_gZNbE.js +0 -1
  101. package/static/app/assets/BrainHome-CRUMJL93.js +0 -2
  102. package/static/app/assets/BrainSignals-BP8-Qh2g.js +0 -1
  103. package/static/app/assets/CommandPalette-BoUTXBfm.js +0 -1
  104. package/static/app/assets/Library-PcfVUco6.js +0 -1
  105. package/static/app/assets/ReviewCard-CX5RqRqx.js +0 -1
  106. package/static/app/assets/download-C7yxYHvz.js +0 -1
  107. package/static/app/assets/index-DU78Kogz.css +0 -2
  108. package/static/app/assets/sunrise-CIP2ELJ1.js +0 -1
  109. package/static/app/assets/utils-Dh-Dwtkb.js +0 -7
@@ -27,12 +27,16 @@ import re
27
27
  from dataclasses import dataclass
28
28
  from enum import Enum
29
29
  from pathlib import Path
30
- from typing import Any, Awaitable, Callable, Dict, FrozenSet, List, Optional, Tuple
30
+ from typing import Any, Awaitable, Callable, Dict, FrozenSet, List, Mapping, Optional, Tuple
31
31
 
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,135 @@ 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
+ @dataclass(frozen=True)
344
+ class TranscriptBudget:
345
+ """Executor/critic prompt shaping caps (review Wave 0.3).
346
+
347
+ ``window`` full recent steps for the executor; per-string caps keep tool
348
+ output bodies from dominating either prompt. Overridable through the same
349
+ ``Config.from_env`` pattern as :class:`PhaseBudgets`.
350
+ """
351
+
352
+ window: int = 8
353
+ result_chars: int = 700
354
+ verify_chars: int = 1200
355
+
356
+ @classmethod
357
+ def from_env(cls, env: Optional[Mapping[str, str]] = None) -> "TranscriptBudget":
358
+ from latticeai.core.config import _int
359
+
360
+ if env is None:
361
+ import os
362
+
363
+ env = os.environ
364
+
365
+ def cap(key: str, default: int, floor: int) -> int:
366
+ return max(floor, _int(env, key, default))
367
+
368
+ return cls(
369
+ window=cap("LATTICEAI_AGENT_TRANSCRIPT_WINDOW", cls.window, 2),
370
+ result_chars=cap("LATTICEAI_AGENT_TRANSCRIPT_CHARS", cls.result_chars, 120),
371
+ verify_chars=cap("LATTICEAI_AGENT_VERIFY_CHARS", cls.verify_chars, 200),
372
+ )
373
+
374
+
375
+ @dataclass(frozen=True)
376
+ class PhaseBudgets:
377
+ """Per-phase token budgets for the agent loop.
378
+
379
+ One shared budget let a weak model burn everything on planning prose and
380
+ reach EXECUTE with nothing left. Each role phase now has its own cap, so
381
+ a verbose planner can never starve execution or verification. Defaults
382
+ match the historical hardcoded values; every cap is overridable through
383
+ the ``Config.from_env`` environment pattern.
384
+ """
385
+
386
+ plan_tokens: int = 1024
387
+ execute_tokens: int = 4096
388
+ verify_tokens: int = 512
389
+ memory_tokens: int = 256
390
+
391
+ @classmethod
392
+ def from_env(cls, env: Optional[Mapping[str, str]] = None) -> "PhaseBudgets":
393
+ from latticeai.core.config import _int
394
+
395
+ if env is None:
396
+ import os
397
+
398
+ env = os.environ
399
+
400
+ def cap(key: str, default: int) -> int:
401
+ # A misconfigured/absurd value must not brick the loop: floor at a
402
+ # budget that still fits one JSON action object.
403
+ return max(128, _int(env, key, default))
404
+
405
+ return cls(
406
+ plan_tokens=cap("LATTICEAI_AGENT_PLAN_TOKENS", cls.plan_tokens),
407
+ execute_tokens=cap("LATTICEAI_AGENT_EXECUTE_TOKENS", cls.execute_tokens),
408
+ verify_tokens=cap("LATTICEAI_AGENT_VERIFY_TOKENS", cls.verify_tokens),
409
+ memory_tokens=cap("LATTICEAI_AGENT_MEMORY_TOKENS", cls.memory_tokens),
410
+ )
411
+
412
+
230
413
  @dataclass
231
414
  class AgentDeps:
232
415
  """The ports a :class:`SingleAgentRuntime` needs from the outside world.
@@ -286,12 +469,68 @@ class AgentDeps:
286
469
  # returning None (fall through to the classic gates) or a verdict dict.
287
470
  change_governor: Any = None
288
471
 
472
+ # ── phase budgets (optional) ─────────────────────────────────────
473
+ # Per-phase token caps (plan/execute/verify/memory). None reads the
474
+ # environment once at first use; tests inject a fixed PhaseBudgets.
475
+ phase_budgets: Optional[PhaseBudgets] = None
476
+
477
+ # ── transcript shaping (optional) ────────────────────────────────
478
+ # Executor/critic prompt window caps. None reads the environment once;
479
+ # tests inject a fixed TranscriptBudget.
480
+ transcript_budget: Optional["TranscriptBudget"] = None
481
+
482
+ # ── step observer port (optional) ────────────────────────────────
483
+ # Default per-runtime observer for live step events; a per-run observer
484
+ # can also be attached on AgentRunContext.on_step. Both are advisory.
485
+ on_step: Optional[Callable[[Dict[str, Any]], None]] = None
486
+
289
487
 
290
488
  class SingleAgentRuntime:
291
489
  """Drives the agent state machine over injected :class:`AgentDeps`."""
292
490
 
293
491
  def __init__(self, deps: AgentDeps) -> None:
294
492
  self.deps = deps
493
+ self._env_phase_budgets: Optional[PhaseBudgets] = None
494
+ self._env_transcript_budget: Optional[TranscriptBudget] = None
495
+
496
+ @property
497
+ def phase_budgets(self) -> PhaseBudgets:
498
+ # getattr twice: partially-constructed runtimes/deps (tests build them
499
+ # via __new__ or minimal fakes) still get working default budgets.
500
+ injected = getattr(self.deps, "phase_budgets", None)
501
+ if injected is not None:
502
+ return injected
503
+ if getattr(self, "_env_phase_budgets", None) is None:
504
+ self._env_phase_budgets = PhaseBudgets.from_env()
505
+ return self._env_phase_budgets
506
+
507
+ @property
508
+ def transcript_budget(self) -> TranscriptBudget:
509
+ injected = getattr(self.deps, "transcript_budget", None)
510
+ if injected is not None:
511
+ return injected
512
+ if getattr(self, "_env_transcript_budget", None) is None:
513
+ self._env_transcript_budget = TranscriptBudget.from_env()
514
+ return self._env_transcript_budget
515
+
516
+ def _emit_step(self, ctx: AgentRunContext, phase: str, event: str, **details: Any) -> None:
517
+ """Fire the per-run / deps step observers (review Wave 1.1).
518
+
519
+ Observers power the live step timeline in the UI. They are pure
520
+ telemetry: any observer failure is logged and swallowed — the loop
521
+ itself must never notice.
522
+ """
523
+ payload: Dict[str, Any] = {"phase": phase, "event": event}
524
+ for key, value in details.items():
525
+ if value is not None:
526
+ payload[key] = value
527
+ for observer in (getattr(ctx, "on_step", None), getattr(self.deps, "on_step", None)):
528
+ if observer is None:
529
+ continue
530
+ try:
531
+ observer(dict(payload))
532
+ except Exception as exc: # noqa: BLE001 — observers are advisory
533
+ logging.warning("agent step observer failed: %s", exc)
295
534
 
296
535
  def boundary(self) -> Dict[str, Any]:
297
536
  return runtime_boundary_contract(
@@ -331,7 +570,7 @@ class SingleAgentRuntime:
331
570
  raw = await d.generate_as(
332
571
  model_id,
333
572
  message="Produce a JSON execution plan for this request.",
334
- context=context, max_tokens=1024, temperature=0.1,
573
+ context=context, max_tokens=self.phase_budgets.plan_tokens, temperature=0.1,
335
574
  )
336
575
  ctx.trace.llm_call("plan", model=model_id)
337
576
  try:
@@ -357,11 +596,23 @@ class SingleAgentRuntime:
357
596
  "estimated_steps": plan.get("estimated_steps", 1),
358
597
  **({"plan_fixes": plan_fixes} if plan_fixes else {}),
359
598
  })
599
+ self._emit_step(
600
+ ctx, "plan", "planned",
601
+ goal=str(plan.get("goal") or "")[:200],
602
+ steps=len(plan.get("steps") or []),
603
+ requires_approval=bool(plan.get("requires_approval", False)),
604
+ )
360
605
  ctx.state = AgentState.WAITING_APPROVAL
361
606
 
362
607
  # ── APPROVAL ─────────────────────────────────────────────────────
363
- def approve(self, ctx: AgentRunContext, current_user: str, *, approved_by_human: bool = False) -> None:
364
- """APPROVAL: Check governance, log decision, auto-approve (future: UI prompt)."""
608
+ def approval_requirements(self, ctx: AgentRunContext) -> Dict[str, Any]:
609
+ """Read-only preview of the approval gate for a planned run.
610
+
611
+ Shares the exact predicate :meth:`approve` enforces, so the HTTP
612
+ layer can pause a run as ``awaiting_approval`` (with a plan summary
613
+ for the user) instead of letting it fail closed — without ever
614
+ weakening the gate itself.
615
+ """
365
616
  d = self.deps
366
617
  auto_approve_tools = {name for name, p in d.tool_governance.items() if p["auto_approve"]}
367
618
  # Governor-managed tools never hard-block the plan: each call is
@@ -377,7 +628,26 @@ class SingleAgentRuntime:
377
628
  if s.get("action") not in auto_approve_tools
378
629
  and s.get("action") not in governed_tools
379
630
  ]
380
- requires = ctx.plan.get("requires_approval", False) or bool(non_auto)
631
+ requires = bool(ctx.plan.get("requires_approval", False)) or bool(non_auto)
632
+ lines = [
633
+ f"{index}. {step.get('description') or step.get('action') or '?'}"
634
+ for index, step in enumerate(steps, start=1)
635
+ ]
636
+ summary = str(ctx.plan.get("goal") or "").strip()
637
+ if lines:
638
+ summary = (summary + "\n" if summary else "") + "\n".join(lines)
639
+ return {
640
+ "requires_approval": requires,
641
+ "non_auto_steps": non_auto,
642
+ "plan_summary": summary,
643
+ }
644
+
645
+ def approve(self, ctx: AgentRunContext, current_user: str, *, approved_by_human: bool = False) -> None:
646
+ """APPROVAL: Check governance, log decision, auto-approve (future: UI prompt)."""
647
+ d = self.deps
648
+ requirements = self.approval_requirements(ctx)
649
+ non_auto = requirements["non_auto_steps"]
650
+ requires = requirements["requires_approval"]
381
651
 
382
652
  ctx.transcript.append({
383
653
  "state": AgentState.WAITING_APPROVAL.value,
@@ -387,6 +657,7 @@ class SingleAgentRuntime:
387
657
  })
388
658
  decision = "human_approved" if requires and approved_by_human else ("blocked_pending_approval" if requires else "auto_approved")
389
659
  ctx.trace.decision("approve", decision=decision, non_auto_steps=len(non_auto))
660
+ self._emit_step(ctx, "approval", "decision", decision=decision)
390
661
  d.audit(
391
662
  "agent_approval", user_email=current_user,
392
663
  requires_approval=requires,
@@ -420,7 +691,8 @@ class SingleAgentRuntime:
420
691
  raw = await d.generate_as(
421
692
  model_id,
422
693
  message="Execute the next step.",
423
- context=context, max_tokens=4096, temperature=req.temperature,
694
+ context=context, max_tokens=self.phase_budgets.execute_tokens,
695
+ temperature=req.temperature,
424
696
  )
425
697
  ctx.trace.llm_call("execute", model=model_id)
426
698
  try:
@@ -449,6 +721,7 @@ class SingleAgentRuntime:
449
721
  "state": AgentState.EXECUTING.value, "action": "final", "thoughts": thoughts,
450
722
  })
451
723
  ctx.trace.decision("execute", decision="final")
724
+ self._emit_step(ctx, "execute", "final")
452
725
  ctx.state = AgentState.VERIFYING
453
726
  return
454
727
 
@@ -459,6 +732,7 @@ class SingleAgentRuntime:
459
732
  "error": "LOOP_DETECTED: identical action+args repeated — halted.",
460
733
  })
461
734
  ctx.trace.decision("execute", decision="loop_detected", tool=name)
735
+ self._emit_step(ctx, "execute", "blocked", action=name, reason="loop_detected")
462
736
  break
463
737
 
464
738
  if name == "clear_history":
@@ -467,6 +741,7 @@ class SingleAgentRuntime:
467
741
  "state": AgentState.EXECUTING.value, "action": name,
468
742
  "thoughts": thoughts, "args": args, "result": result,
469
743
  })
744
+ self._emit_step(ctx, "execute", "tool", action=name, ok=True)
470
745
  continue
471
746
 
472
747
  policy = d.policy_for(name, args)
@@ -495,10 +770,13 @@ class SingleAgentRuntime:
495
770
  ) -> str:
496
771
  """Assemble one executor turn's prompt (plan, corrections, recent chat)."""
497
772
  d = self.deps
773
+ # Only the latest corrections steer the next attempt — stale hints
774
+ # from earlier retries dilute weak models (review Wave 0.3).
775
+ active_corrections = ctx.corrections[-3:]
498
776
  corrections_hint = (
499
777
  "\n\nCritic corrections from previous attempt:\n"
500
- + "\n".join(f"- {c}" for c in ctx.corrections)
501
- ) if ctx.corrections else ""
778
+ + "\n".join(f"- {c}" for c in active_corrections)
779
+ ) if active_corrections else ""
502
780
 
503
781
  recent_kwargs = {
504
782
  "conversation_id": req.conversation_id,
@@ -507,6 +785,12 @@ class SingleAgentRuntime:
507
785
  if request_workspace is not None:
508
786
  recent_kwargs["workspace_id"] = request_workspace
509
787
  recent_conversation = d.recent_chat_context(**recent_kwargs) or "(none)"
788
+ budget = self.transcript_budget
789
+ bounded_transcript = compact_transcript(
790
+ ctx.transcript,
791
+ window=budget.window,
792
+ result_chars=budget.result_chars,
793
+ )
510
794
  return (
511
795
  f"{d.executor_prompt}\n\n"
512
796
  f"[LANGUAGE HINT: {lang_hint}]\n"
@@ -514,7 +798,7 @@ class SingleAgentRuntime:
514
798
  f"PLAN:\n{json.dumps(ctx.plan, ensure_ascii=False)}\n\n"
515
799
  f"Recent conversation:\n{recent_conversation}\n\n"
516
800
  f"User request: {req.message}{corrections_hint}\n\n"
517
- f"Execution transcript:\n{json.dumps(ctx.transcript, ensure_ascii=False, indent=2)}"
801
+ f"Execution transcript:\n{json.dumps(bounded_transcript, ensure_ascii=False, indent=2)}"
518
802
  )
519
803
 
520
804
  def _note_parse_failure(
@@ -527,8 +811,10 @@ class SingleAgentRuntime:
527
811
  })
528
812
  if parse_failures >= 3:
529
813
  ctx.trace.parse_error("execute", error=str(exc), recovered=False)
814
+ self._emit_step(ctx, "execute", "parse_error", recovered=False)
530
815
  return True
531
816
  ctx.trace.parse_error("execute", error=str(exc), recovered=True)
817
+ self._emit_step(ctx, "execute", "parse_error", recovered=True)
532
818
  # Weak models often need one concrete reminder of the wire
533
819
  # format; feed it through the corrections channel and retry
534
820
  # instead of aborting the whole run on the first slip.
@@ -584,6 +870,7 @@ class SingleAgentRuntime:
584
870
  if verdict is not None and verdict.get("decision") == "proposed":
585
871
  proposal = verdict.get("proposal") or {}
586
872
  ctx.trace.tool("execute", name=name, outcome="proposed", risk=risk)
873
+ self._emit_step(ctx, "execute", "proposed", action=name)
587
874
  ctx.transcript.append({
588
875
  "state": AgentState.EXECUTING.value, "action": name,
589
876
  "thoughts": thoughts, "args": {k: v for k, v in args.items() if k != "content"},
@@ -610,6 +897,7 @@ class SingleAgentRuntime:
610
897
  d = self.deps
611
898
  if policy["risk"] == "destructive":
612
899
  ctx.trace.tool("execute", name=name, outcome="blocked_destructive", risk=risk)
900
+ self._emit_step(ctx, "execute", "blocked", action=name, reason="destructive")
613
901
  ctx.transcript.append({
614
902
  "state": AgentState.EXECUTING.value, "action": name,
615
903
  "thoughts": thoughts, "args": args, "risk": risk,
@@ -632,6 +920,7 @@ class SingleAgentRuntime:
632
920
  args={k: v for k, v in args.items() if k != "content"},
633
921
  )
634
922
  ctx.trace.tool("execute", name=name, outcome="blocked_approval", risk=risk)
923
+ self._emit_step(ctx, "execute", "blocked", action=name, reason="approval")
635
924
  ctx.transcript.append({
636
925
  "state": AgentState.EXECUTING.value, "action": name,
637
926
  "thoughts": thoughts, "args": args, "risk": risk,
@@ -667,6 +956,11 @@ class SingleAgentRuntime:
667
956
  "artifact_repair" if meta.get("repaired") else "artifact_sanitize"
668
957
  ],
669
958
  )
959
+ step_index = 1 + sum(
960
+ 1 for s in ctx.transcript
961
+ if s.get("state") == AgentState.EXECUTING.value
962
+ and s.get("action") not in (None, "final", "parse_error")
963
+ )
670
964
  try:
671
965
  d.check_role(name, current_user)
672
966
  # Shared tool lifecycle: pre_tool (may block) → execute → post_tool.
@@ -682,6 +976,10 @@ class SingleAgentRuntime:
682
976
  "risk": risk, "governance": dict(policy), "result": result,
683
977
  **({"content_sanitize": sanitize_meta} if sanitize_meta else {}),
684
978
  })
979
+ self._emit_step(
980
+ ctx, "execute", "tool", action=name, ok=True, step=step_index,
981
+ path=str(args.get("path")) if args.get("path") else None,
982
+ )
685
983
  except (ToolError, KeyError, TypeError, PermissionError) as exc:
686
984
  ctx.trace.tool("execute", name=name, outcome="error", risk=risk)
687
985
  ctx.transcript.append({
@@ -689,6 +987,10 @@ class SingleAgentRuntime:
689
987
  "thoughts": thoughts, "args": args,
690
988
  "risk": risk, "governance": dict(policy), "error": str(exc),
691
989
  })
990
+ self._emit_step(
991
+ ctx, "execute", "tool", action=name, ok=False, step=step_index,
992
+ path=str(args.get("path")) if args.get("path") else None,
993
+ )
692
994
 
693
995
  # ── VERIFY ───────────────────────────────────────────────────────
694
996
  def _has_execution_evidence(self, ctx: AgentRunContext) -> bool:
@@ -717,17 +1019,23 @@ class SingleAgentRuntime:
717
1019
  NEEDS_REVIEW so the user is told to check the result themselves.
718
1020
  """
719
1021
  d = self.deps
1022
+ # The critic must see every step (evidence completeness), but not
1023
+ # every byte of tool output — long bodies are capped per string so
1024
+ # verification stays affordable on long runs (review Wave 0.3).
1025
+ verify_transcript = _truncate_strings(
1026
+ ctx.transcript, self.transcript_budget.verify_chars
1027
+ )
720
1028
  context = (
721
1029
  f"{d.critic_prompt}\n\n"
722
1030
  f"[LANGUAGE HINT: {lang_hint}]\n\n"
723
1031
  f"Original request: {req.message}\n"
724
1032
  f"Plan goal: {ctx.plan.get('goal', req.message)}\n\n"
725
- f"Full transcript:\n{json.dumps(ctx.transcript, ensure_ascii=False, indent=2)}"
1033
+ f"Full transcript:\n{json.dumps(verify_transcript, ensure_ascii=False, indent=2)}"
726
1034
  )
727
1035
  raw = await d.generate_as(
728
1036
  model_id,
729
1037
  message="Review the execution transcript and return your verdict JSON.",
730
- context=context, max_tokens=512, temperature=0.1,
1038
+ context=context, max_tokens=self.phase_budgets.verify_tokens, temperature=0.1,
731
1039
  )
732
1040
  ctx.trace.llm_call("verify", model=model_id)
733
1041
  verdict: Optional[Dict[str, Any]] = None
@@ -749,7 +1057,8 @@ class SingleAgentRuntime:
749
1057
  raw = await d.generate_as(
750
1058
  model_id,
751
1059
  message="Return your verdict as one strict JSON object.",
752
- context=strict_context, max_tokens=512, temperature=0.0,
1060
+ context=strict_context, max_tokens=self.phase_budgets.verify_tokens,
1061
+ temperature=0.0,
753
1062
  )
754
1063
  ctx.trace.llm_call("verify", model=model_id)
755
1064
  try:
@@ -775,6 +1084,7 @@ class SingleAgentRuntime:
775
1084
  "verify", decision="verification_unavailable",
776
1085
  verifier_available=False, verdict_valid=False, evidence=has_evidence,
777
1086
  )
1087
+ self._emit_step(ctx, "verify", "verdict", verdict="UNAVAILABLE")
778
1088
  ctx.final_message = (
779
1089
  "검증을 완료하지 못했습니다 — 검증 모델의 응답을 해석할 수 없었습니다. "
780
1090
  "실행 결과를 직접 확인해 주시고, 필요하면 다시 시도해 주세요."
@@ -803,6 +1113,10 @@ class SingleAgentRuntime:
803
1113
  "verify", decision=str(verdict.get("verdict", "")), next_state=next_s,
804
1114
  verifier_available=True, verdict_valid=True, evidence=has_evidence,
805
1115
  )
1116
+ self._emit_step(
1117
+ ctx, "verify", "verdict",
1118
+ verdict=str(verdict.get("verdict", "")), next_state=next_s,
1119
+ )
806
1120
  if verdict.get("verdict") == "PASS":
807
1121
  # DONE requires both: a validly parsed PASS verdict AND
808
1122
  # deterministic execution evidence in the transcript. A PASS over
@@ -884,27 +1198,49 @@ class SingleAgentRuntime:
884
1198
  else "롤백을 시도했으나 복구할 파일이 없거나 git이 초기화되지 않았습니다."
885
1199
  )
886
1200
  d.audit("agent_rollback", user_email=current_user, rolled_back=rolled)
1201
+ self._emit_step(ctx, "rollback", "rolled_back", recovered=len(recovered))
887
1202
  # Rollback is a recovery from a failed verification — terminal state is FAILED
888
1203
  ctx.state = AgentState.FAILED
889
1204
 
890
1205
  # ── MEMORY ───────────────────────────────────────────────────────
891
1206
  async def memory_update(self, ctx: AgentRunContext, req: Any, current_user: str) -> None:
892
- """Background: Memory Updater role extracts learnings after DONE."""
1207
+ """Background: Memory Updater role extracts learnings from a terminal run.
1208
+
1209
+ Terminal-state learning policy (review §4.2 L6): DONE runs record what
1210
+ worked; FAILED / NEEDS_REVIEW runs record what went wrong — failure is
1211
+ exactly the experience worth remembering. The run status stored with
1212
+ the experience is the *actual* terminal state, never a blanket "ok".
1213
+ """
893
1214
  d = self.deps
1215
+ terminal = ctx.state.value if ctx.state in AGENT_TERMINAL_STATES else "UNKNOWN"
1216
+ outcome_hint = (
1217
+ "The task completed successfully."
1218
+ if ctx.state == AgentState.DONE
1219
+ else (
1220
+ f"The task ended as {terminal} — extract what went wrong and "
1221
+ "what to do differently next time, not a success story."
1222
+ )
1223
+ )
894
1224
  context = (
895
1225
  f"{d.memory_updater_prompt}\n\n"
896
- f"Completed task: {req.message}\n\n"
1226
+ f"Task: {req.message}\n"
1227
+ f"Terminal status: {terminal}. {outcome_hint}\n\n"
897
1228
  f"Last 5 transcript steps:\n{json.dumps(ctx.transcript[-5:], ensure_ascii=False)}"
898
1229
  )
899
1230
  try:
900
1231
  raw = await d.generate(
901
1232
  message="Extract learnings from this completed task.",
902
- context=context, max_tokens=256, temperature=0.1,
1233
+ context=context, max_tokens=self.phase_budgets.memory_tokens, temperature=0.1,
903
1234
  )
904
1235
  mem = extract_action(str(raw))
905
1236
  kept_learnings = filter_learnings(mem.get("learnings") or [])
906
1237
  if mem.get("save_to_knowledge") and kept_learnings:
907
1238
  learnings = "\n".join(kept_learnings)
1239
+ status_label = {
1240
+ AgentState.DONE: "ok",
1241
+ AgentState.NEEDS_REVIEW: "needs_review",
1242
+ AgentState.FAILED: "failed",
1243
+ }.get(ctx.state, "unknown")
908
1244
  if d.brain_memory is not None:
909
1245
  # This runtime is LLM-driven — its learnings are real
910
1246
  # experiences and enter the brain with provenance.
@@ -913,7 +1249,7 @@ class SingleAgentRuntime:
913
1249
  learnings,
914
1250
  run={
915
1251
  "mode": "llm",
916
- "status": "ok",
1252
+ "status": status_label,
917
1253
  "agent_id": "agent:executor",
918
1254
  "steps": len(ctx.transcript),
919
1255
  },
@@ -954,3 +1290,4 @@ class SingleAgentRuntime:
954
1290
  ctx.state = AgentState.FAILED
955
1291
 
956
1292
  ctx.state_history.append(ctx.state.value)
1293
+ self._emit_step(ctx, "terminal", "state", state=ctx.state.value)