ltcai 9.9.3 → 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 (81) hide show
  1. package/README.md +43 -38
  2. package/docs/CHANGELOG.md +65 -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/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/retrieval.py +61 -12
  17. package/lattice_brain/graph/retrieval_policy.py +174 -0
  18. package/lattice_brain/graph/retrieval_vector.py +122 -2
  19. package/lattice_brain/portability.py +41 -12
  20. package/lattice_brain/runtime/multi_agent.py +1 -1
  21. package/latticeai/__init__.py +1 -1
  22. package/latticeai/api/chat.py +6 -0
  23. package/latticeai/api/chat_agent_http.py +175 -6
  24. package/latticeai/api/chat_intents.py +37 -18
  25. package/latticeai/api/chat_stream.py +76 -2
  26. package/latticeai/api/knowledge_graph.py +35 -1
  27. package/latticeai/core/agent.py +264 -9
  28. package/latticeai/core/enterprise.py +5 -0
  29. package/latticeai/core/file_generation.py +130 -5
  30. package/latticeai/core/legacy_compatibility.py +1 -1
  31. package/latticeai/core/marketplace.py +1 -1
  32. package/latticeai/core/run_store.py +243 -0
  33. package/latticeai/core/workspace_os.py +1 -1
  34. package/latticeai/models/router.py +25 -12
  35. package/latticeai/services/architecture_readiness.py +1 -1
  36. package/latticeai/services/command_center.py +90 -1
  37. package/latticeai/services/folder_watch.py +5 -0
  38. package/latticeai/services/funnel_metrics.py +9 -1
  39. package/latticeai/services/product_readiness.py +1 -1
  40. package/latticeai/services/search_service.py +43 -13
  41. package/package.json +1 -1
  42. package/scripts/bench_agent_smoke.py +410 -0
  43. package/scripts/check_current_release_docs.mjs +1 -1
  44. package/scripts/funnel_soft_gate.py +192 -0
  45. package/src-tauri/Cargo.lock +1 -1
  46. package/src-tauri/Cargo.toml +1 -1
  47. package/src-tauri/tauri.conf.json +1 -1
  48. package/static/app/asset-manifest.json +29 -28
  49. package/static/app/assets/Act-BCmTU0E2.js +1 -0
  50. package/static/app/assets/{Brain-DEY9jLVt.js → Brain-CTnjox7w.js} +2 -2
  51. package/static/app/assets/BrainHome-BJ3sFNX0.js +2 -0
  52. package/static/app/assets/BrainSignals-C52lwZVD.js +1 -0
  53. package/static/app/assets/{Capture-CVZ09QXi.js → Capture-B6vBhFa3.js} +1 -1
  54. package/static/app/assets/{CommandPalette-DepwOQFv.js → CommandPalette-90u9FWFH.js} +1 -1
  55. package/static/app/assets/{Library-Bp0n-HlW.js → Library-pKCK0_tk.js} +1 -1
  56. package/static/app/assets/{LivingBrain-DxP4efJF.js → LivingBrain-Jlf2wFqI.js} +1 -1
  57. package/static/app/assets/{ProductFlow-DRbm7NEq.js → ProductFlow-yg1fKP1P.js} +1 -1
  58. package/static/app/assets/{ReviewCard-C4HAO7A3.js → ReviewCard-DWvD7n9h.js} +1 -1
  59. package/static/app/assets/{System-ByQcmJW-.js → System-BAEuHqNY.js} +1 -1
  60. package/static/app/assets/{bot-BNDyZLR7.js → bot-nB_buEZD.js} +1 -1
  61. package/static/app/assets/circle-pause-DLNw6Ucp.js +1 -0
  62. package/static/app/assets/{circle-play-BkhdcHgd.js → circle-play-B-IsFL1y.js} +1 -1
  63. package/static/app/assets/{cpu-C6jjYm6i.js → cpu-CEPBHaBl.js} +1 -1
  64. package/static/app/assets/{folder-open-DjGIvDBQ.js → folder-open-hmN0N9cX.js} +1 -1
  65. package/static/app/assets/{hard-drive-BlSbwSaT.js → hard-drive-CBV_B_Yd.js} +1 -1
  66. package/static/app/assets/{index-Bge3DXW7.css → index-7FAfYm4v.css} +1 -1
  67. package/static/app/assets/{index-CHu7cgj3.js → index-DrmOCySv.js} +3 -3
  68. package/static/app/assets/{input-DVDI0YR3.js → input-gtVCg-ll.js} +1 -1
  69. package/static/app/assets/{navigation-BddhEWA0.js → navigation-Bot0hvuv.js} +1 -1
  70. package/static/app/assets/{network-pYQt5oBu.js → network-jE42eKfT.js} +1 -1
  71. package/static/app/assets/{primitives-D7gCdEvS.js → primitives-CX2Komon.js} +1 -1
  72. package/static/app/assets/{shield-alert-K9RKGQeg.js → shield-alert-BftATuAA.js} +1 -1
  73. package/static/app/assets/{textarea-sqQmoBKL.js → textarea-CiMJfOSI.js} +1 -1
  74. package/static/app/assets/{useFocusTrap-7EV9dFP2.js → useFocusTrap-B7RPGfFy.js} +1 -1
  75. package/static/app/assets/utils-SJUNVOj5.js +7 -0
  76. package/static/app/index.html +4 -4
  77. package/static/sw.js +1 -1
  78. package/static/app/assets/Act-DmdruVKV.js +0 -1
  79. package/static/app/assets/BrainHome-CeNaxjP1.js +0 -2
  80. package/static/app/assets/BrainSignals-CStjIqYi.js +0 -1
  81. package/static/app/assets/utils-uQYKXNeq.js +0 -7
@@ -2,9 +2,10 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import asyncio
5
6
  import json
6
7
  import logging
7
- from typing import Any, AsyncIterator, Dict, Optional
8
+ from typing import Any, AsyncIterator, Awaitable, Callable, Dict, Optional
8
9
 
9
10
  from fastapi.responses import JSONResponse, StreamingResponse
10
11
 
@@ -37,6 +38,74 @@ def agent_payload_stream(
37
38
  return _stream()
38
39
 
39
40
 
41
+ def agent_live_stream(
42
+ start: Callable[[Callable[[Dict[str, Any]], None]], Awaitable[Dict[str, Any]]],
43
+ *,
44
+ router: Any,
45
+ model_id: Optional[str] = None,
46
+ finalize: Optional[Callable[[Dict[str, Any]], str]] = None,
47
+ ) -> AsyncIterator[str]:
48
+ """Live SSE for an agent run (review Wave 1.1).
49
+
50
+ ``start(observer)`` runs the agent with a per-run step observer; every
51
+ observed step is emitted immediately as a named ``agent_step`` frame, so
52
+ the user watches the loop work instead of staring at silence. When the
53
+ run finishes, ``finalize(result)`` (history/funnel side effects) produces
54
+ the answer text and the classic final payload frames follow — clients
55
+ that ignore named events see exactly the historical stream shape.
56
+ """
57
+
58
+ async def _stream() -> AsyncIterator[str]:
59
+ response_model = model_id or router.current_model_id
60
+ queue: asyncio.Queue = asyncio.Queue()
61
+
62
+ def observer(event: Dict[str, Any]) -> None:
63
+ # Called synchronously from the agent coroutine on this loop.
64
+ queue.put_nowait(event)
65
+
66
+ task = asyncio.create_task(start(observer))
67
+ try:
68
+ while True:
69
+ getter = asyncio.create_task(queue.get())
70
+ done, _ = await asyncio.wait(
71
+ {getter, task}, return_when=asyncio.FIRST_COMPLETED
72
+ )
73
+ if getter in done:
74
+ step = getter.result()
75
+ yield (
76
+ "event: agent_step\n"
77
+ f"data: {json.dumps(step, ensure_ascii=False)}\n\n"
78
+ )
79
+ continue
80
+ getter.cancel()
81
+ break
82
+ while not queue.empty():
83
+ step = queue.get_nowait()
84
+ yield (
85
+ "event: agent_step\n"
86
+ f"data: {json.dumps(step, ensure_ascii=False)}\n\n"
87
+ )
88
+ result = task.result()
89
+ except Exception as exc: # noqa: BLE001 — already streaming a 200
90
+ logging.warning("agent live stream failed: %s", exc)
91
+ detail = getattr(exc, "detail", None) or str(exc)
92
+ yield f"data: {json.dumps({'error': str(detail), 'model': response_model}, ensure_ascii=False)}\n\n"
93
+ yield "data: [DONE]\n\n"
94
+ return
95
+
96
+ answer = str(result.get("response") or "작업을 완료했습니다.")
97
+ if finalize is not None:
98
+ try:
99
+ answer = finalize(result)
100
+ except Exception as exc: # noqa: BLE001 — side effects must not kill the stream
101
+ logging.warning("agent live stream finalize failed: %s", exc)
102
+ yield f"data: {json.dumps({'chunk': answer, 'model': response_model, 'agent': result}, ensure_ascii=False)}\n\n"
103
+ yield f"data: {json.dumps({'chunk': '', 'model': response_model, 'agent': result}, ensure_ascii=False)}\n\n"
104
+ yield "data: [DONE]\n\n"
105
+
106
+ return _stream()
107
+
108
+
40
109
  async def stream_chat(
41
110
  req: Any,
42
111
  context: str,
@@ -136,4 +205,9 @@ async def stream_chat(
136
205
  yield "data: [DONE]\n\n"
137
206
 
138
207
 
139
- __all__ = ["agent_payload_stream", "single_answer_response", "stream_chat"]
208
+ __all__ = [
209
+ "agent_live_stream",
210
+ "agent_payload_stream",
211
+ "single_answer_response",
212
+ "stream_chat",
213
+ ]
@@ -7,7 +7,7 @@ data endpoints back the v3 SPA (Files, Hybrid Search, graph explorer).
7
7
  """
8
8
 
9
9
  from pathlib import Path
10
- from typing import Any, Callable, Dict, Optional
10
+ from typing import Any, Callable, Dict, List, Optional
11
11
 
12
12
  from fastapi import APIRouter, HTTPException, Request
13
13
  from pydantic import BaseModel
@@ -38,6 +38,11 @@ class CurateNoiseRequest(BaseModel):
38
38
  max_removals: int = 200
39
39
 
40
40
 
41
+ class PromotionActionRequest(BaseModel):
42
+ # None applies/rejects every pending promotion; otherwise only these ids.
43
+ ids: Optional[List[str]] = None
44
+
45
+
41
46
  def _workspace_scope_from_request(request: Request) -> Optional[str]:
42
47
  header = request.headers.get("X-Workspace-Id")
43
48
  if header and header.strip():
@@ -173,6 +178,35 @@ def create_knowledge_graph_router(
173
178
  max_removals=req.max_removals,
174
179
  )
175
180
 
181
+ @router.get("/knowledge-graph/promotions")
182
+ async def knowledge_graph_promotions(request: Request):
183
+ """Pending curator promotions awaiting human review (review Wave 4).
184
+
185
+ Populated when ``curate()`` runs in review mode (explicit
186
+ ``review_mode=True`` or the LATTICEAI_GRAPH_PROMOTION_REVIEW env
187
+ opt-in). Administrative like ``/knowledge-graph/curate``: the queue
188
+ governs the shared graph.
189
+ """
190
+ (require_admin or require_user)(request)
191
+ pending = graph().pending_promotions()
192
+ return {"pending": pending, "total": len(pending)}
193
+
194
+ @router.post("/knowledge-graph/promotions/apply")
195
+ async def knowledge_graph_promotions_apply(
196
+ req: PromotionActionRequest, request: Request
197
+ ):
198
+ """Apply pending promotions (all when ``ids`` is omitted)."""
199
+ (require_admin or require_user)(request)
200
+ return graph().apply_pending_promotions(ids=req.ids)
201
+
202
+ @router.post("/knowledge-graph/promotions/reject")
203
+ async def knowledge_graph_promotions_reject(
204
+ req: PromotionActionRequest, request: Request
205
+ ):
206
+ """Reject pending promotions without writing (all when ``ids`` omitted)."""
207
+ (require_admin or require_user)(request)
208
+ return graph().reject_pending_promotions(ids=req.ids)
209
+
176
210
  @router.get("/knowledge-graph/provenance/coverage")
177
211
  async def knowledge_graph_provenance_coverage(request: Request):
178
212
  require_user(request)
@@ -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,97 @@ 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
+
230
375
  @dataclass(frozen=True)
231
376
  class PhaseBudgets:
232
377
  """Per-phase token budgets for the agent loop.
@@ -329,6 +474,16 @@ class AgentDeps:
329
474
  # environment once at first use; tests inject a fixed PhaseBudgets.
330
475
  phase_budgets: Optional[PhaseBudgets] = None
331
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
+
332
487
 
333
488
  class SingleAgentRuntime:
334
489
  """Drives the agent state machine over injected :class:`AgentDeps`."""
@@ -336,6 +491,7 @@ class SingleAgentRuntime:
336
491
  def __init__(self, deps: AgentDeps) -> None:
337
492
  self.deps = deps
338
493
  self._env_phase_budgets: Optional[PhaseBudgets] = None
494
+ self._env_transcript_budget: Optional[TranscriptBudget] = None
339
495
 
340
496
  @property
341
497
  def phase_budgets(self) -> PhaseBudgets:
@@ -348,6 +504,34 @@ class SingleAgentRuntime:
348
504
  self._env_phase_budgets = PhaseBudgets.from_env()
349
505
  return self._env_phase_budgets
350
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)
534
+
351
535
  def boundary(self) -> Dict[str, Any]:
352
536
  return runtime_boundary_contract(
353
537
  name="SingleAgentRuntime",
@@ -412,6 +596,12 @@ class SingleAgentRuntime:
412
596
  "estimated_steps": plan.get("estimated_steps", 1),
413
597
  **({"plan_fixes": plan_fixes} if plan_fixes else {}),
414
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
+ )
415
605
  ctx.state = AgentState.WAITING_APPROVAL
416
606
 
417
607
  # ── APPROVAL ─────────────────────────────────────────────────────
@@ -467,6 +657,7 @@ class SingleAgentRuntime:
467
657
  })
468
658
  decision = "human_approved" if requires and approved_by_human else ("blocked_pending_approval" if requires else "auto_approved")
469
659
  ctx.trace.decision("approve", decision=decision, non_auto_steps=len(non_auto))
660
+ self._emit_step(ctx, "approval", "decision", decision=decision)
470
661
  d.audit(
471
662
  "agent_approval", user_email=current_user,
472
663
  requires_approval=requires,
@@ -530,6 +721,7 @@ class SingleAgentRuntime:
530
721
  "state": AgentState.EXECUTING.value, "action": "final", "thoughts": thoughts,
531
722
  })
532
723
  ctx.trace.decision("execute", decision="final")
724
+ self._emit_step(ctx, "execute", "final")
533
725
  ctx.state = AgentState.VERIFYING
534
726
  return
535
727
 
@@ -540,6 +732,7 @@ class SingleAgentRuntime:
540
732
  "error": "LOOP_DETECTED: identical action+args repeated — halted.",
541
733
  })
542
734
  ctx.trace.decision("execute", decision="loop_detected", tool=name)
735
+ self._emit_step(ctx, "execute", "blocked", action=name, reason="loop_detected")
543
736
  break
544
737
 
545
738
  if name == "clear_history":
@@ -548,6 +741,7 @@ class SingleAgentRuntime:
548
741
  "state": AgentState.EXECUTING.value, "action": name,
549
742
  "thoughts": thoughts, "args": args, "result": result,
550
743
  })
744
+ self._emit_step(ctx, "execute", "tool", action=name, ok=True)
551
745
  continue
552
746
 
553
747
  policy = d.policy_for(name, args)
@@ -576,10 +770,13 @@ class SingleAgentRuntime:
576
770
  ) -> str:
577
771
  """Assemble one executor turn's prompt (plan, corrections, recent chat)."""
578
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:]
579
776
  corrections_hint = (
580
777
  "\n\nCritic corrections from previous attempt:\n"
581
- + "\n".join(f"- {c}" for c in ctx.corrections)
582
- ) if ctx.corrections else ""
778
+ + "\n".join(f"- {c}" for c in active_corrections)
779
+ ) if active_corrections else ""
583
780
 
584
781
  recent_kwargs = {
585
782
  "conversation_id": req.conversation_id,
@@ -588,6 +785,12 @@ class SingleAgentRuntime:
588
785
  if request_workspace is not None:
589
786
  recent_kwargs["workspace_id"] = request_workspace
590
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
+ )
591
794
  return (
592
795
  f"{d.executor_prompt}\n\n"
593
796
  f"[LANGUAGE HINT: {lang_hint}]\n"
@@ -595,7 +798,7 @@ class SingleAgentRuntime:
595
798
  f"PLAN:\n{json.dumps(ctx.plan, ensure_ascii=False)}\n\n"
596
799
  f"Recent conversation:\n{recent_conversation}\n\n"
597
800
  f"User request: {req.message}{corrections_hint}\n\n"
598
- 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)}"
599
802
  )
600
803
 
601
804
  def _note_parse_failure(
@@ -608,8 +811,10 @@ class SingleAgentRuntime:
608
811
  })
609
812
  if parse_failures >= 3:
610
813
  ctx.trace.parse_error("execute", error=str(exc), recovered=False)
814
+ self._emit_step(ctx, "execute", "parse_error", recovered=False)
611
815
  return True
612
816
  ctx.trace.parse_error("execute", error=str(exc), recovered=True)
817
+ self._emit_step(ctx, "execute", "parse_error", recovered=True)
613
818
  # Weak models often need one concrete reminder of the wire
614
819
  # format; feed it through the corrections channel and retry
615
820
  # instead of aborting the whole run on the first slip.
@@ -665,6 +870,7 @@ class SingleAgentRuntime:
665
870
  if verdict is not None and verdict.get("decision") == "proposed":
666
871
  proposal = verdict.get("proposal") or {}
667
872
  ctx.trace.tool("execute", name=name, outcome="proposed", risk=risk)
873
+ self._emit_step(ctx, "execute", "proposed", action=name)
668
874
  ctx.transcript.append({
669
875
  "state": AgentState.EXECUTING.value, "action": name,
670
876
  "thoughts": thoughts, "args": {k: v for k, v in args.items() if k != "content"},
@@ -691,6 +897,7 @@ class SingleAgentRuntime:
691
897
  d = self.deps
692
898
  if policy["risk"] == "destructive":
693
899
  ctx.trace.tool("execute", name=name, outcome="blocked_destructive", risk=risk)
900
+ self._emit_step(ctx, "execute", "blocked", action=name, reason="destructive")
694
901
  ctx.transcript.append({
695
902
  "state": AgentState.EXECUTING.value, "action": name,
696
903
  "thoughts": thoughts, "args": args, "risk": risk,
@@ -713,6 +920,7 @@ class SingleAgentRuntime:
713
920
  args={k: v for k, v in args.items() if k != "content"},
714
921
  )
715
922
  ctx.trace.tool("execute", name=name, outcome="blocked_approval", risk=risk)
923
+ self._emit_step(ctx, "execute", "blocked", action=name, reason="approval")
716
924
  ctx.transcript.append({
717
925
  "state": AgentState.EXECUTING.value, "action": name,
718
926
  "thoughts": thoughts, "args": args, "risk": risk,
@@ -748,6 +956,11 @@ class SingleAgentRuntime:
748
956
  "artifact_repair" if meta.get("repaired") else "artifact_sanitize"
749
957
  ],
750
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
+ )
751
964
  try:
752
965
  d.check_role(name, current_user)
753
966
  # Shared tool lifecycle: pre_tool (may block) → execute → post_tool.
@@ -763,6 +976,10 @@ class SingleAgentRuntime:
763
976
  "risk": risk, "governance": dict(policy), "result": result,
764
977
  **({"content_sanitize": sanitize_meta} if sanitize_meta else {}),
765
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
+ )
766
983
  except (ToolError, KeyError, TypeError, PermissionError) as exc:
767
984
  ctx.trace.tool("execute", name=name, outcome="error", risk=risk)
768
985
  ctx.transcript.append({
@@ -770,6 +987,10 @@ class SingleAgentRuntime:
770
987
  "thoughts": thoughts, "args": args,
771
988
  "risk": risk, "governance": dict(policy), "error": str(exc),
772
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
+ )
773
994
 
774
995
  # ── VERIFY ───────────────────────────────────────────────────────
775
996
  def _has_execution_evidence(self, ctx: AgentRunContext) -> bool:
@@ -798,12 +1019,18 @@ class SingleAgentRuntime:
798
1019
  NEEDS_REVIEW so the user is told to check the result themselves.
799
1020
  """
800
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
+ )
801
1028
  context = (
802
1029
  f"{d.critic_prompt}\n\n"
803
1030
  f"[LANGUAGE HINT: {lang_hint}]\n\n"
804
1031
  f"Original request: {req.message}\n"
805
1032
  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)}"
1033
+ f"Full transcript:\n{json.dumps(verify_transcript, ensure_ascii=False, indent=2)}"
807
1034
  )
808
1035
  raw = await d.generate_as(
809
1036
  model_id,
@@ -857,6 +1084,7 @@ class SingleAgentRuntime:
857
1084
  "verify", decision="verification_unavailable",
858
1085
  verifier_available=False, verdict_valid=False, evidence=has_evidence,
859
1086
  )
1087
+ self._emit_step(ctx, "verify", "verdict", verdict="UNAVAILABLE")
860
1088
  ctx.final_message = (
861
1089
  "검증을 완료하지 못했습니다 — 검증 모델의 응답을 해석할 수 없었습니다. "
862
1090
  "실행 결과를 직접 확인해 주시고, 필요하면 다시 시도해 주세요."
@@ -885,6 +1113,10 @@ class SingleAgentRuntime:
885
1113
  "verify", decision=str(verdict.get("verdict", "")), next_state=next_s,
886
1114
  verifier_available=True, verdict_valid=True, evidence=has_evidence,
887
1115
  )
1116
+ self._emit_step(
1117
+ ctx, "verify", "verdict",
1118
+ verdict=str(verdict.get("verdict", "")), next_state=next_s,
1119
+ )
888
1120
  if verdict.get("verdict") == "PASS":
889
1121
  # DONE requires both: a validly parsed PASS verdict AND
890
1122
  # deterministic execution evidence in the transcript. A PASS over
@@ -966,16 +1198,33 @@ class SingleAgentRuntime:
966
1198
  else "롤백을 시도했으나 복구할 파일이 없거나 git이 초기화되지 않았습니다."
967
1199
  )
968
1200
  d.audit("agent_rollback", user_email=current_user, rolled_back=rolled)
1201
+ self._emit_step(ctx, "rollback", "rolled_back", recovered=len(recovered))
969
1202
  # Rollback is a recovery from a failed verification — terminal state is FAILED
970
1203
  ctx.state = AgentState.FAILED
971
1204
 
972
1205
  # ── MEMORY ───────────────────────────────────────────────────────
973
1206
  async def memory_update(self, ctx: AgentRunContext, req: Any, current_user: str) -> None:
974
- """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
+ """
975
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
+ )
976
1224
  context = (
977
1225
  f"{d.memory_updater_prompt}\n\n"
978
- f"Completed task: {req.message}\n\n"
1226
+ f"Task: {req.message}\n"
1227
+ f"Terminal status: {terminal}. {outcome_hint}\n\n"
979
1228
  f"Last 5 transcript steps:\n{json.dumps(ctx.transcript[-5:], ensure_ascii=False)}"
980
1229
  )
981
1230
  try:
@@ -987,6 +1236,11 @@ class SingleAgentRuntime:
987
1236
  kept_learnings = filter_learnings(mem.get("learnings") or [])
988
1237
  if mem.get("save_to_knowledge") and kept_learnings:
989
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")
990
1244
  if d.brain_memory is not None:
991
1245
  # This runtime is LLM-driven — its learnings are real
992
1246
  # experiences and enter the brain with provenance.
@@ -995,7 +1249,7 @@ class SingleAgentRuntime:
995
1249
  learnings,
996
1250
  run={
997
1251
  "mode": "llm",
998
- "status": "ok",
1252
+ "status": status_label,
999
1253
  "agent_id": "agent:executor",
1000
1254
  "steps": len(ctx.transcript),
1001
1255
  },
@@ -1036,3 +1290,4 @@ class SingleAgentRuntime:
1036
1290
  ctx.state = AgentState.FAILED
1037
1291
 
1038
1292
  ctx.state_history.append(ctx.state.value)
1293
+ self._emit_step(ctx, "terminal", "state", state=ctx.state.value)
@@ -52,6 +52,11 @@ class EnterpriseCapability(str, Enum):
52
52
  DLP_POLICY = "dlp_policy"
53
53
  EDISCOVERY = "ediscovery"
54
54
  ADMIN_POLICY_PACKS = "admin_policy_packs"
55
+ # Human review-before-promote governance for knowledge-graph concept
56
+ # promotions (review 2026-07-25 Wave 4). Community users can opt in per
57
+ # deployment via LATTICEAI_GRAPH_PROMOTION_REVIEW; this capability is the
58
+ # seam for Enterprise policy packs that mandate reviewed promotions.
59
+ GRAPH_PROMOTION_REVIEW = "graph_promotion_review"
55
60
 
56
61
 
57
62
  @runtime_checkable