ltcai 9.9.4 → 9.9.9

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 (138) hide show
  1. package/README.md +43 -42
  2. package/docs/CHANGELOG.md +228 -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 +3 -1
  7. package/docs/PERMISSION_MODE.md +107 -0
  8. package/docs/SURFACE_PARITY.md +45 -9
  9. package/docs/TRUST_MODEL.md +1 -1
  10. package/docs/WHY_LATTICE.md +1 -1
  11. package/docs/kg-schema.md +1 -1
  12. package/lattice_brain/__init__.py +1 -1
  13. package/lattice_brain/context.py +39 -4
  14. package/lattice_brain/graph/_kg_common.py +189 -14
  15. package/lattice_brain/graph/curator.py +62 -0
  16. package/lattice_brain/graph/discovery.py +76 -0
  17. package/lattice_brain/graph/ingest.py +37 -7
  18. package/lattice_brain/graph/rerank.py +160 -0
  19. package/lattice_brain/graph/retrieval.py +28 -442
  20. package/lattice_brain/graph/retrieval_reads.py +459 -0
  21. package/lattice_brain/graph/retrieval_vector.py +10 -0
  22. package/lattice_brain/graph/store.py +2 -0
  23. package/lattice_brain/ingestion.py +9 -106
  24. package/lattice_brain/ingestion_jobs.py +133 -0
  25. package/lattice_brain/quality.py +26 -6
  26. package/lattice_brain/runtime/multi_agent.py +1 -1
  27. package/latticeai/__init__.py +1 -1
  28. package/latticeai/api/brain_intelligence.py +8 -0
  29. package/latticeai/api/chat.py +9 -0
  30. package/latticeai/api/chat_agent_http.py +206 -48
  31. package/latticeai/api/chat_contracts.py +10 -1
  32. package/latticeai/api/chat_documents.py +16 -0
  33. package/latticeai/api/evidence_actions.py +48 -0
  34. package/latticeai/api/permission_mode.py +61 -0
  35. package/latticeai/api/project_sessions.py +112 -0
  36. package/latticeai/api/voice_capture.py +89 -0
  37. package/latticeai/app_factory.py +53 -0
  38. package/latticeai/core/agent.py +536 -60
  39. package/latticeai/core/agent_permission.py +160 -0
  40. package/latticeai/core/agent_profiles.py +110 -0
  41. package/latticeai/core/artifact_ledger.py +109 -0
  42. package/latticeai/core/context_builder.py +101 -2
  43. package/latticeai/core/legacy_compatibility.py +1 -1
  44. package/latticeai/core/marketplace.py +1 -1
  45. package/latticeai/core/permission_mode.py +296 -0
  46. package/latticeai/core/project_sessions.py +337 -0
  47. package/latticeai/core/run_explain.py +421 -0
  48. package/latticeai/core/run_store.py +7 -0
  49. package/latticeai/core/workspace_os.py +15 -80
  50. package/latticeai/core/workspace_review_items.py +107 -0
  51. package/latticeai/integrations/telegram_bot.py +270 -23
  52. package/latticeai/runtime/chat_wiring.py +8 -0
  53. package/latticeai/runtime/context_runtime.py +11 -0
  54. package/latticeai/runtime/permission_mode_wiring.py +116 -0
  55. package/latticeai/runtime/router_registration.py +9 -0
  56. package/latticeai/services/app_context.py +3 -0
  57. package/latticeai/services/architecture_readiness.py +1 -1
  58. package/latticeai/services/brain_intelligence.py +105 -0
  59. package/latticeai/services/evidence_actions.py +254 -0
  60. package/latticeai/services/funnel_metrics.py +111 -16
  61. package/latticeai/services/local_knowledge.py +26 -0
  62. package/latticeai/services/memory_service.py +12 -0
  63. package/latticeai/services/permission_mode_service.py +170 -0
  64. package/latticeai/services/product_readiness.py +1 -1
  65. package/latticeai/services/tool_dispatch.py +129 -4
  66. package/latticeai/services/voice_capture.py +189 -0
  67. package/package.json +6 -3
  68. package/scripts/check_bundle_budget.mjs +11 -0
  69. package/scripts/check_current_release_docs.mjs +1 -1
  70. package/scripts/check_i18n_namespace_coverage.mjs +148 -0
  71. package/scripts/run_sidecar_e2e.mjs +159 -0
  72. package/skills/meeting_notes/SKILL.md +82 -0
  73. package/skills/meeting_notes/examples.md +41 -0
  74. package/skills/meeting_notes/risk.json +9 -0
  75. package/skills/meeting_notes/schema.json +55 -0
  76. package/skills/weekly_review/SKILL.md +75 -0
  77. package/skills/weekly_review/examples.md +39 -0
  78. package/skills/weekly_review/risk.json +9 -0
  79. package/skills/weekly_review/schema.json +55 -0
  80. package/src-tauri/Cargo.lock +1 -1
  81. package/src-tauri/Cargo.toml +1 -1
  82. package/src-tauri/tauri.conf.json +1 -1
  83. package/static/app/asset-manifest.json +37 -29
  84. package/static/app/assets/Act-BFbpl2_Y.js +1 -0
  85. package/static/app/assets/AdminConsole-DaZPha9Y.js +1 -0
  86. package/static/app/assets/Brain-DGrV0heZ.js +321 -0
  87. package/static/app/assets/BrainHome-Re6Zxh_W.js +2 -0
  88. package/static/app/assets/BrainSignals-BQ11jDpJ.js +1 -0
  89. package/static/app/assets/Capture-DRaf1zLH.js +1 -0
  90. package/static/app/assets/CommandPalette-DAKPm4uJ.js +1 -0
  91. package/static/app/assets/LanguageSwitcher-BgTwHoWv.js +1 -0
  92. package/static/app/assets/Library-BVtrSt12.js +1 -0
  93. package/static/app/assets/LivingBrain-7glOmK0R.js +1 -0
  94. package/static/app/assets/ProductFlow-cB3EFinZ.js +1 -0
  95. package/static/app/assets/ReviewCard-CShhHS-w.js +3 -0
  96. package/static/app/assets/System-B4nQDy8a.js +1 -0
  97. package/static/app/assets/{bot-nB_buEZD.js → bot-DtKkLfXj.js} +1 -1
  98. package/static/app/assets/brain-wVV63_1z.js +1 -0
  99. package/static/app/assets/button-BTxZDMFJ.js +1 -0
  100. package/static/app/assets/{circle-pause-DLNw6Ucp.js → circle-pause--qxF9-EO.js} +1 -1
  101. package/static/app/assets/{circle-play-B-IsFL1y.js → circle-play-89x3S6jm.js} +1 -1
  102. package/static/app/assets/{cpu-CEPBHaBl.js → cpu-DTUI63vo.js} +1 -1
  103. package/static/app/assets/download-EJS5_O0f.js +1 -0
  104. package/static/app/assets/{folder-open-hmN0N9cX.js → folder-open-BL9zYTnc.js} +1 -1
  105. package/static/app/assets/{hard-drive-CBV_B_Yd.js → hard-drive-BFnwi1Lu.js} +1 -1
  106. package/static/app/assets/index-BFtDYI6l.js +10 -0
  107. package/static/app/assets/index-BY9bpxyx.css +2 -0
  108. package/static/app/assets/input-DEz5UjBz.js +1 -0
  109. package/static/app/assets/{network-jE42eKfT.js → network-zKa3jAwl.js} +1 -1
  110. package/static/app/assets/primitives-jNrZm2O9.js +1 -0
  111. package/static/app/assets/search-ku6-_6wY.js +1 -0
  112. package/static/app/assets/shield-alert-CpHeCsaF.js +1 -0
  113. package/static/app/assets/textarea-DpcQkn75.js +1 -0
  114. package/static/app/assets/{useFocusTrap-B7RPGfFy.js → useFocusTrap-58TNCONM.js} +1 -1
  115. package/static/app/assets/{navigation-Bot0hvuv.js → useQuery-CcVlXlBd.js} +1 -1
  116. package/static/app/assets/users-C0cFMj6o.js +1 -0
  117. package/static/app/assets/utils-DYf7pqrR.js +7 -0
  118. package/static/app/assets/workspace-CjthHsLN.js +1 -0
  119. package/static/app/index.html +4 -4
  120. package/static/sw.js +1 -1
  121. package/static/app/assets/Act-BCmTU0E2.js +0 -1
  122. package/static/app/assets/Brain-CTnjox7w.js +0 -321
  123. package/static/app/assets/BrainHome-BJ3sFNX0.js +0 -2
  124. package/static/app/assets/BrainSignals-C52lwZVD.js +0 -1
  125. package/static/app/assets/Capture-B6vBhFa3.js +0 -1
  126. package/static/app/assets/CommandPalette-90u9FWFH.js +0 -1
  127. package/static/app/assets/Library-pKCK0_tk.js +0 -1
  128. package/static/app/assets/LivingBrain-Jlf2wFqI.js +0 -1
  129. package/static/app/assets/ProductFlow-yg1fKP1P.js +0 -1
  130. package/static/app/assets/ReviewCard-DWvD7n9h.js +0 -3
  131. package/static/app/assets/System-BAEuHqNY.js +0 -1
  132. package/static/app/assets/index-7FAfYm4v.css +0 -2
  133. package/static/app/assets/index-DrmOCySv.js +0 -10
  134. package/static/app/assets/input-gtVCg-ll.js +0 -1
  135. package/static/app/assets/primitives-CX2Komon.js +0 -1
  136. package/static/app/assets/shield-alert-BftATuAA.js +0 -1
  137. package/static/app/assets/textarea-CiMJfOSI.js +0 -1
  138. package/static/app/assets/utils-SJUNVOj5.js +0 -7
@@ -0,0 +1,133 @@
1
+ """Background ingestion jobs — queue + per-job progress (v9.9.6 extraction).
2
+
3
+ Split out of :mod:`lattice_brain.ingestion` (behaviour-preserving move, review
4
+ 2026-07-27 P2 #8 "ingestion job/watch 분리"). The pipeline owns *what it means
5
+ to ingest one item*; this module owns *scheduling many of them and reporting
6
+ progress* — a genuinely separate concern with its own frozen wire schema
7
+ (``/api/ingestion/jobs*``) and its own resume semantics.
8
+
9
+ The seam is also where a real scheduler (thread pool, rq, celery) would plug
10
+ in without touching the pipeline.
11
+
12
+ ``IngestionItem`` is imported only for type checking: the pipeline module owns
13
+ that dataclass, and a runtime import here would be circular.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import threading
19
+ from dataclasses import dataclass, field
20
+ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set
21
+
22
+ from .utils import utc_now_iso
23
+
24
+ if TYPE_CHECKING: # pragma: no cover - typing only
25
+ from .ingestion import IngestionItem
26
+
27
+
28
+ JOB_ERRORS_CAP = 50 # per-job error records kept (failed count keeps counting)
29
+
30
+
31
+ @dataclass
32
+ class BackgroundIngestionJob:
33
+ """Job descriptor + progress state for background/incremental indexing.
34
+
35
+ ``done_indices`` tracks per-item completion so an interrupted or partially
36
+ failed job can be *resumed* from the remaining items instead of restarting.
37
+ ``errors`` is capped at ``max_errors`` records; ``failed`` keeps counting.
38
+ """
39
+ job_id: str
40
+ items: List[IngestionItem]
41
+ status: str = "queued" # queued | running | completed | failed | partial
42
+ created_at: str = field(default_factory=utc_now_iso)
43
+ updated_at: str = field(default_factory=utc_now_iso)
44
+ processed: int = 0
45
+ failed: int = 0
46
+ total: int = 0
47
+ errors: List[Dict[str, Any]] = field(default_factory=list)
48
+ incremental: bool = True
49
+ user_email: Optional[str] = None
50
+ max_errors: int = JOB_ERRORS_CAP
51
+ done_indices: Set[int] = field(default_factory=set)
52
+
53
+ def touch(self) -> None:
54
+ self.updated_at = utc_now_iso()
55
+
56
+ def record_error(self, index: int, item: IngestionItem, detail: Any) -> None:
57
+ self.failed += 1
58
+ if len(self.errors) < self.max_errors:
59
+ self.errors.append({
60
+ "index": index,
61
+ "source": item.source_uri or item.path or item.title or item.source_type,
62
+ "detail": str(detail)[:500],
63
+ })
64
+
65
+ def remaining_indices(self) -> List[int]:
66
+ return [i for i in range(len(self.items)) if i not in self.done_indices]
67
+
68
+ def as_dict(self) -> Dict[str, Any]:
69
+ """Frozen job schema consumed by ``/api/ingestion/jobs*``."""
70
+ return {
71
+ "job_id": self.job_id,
72
+ "status": self.status,
73
+ "total": self.total,
74
+ "processed": self.processed,
75
+ "failed": self.failed,
76
+ "errors": list(self.errors),
77
+ "created_at": self.created_at,
78
+ "updated_at": self.updated_at,
79
+ }
80
+
81
+
82
+ class BackgroundIngestionQueue:
83
+ """Simple in-memory queue for background incremental ingestion.
84
+
85
+ For large corpus: this is the seam where a real scheduler / worker pool
86
+ (celery, rq, or internal thread) can be plugged later without changing callers.
87
+ Supports incremental (skip duplicates) vs force reindex.
88
+ """
89
+ def __init__(self) -> None:
90
+ self._jobs: Dict[str, BackgroundIngestionJob] = {}
91
+ self._counter = 0
92
+ self._lock = threading.Lock()
93
+
94
+ def schedule(
95
+ self,
96
+ items: List[IngestionItem],
97
+ *,
98
+ incremental: bool = True,
99
+ user_email: Optional[str] = None,
100
+ ) -> BackgroundIngestionJob:
101
+ with self._lock:
102
+ self._counter += 1
103
+ job_id = f"bg_ingest_{self._counter:04d}"
104
+ job = BackgroundIngestionJob(
105
+ job_id=job_id,
106
+ items=items,
107
+ total=len(items),
108
+ incremental=incremental,
109
+ user_email=user_email,
110
+ )
111
+ # annotate items for downstream
112
+ for it in job.items:
113
+ # attach flag without breaking dataclass defaults (use metadata)
114
+ it.metadata = {**it.metadata, "incremental": incremental, "bg_job": job_id}
115
+ with self._lock:
116
+ self._jobs[job_id] = job
117
+ return job
118
+
119
+ def get(self, job_id: str) -> Optional[BackgroundIngestionJob]:
120
+ return self._jobs.get(job_id)
121
+
122
+ def list_pending(self) -> List[BackgroundIngestionJob]:
123
+ return [j for j in self._jobs.values() if j.status == "queued"]
124
+
125
+ def list_recent(self, limit: int = 20) -> List[BackgroundIngestionJob]:
126
+ """Most recent jobs first (insertion order is schedule order)."""
127
+ limit = max(1, int(limit))
128
+ with self._lock:
129
+ jobs = list(self._jobs.values())
130
+ return list(reversed(jobs))[:limit]
131
+
132
+
133
+ __all__ = ["JOB_ERRORS_CAP", "BackgroundIngestionJob", "BackgroundIngestionQueue"]
@@ -131,13 +131,33 @@ class HybridFusion:
131
131
  return sorted(results, key=lambda x: x["fused_score"], reverse=True)
132
132
 
133
133
  class RerankerInterface:
134
- """Pluggable reranker interface"""
134
+ """Pluggable reranker interface.
135
+
136
+ Default is identity (fused score). When
137
+ ``LATTICEAI_CROSS_ENCODER_RERANK=1`` and a CrossEncoder is importable,
138
+ delegates to :func:`lattice_brain.graph.rerank.rerank_matches`. Failures
139
+ never raise — always returns a ranked list.
140
+ """
141
+
135
142
  def rerank(self, query: str, candidates: List[Dict], top_k: int = 5) -> List[Dict]:
136
- # Default local-safe fallback: preserve fused ordering without making
137
- # unearned cross-encoder claims.
138
- for c in candidates:
139
- c["rerank_score"] = c.get("fused_score", 0.0)
140
- return sorted(candidates, key=lambda x: x.get("rerank_score", 0), reverse=True)[:top_k]
143
+ try:
144
+ from lattice_brain.graph.rerank import rerank_matches
145
+
146
+ # Map fused_score → score so the shared helper ranks consistently.
147
+ prepared = []
148
+ for c in candidates:
149
+ item = dict(c)
150
+ if "score" not in item:
151
+ item["score"] = item.get("fused_score", 0.0)
152
+ prepared.append(item)
153
+ result = rerank_matches(query, prepared, top_k=top_k)
154
+ return list(result.get("matches") or [])
155
+ except Exception: # noqa: BLE001 — quality path must never raise
156
+ for c in candidates:
157
+ c["rerank_score"] = c.get("fused_score", 0.0)
158
+ return sorted(
159
+ candidates, key=lambda x: x.get("rerank_score", 0), reverse=True
160
+ )[:top_k]
141
161
 
142
162
  # -----------------------------
143
163
  # 3. Memory Candidate Extraction / Scoring / Dedupe / Merge / Conflict / Retention
@@ -48,7 +48,7 @@ from .contracts import multi_agent_contract
48
48
  from ..utils import now_iso as _now
49
49
 
50
50
 
51
- MULTI_AGENT_VERSION = "9.9.4"
51
+ MULTI_AGENT_VERSION = "9.9.9"
52
52
 
53
53
  AGENT_ROLES = ("researcher", "planner", "executor", "reviewer", "release")
54
54
  CORE_PIPELINE = ("planner", "executor", "reviewer")
@@ -1,3 +1,3 @@
1
1
  """Lattice AI - modular server package."""
2
2
 
3
- __version__ = "9.9.4"
3
+ __version__ = "9.9.9"
@@ -57,6 +57,14 @@ def create_brain_intelligence_router(
57
57
  scope = gate_read(request)
58
58
  return service.contradictions(user_email=user, workspace_id=scope)
59
59
 
60
+ @router.get("/api/brain/garden")
61
+ async def brain_garden(request: Request, limit: int = 8):
62
+ """Knowledge garden overview (v9.9.7): recent / contradictions /
63
+ stale / frequent, read-only and workspace-scoped."""
64
+ user = require_user(request)
65
+ scope = gate_read(request)
66
+ return service.garden_overview(user_email=user, workspace_id=scope, limit=limit)
67
+
60
68
  @router.get("/api/brain/vector-freshness")
61
69
  async def brain_vector_freshness(request: Request):
62
70
  """Vector index freshness summary (read-only, never raises).
@@ -16,6 +16,7 @@ from fastapi import APIRouter, HTTPException, Request
16
16
  from fastapi.responses import JSONResponse, StreamingResponse
17
17
 
18
18
  from latticeai.api.chat_agent_http import AgentHTTPController
19
+ from latticeai.core.project_sessions import ProjectSessionStore
19
20
  from latticeai.api.chat_contracts import (
20
21
  AgentEvalRequest,
21
22
  AgentRequest,
@@ -197,6 +198,14 @@ def create_chat_router(context: AppContext) -> APIRouter:
197
198
  if context.data_dir
198
199
  else None
199
200
  ),
201
+ # Multi-turn project loop (v9.9.6): optional — without a data dir the
202
+ # agent runs exactly as before, one run at a time with no project state.
203
+ project_sessions=(
204
+ ProjectSessionStore(Path(context.data_dir) / "project_sessions")
205
+ if context.data_dir
206
+ else None
207
+ ),
208
+ artifact_ledger=context.artifact_ledger,
200
209
  )
201
210
  intent_controller = ChatIntentController(
202
211
  model_router=model_router,
@@ -14,11 +14,14 @@ from pathlib import Path
14
14
  from typing import Any, Dict, Optional
15
15
 
16
16
  from fastapi import APIRouter, HTTPException, Request
17
+ from fastapi.responses import StreamingResponse
17
18
 
18
19
  from lattice_brain.runtime.hooks import dispatch_tool
19
20
  from latticeai.api.chat_contracts import AgentEvalRequest, AgentRequest, AgentResumeRequest
20
21
  from latticeai.api.chat_helpers import _LANG_HINT, detect_language, workspace_scope_from_request
22
+ from latticeai.api.chat_stream import agent_live_stream
21
23
  from latticeai.core.agent import AgentRunContext, AgentState, normalize_plan
24
+ from latticeai.core.run_explain import explain_run
22
25
  from latticeai.core.run_store import (
23
26
  AgentRunStore,
24
27
  hash_approval_token,
@@ -48,6 +51,8 @@ class AgentHTTPController:
48
51
  ensure_agent_root: Any,
49
52
  funnel_metrics: Any = None,
50
53
  run_store: Any = None,
54
+ project_sessions: Any = None,
55
+ artifact_ledger: Any = None,
51
56
  ) -> None:
52
57
  self.runtime = runtime
53
58
  self.model_router = model_router
@@ -65,9 +70,13 @@ class AgentHTTPController:
65
70
  self.agent_root = Path(agent_root)
66
71
  self.ensure_agent_root = ensure_agent_root
67
72
  self.funnel_metrics = funnel_metrics
68
- self._pending: Dict[str, tuple] = {}
73
+ # Multi-turn project loop (v9.9.6). Optional: without a store the
74
+ # agent behaves exactly as before — single runs, no project memory.
75
+ self.project_sessions = project_sessions
76
+ # Re-search loop (v9.9.6): the next turn must see files this run just
77
+ # wrote, even before asynchronous indexing catches up.
78
+ self.artifact_ledger = artifact_ledger
69
79
  self._pending_lock = threading.Lock()
70
- self._pending_ttl_seconds = 15 * 60
71
80
  # awaiting_approval runs: run_id -> paused run state + approval token.
72
81
  # Fail-closed: governed steps only ever execute after resume presents
73
82
  # the matching, unexpired token for this run (or via the legacy
@@ -93,6 +102,24 @@ class AgentHTTPController:
93
102
 
94
103
  @router.post("/agent")
95
104
  async def agent(req: AgentRequest, request: Request):
105
+ if req.stream:
106
+ # Surface parity (v9.9.7): the web app already watches the loop
107
+ # live through the chat route. `/agent` clients (VS Code) get
108
+ # the same named `agent_step` frames here, followed by the
109
+ # exact terminal payload the JSON response returns — a client
110
+ # that ignores named events sees the historical shape.
111
+ async def start(observer):
112
+ return await self.agent(req, request, on_step=observer)
113
+
114
+ return StreamingResponse(
115
+ agent_live_stream(
116
+ start,
117
+ router=self.model_router,
118
+ model_id=req.executing_model,
119
+ ),
120
+ media_type="text/event-stream",
121
+ headers={"X-Routed-To": "agent"},
122
+ )
96
123
  return await self.agent(req, request)
97
124
 
98
125
  @router.post("/agent/resume")
@@ -240,6 +267,18 @@ class AgentHTTPController:
240
267
  ctx.on_step = on_step
241
268
  ctx.executing_model = req.executing_model
242
269
  ctx.reviewing_model = req.reviewing_model
270
+ ctx.project_context = self._project_summary(req, current_user)
271
+ # Autonomy dial (v9.9.8): resolve the user/workspace-scoped mode once so
272
+ # the plan gate and every per-tool gate in this run agree, even if the
273
+ # stored preference changes mid-run.
274
+ mode_probe = getattr(self.runtime, "resolve_permission_mode", None)
275
+ if callable(mode_probe):
276
+ try:
277
+ ctx.permission_mode = mode_probe(
278
+ user_email=current_user, workspace_id=req.workspace_id,
279
+ ).value
280
+ except Exception: # noqa: BLE001 — never fail a run over the dial
281
+ ctx.permission_mode = None
243
282
  ctx.state = AgentState.PLANNING
244
283
  ctx.state_history.append(ctx.state.value)
245
284
  await self.runtime.plan(
@@ -250,33 +289,29 @@ class AgentHTTPController:
250
289
  model_id=req.planning_model,
251
290
  )
252
291
 
292
+ requirements_probe = getattr(self.runtime, "approval_requirements", None)
293
+
253
294
  if req.human_in_loop:
254
- context_id = secrets.token_urlsafe(16)
255
- with self._pending_lock:
256
- self._pending[context_id] = (
257
- ctx,
258
- req,
259
- language_hint,
260
- current_user,
261
- time.monotonic(),
262
- )
263
- return {
264
- "status": "waiting_approval",
265
- "context_id": context_id,
266
- "plan": ctx.plan,
267
- "steps": ctx.transcript,
268
- "state_history": ctx.state_history,
269
- "planning_model": req.planning_model or self.model_router.current_model_id,
270
- "executing_model": req.executing_model or self.model_router.current_model_id,
271
- "reviewing_model": req.reviewing_model or self.model_router.current_model_id,
272
- "loop": ctx.trace.summary(),
273
- }
295
+ # Deprecated explicit pause (L1 unification, 9.9.5): the legacy
296
+ # ``human_in_loop`` flow now rides the same durable approval
297
+ # store as ``awaiting_approval`` — one pause path, one resume
298
+ # path, restart-safe. The legacy wire contract is preserved:
299
+ # ``status="waiting_approval"`` + ``context_id`` (token-less,
300
+ # same-user resume).
301
+ requirements = (
302
+ requirements_probe(ctx)
303
+ if callable(requirements_probe)
304
+ else {"requires_approval": True, "non_auto_steps": [], "plan_summary": ""}
305
+ )
306
+ return self._pause_for_approval(
307
+ ctx, req, language_hint, current_user, requirements,
308
+ legacy_context=True,
309
+ )
274
310
 
275
311
  # Interactive approval: when the plan needs human approval, pause the
276
312
  # run as awaiting_approval (short-TTL token) instead of failing it.
277
313
  # ``getattr`` keeps injected fake runtimes without the preview method
278
314
  # on the historical fail-closed path.
279
- requirements_probe = getattr(self.runtime, "approval_requirements", None)
280
315
  if callable(requirements_probe):
281
316
  requirements = requirements_probe(ctx)
282
317
  if requirements.get("requires_approval"):
@@ -294,6 +329,55 @@ class AgentHTTPController:
294
329
  max_retry,
295
330
  )
296
331
 
332
+ def _project_summary(self, req: AgentRequest, current_user: str) -> str:
333
+ """Where this project stands, for the planner/executor prompt.
334
+
335
+ Advisory: a missing store, an unknown project id, or a read failure
336
+ yields "" and the run proceeds as a standalone run.
337
+ """
338
+ project_id = getattr(req, "project_id", None)
339
+ if not project_id or self.project_sessions is None:
340
+ return ""
341
+ try:
342
+ return self.project_sessions.summary(
343
+ str(project_id),
344
+ user_email=current_user or None,
345
+ workspace_id=req.workspace_id,
346
+ )
347
+ except Exception as exc: # noqa: BLE001 — project context never gates a run
348
+ logging.warning("project session summary failed: %s", exc)
349
+ return ""
350
+
351
+ def _record_project_run(
352
+ self,
353
+ req: AgentRequest,
354
+ current_user: str,
355
+ ctx: AgentRunContext,
356
+ payload: Dict[str, Any],
357
+ ) -> None:
358
+ """Fold a finished run into its project session (best effort).
359
+
360
+ The verification result is stored exactly as the run reported it — a
361
+ NEEDS_REVIEW run never becomes a project's "done".
362
+ """
363
+ project_id = getattr(req, "project_id", None)
364
+ if not project_id or self.project_sessions is None:
365
+ return
366
+ try:
367
+ self.project_sessions.record_run(
368
+ str(project_id),
369
+ run_id=str(payload.get("run_id") or ""),
370
+ goal=str(ctx.plan.get("goal") or req.message),
371
+ status=str(payload.get("status") or ""),
372
+ final_state=ctx.state.value,
373
+ files=payload.get("created_files") or [],
374
+ explanation=payload.get("explanation"),
375
+ user_email=current_user or None,
376
+ workspace_id=req.workspace_id,
377
+ )
378
+ except Exception as exc: # noqa: BLE001 — bookkeeping never fails a run
379
+ logging.warning("project session record failed: %s", exc)
380
+
297
381
  def _pause_for_approval(
298
382
  self,
299
383
  ctx: AgentRunContext,
@@ -301,8 +385,16 @@ class AgentHTTPController:
301
385
  language_hint: str,
302
386
  current_user: str,
303
387
  requirements: Dict[str, Any],
388
+ *,
389
+ legacy_context: bool = False,
304
390
  ) -> Dict[str, Any]:
305
- """Park a plan that needs approval and hand the user a resume token."""
391
+ """Park a plan that needs approval and hand the user a resume token.
392
+
393
+ ``legacy_context=True`` marks a deprecated ``human_in_loop`` pause:
394
+ same durable storage, but the response speaks the historical
395
+ ``waiting_approval``/``context_id`` contract and resume may present
396
+ the ``context_id`` instead of the approval token (same user only).
397
+ """
306
398
  run_id = secrets.token_urlsafe(16)
307
399
  approval_token = secrets.token_urlsafe(32)
308
400
  now_monotonic = time.monotonic()
@@ -319,6 +411,7 @@ class AgentHTTPController:
319
411
  "token": approval_token,
320
412
  "expires_monotonic": now_monotonic + self._approval_ttl_seconds,
321
413
  "expires_at": expires_at,
414
+ "legacy_context": legacy_context,
322
415
  }
323
416
  # Durable mirror: a restart between pause and resume must not orphan
324
417
  # the run. Best-effort — the in-memory pause still answers on failure.
@@ -332,6 +425,7 @@ class AgentHTTPController:
332
425
  token=approval_token,
333
426
  expires_epoch=time.time() + self._approval_ttl_seconds,
334
427
  expires_at=expires_at,
428
+ legacy_context=legacy_context,
335
429
  )
336
430
  except Exception as exc: # noqa: BLE001
337
431
  logging.warning("agent run store persist failed: %s", exc)
@@ -345,8 +439,8 @@ class AgentHTTPController:
345
439
  "이 작업에는 승인이 필요한 단계가 있어 실행을 잠시 멈췄습니다. "
346
440
  "계획을 확인한 뒤 승인하면 이어서 실행합니다."
347
441
  )
348
- return {
349
- "status": "awaiting_approval",
442
+ payload = {
443
+ "status": "waiting_approval" if legacy_context else "awaiting_approval",
350
444
  "run_id": run_id,
351
445
  "approval": {
352
446
  "token": approval_token,
@@ -364,6 +458,10 @@ class AgentHTTPController:
364
458
  "reviewing_model": req.reviewing_model or self.model_router.current_model_id,
365
459
  "loop": ctx.trace.summary(),
366
460
  }
461
+ if legacy_context:
462
+ # Historical wire field — the run id doubles as the context id.
463
+ payload["context_id"] = run_id
464
+ return payload
367
465
 
368
466
  def _purge_expired_approvals_locked(self, now_monotonic: float) -> None:
369
467
  for run_id, entry in list(self._approvals.items()):
@@ -450,6 +548,7 @@ class AgentHTTPController:
450
548
  "token_hash": str(record.get("token_hash") or ""),
451
549
  "expires_monotonic": time.monotonic() + remaining,
452
550
  "expires_at": record.get("expires_at"),
551
+ "legacy_context": bool(record.get("legacy_context")),
453
552
  }
454
553
 
455
554
  @staticmethod
@@ -532,7 +631,8 @@ class AgentHTTPController:
532
631
  self.funnel_metrics.increment("needs_review_runs")
533
632
  except Exception as exc: # noqa: BLE001
534
633
  logging.warning("funnel metrics increment failed: %s", exc)
535
- return {
634
+ loop_summary = ctx.trace.summary()
635
+ payload = {
536
636
  "status": "ok" if ctx.state == AgentState.DONE else "failed",
537
637
  "response": message,
538
638
  "workspace": str(self.agent_root),
@@ -541,8 +641,34 @@ class AgentHTTPController:
541
641
  "final_state": ctx.state.value,
542
642
  "created_files": collect_created_files(ctx.transcript),
543
643
  "artifacts": collect_artifacts(ctx.transcript),
544
- "loop": ctx.trace.summary(),
644
+ "loop": loop_summary,
645
+ # Plain-language outcome (v9.9.6): the loop counters say a weak
646
+ # model needed three repairs; this says it in a sentence a person
647
+ # can read, and never upgrades a non-success into a success.
648
+ "explanation": explain_run(
649
+ state=ctx.state,
650
+ loop=loop_summary,
651
+ transcript=ctx.transcript,
652
+ max_retry=max_retry,
653
+ ),
545
654
  }
655
+ # Multi-turn project loop (v9.9.6): the project remembers this run's
656
+ # files and its honest verdict, so the next turn continues instead of
657
+ # restarting.
658
+ self._record_project_run(req, current_user, ctx, payload)
659
+ if self.artifact_ledger is not None and payload.get("created_files"):
660
+ try:
661
+ self.artifact_ledger.record(
662
+ payload["created_files"],
663
+ user_email=current_user or None,
664
+ conversation_id=req.conversation_id,
665
+ workspace_id=req.workspace_id,
666
+ )
667
+ except Exception as exc: # noqa: BLE001 — advisory context only
668
+ logging.warning("artifact ledger record failed: %s", exc)
669
+ if getattr(req, "project_id", None):
670
+ payload["project_id"] = str(req.project_id)
671
+ return payload
546
672
 
547
673
  async def resume(
548
674
  self,
@@ -565,31 +691,63 @@ class AgentHTTPController:
565
691
  status_code=400,
566
692
  detail="run_id (with approval_token) or context_id is required.",
567
693
  )
694
+ # Deprecated context_id resume (L1 unification, 9.9.5): the legacy
695
+ # pause lives in the same durable approval store now. Token-less
696
+ # resume stays allowed for it — bound to the pausing user, and only
697
+ # for entries that were created through the legacy pause.
698
+ now_monotonic = time.monotonic()
568
699
  with self._pending_lock:
569
- now = time.monotonic()
570
- for context_id, pending in list(self._pending.items()):
571
- if now - pending[4] >= self._pending_ttl_seconds:
572
- self._pending.pop(context_id, None)
573
- entry = self._pending.get(req.context_id)
574
- if entry and entry[3] == current_user:
575
- self._pending.pop(req.context_id, None)
576
- if not entry:
577
- raise HTTPException(
578
- status_code=404,
579
- detail="Agent context not found or expired. Start a new request.",
580
- )
700
+ entry = self._approvals.get(req.context_id)
701
+ if entry is None:
702
+ try:
703
+ entry = self._restore_persisted_approval(req.context_id)
704
+ except HTTPException as exc:
705
+ if exc.status_code == 410:
706
+ # Legacy contract never had a distinct expiry answer.
707
+ raise HTTPException(
708
+ status_code=404,
709
+ detail="Agent context not found or expired. Start a new request.",
710
+ ) from exc
711
+ raise
712
+ if entry is None or not entry.get("legacy_context"):
713
+ raise HTTPException(
714
+ status_code=404,
715
+ detail="Agent context not found or expired. Start a new request.",
716
+ )
717
+ if entry["user"] != current_user:
718
+ raise HTTPException(
719
+ status_code=403,
720
+ detail="Agent context belongs to another user.",
721
+ )
722
+ if now_monotonic >= entry["expires_monotonic"]:
723
+ self._approvals.pop(req.context_id, None)
724
+ try:
725
+ self.run_store.delete(req.context_id)
726
+ except Exception: # noqa: BLE001
727
+ pass
728
+ raise HTTPException(
729
+ status_code=404,
730
+ detail="Agent context not found or expired. Start a new request.",
731
+ )
732
+ self._approvals.pop(req.context_id, None)
733
+ try:
734
+ self.run_store.delete(req.context_id)
735
+ except Exception as exc: # noqa: BLE001
736
+ logging.warning("agent run store delete failed: %s", exc)
581
737
 
582
- ctx, original_request, language_hint, original_user, _created_at = entry
583
- if original_user != current_user:
584
- raise HTTPException(
585
- status_code=403,
586
- detail="Agent context belongs to another user.",
587
- )
738
+ ctx = entry["ctx"]
739
+ original_request = entry["req"]
740
+ language_hint = entry["language_hint"]
588
741
  if not req.approved:
589
742
  return {"status": "cancelled", "response": "사용자가 계획을 취소했습니다."}
590
743
  if req.modified_plan:
591
- ctx.plan = req.modified_plan
592
- ctx.transcript[-1].update(ctx.plan)
744
+ plan, plan_fixes = normalize_plan(req.modified_plan, original_request.message)
745
+ ctx.plan = plan
746
+ ctx.transcript.append({
747
+ "state": AgentState.WAITING_APPROVAL.value,
748
+ "edited_plan": True,
749
+ **({"plan_fixes": plan_fixes} if plan_fixes else {}),
750
+ })
593
751
  ctx.executing_model = req.executing_model or ctx.executing_model
594
752
  ctx.reviewing_model = req.reviewing_model or ctx.reviewing_model
595
753
  # The authenticated owner of this pending context explicitly approved
@@ -36,6 +36,15 @@ class AgentRequest(BaseModel):
36
36
  executing_model: Optional[str] = None
37
37
  reviewing_model: Optional[str] = None
38
38
  human_in_loop: bool = False
39
+ # Multi-turn project loop (v9.9.6): when set, the run reads the project's
40
+ # accumulated state (files, open TODOs, last verification) and folds its
41
+ # own outcome back in when it finishes.
42
+ project_id: Optional[str] = None
43
+ # Live loop visibility (v9.9.7): stream named `agent_step` SSE frames while
44
+ # the run executes, then the same terminal payload as the JSON response.
45
+ # Surface parity — the web app already gets this through the chat route;
46
+ # `/agent` clients (VS Code) need it on their own endpoint.
47
+ stream: bool = False
39
48
 
40
49
 
41
50
  class AgentResumeRequest(BaseModel):
@@ -45,7 +54,7 @@ class AgentResumeRequest(BaseModel):
45
54
  modified_plan: Optional[dict] = None
46
55
  executing_model: Optional[str] = None
47
56
  reviewing_model: Optional[str] = None
48
- # awaiting_approval flow (v9.10): run_id + short-TTL approval token.
57
+ # awaiting_approval flow (v9.9.x): run_id + short-TTL approval token.
49
58
  run_id: Optional[str] = None
50
59
  approval_token: Optional[str] = None
51
60
  approve: Optional[bool] = None
@@ -134,6 +134,11 @@ class DocumentGenerationCoordinator:
134
134
  logging.debug(
135
135
  "Document generation context retrieved from knowledge graph."
136
136
  )
137
+ # Shared context contract (v9.9.6): document generation now
138
+ # reports the same context_quality signal chat does, so a
139
+ # thin-context document is as visible as a thin-context answer.
140
+ if retrieval.get("stats", {}).get("budget_trimmed"):
141
+ logging.debug("Document context trimmed to the shared budget.")
137
142
  except Exception as exc:
138
143
  logging.warning("Knowledge graph reinforcement skipped: %s", exc)
139
144
  return DocumentPreparation(is_document, context, retrieval)
@@ -165,6 +170,16 @@ class DocumentGenerationCoordinator:
165
170
  graph_markdown = (preparation.retrieval or {}).get("context_markdown", "")
166
171
  system_prompt = session.get_system_prompt(graph_markdown)
167
172
  footnote = format_sources_footnote((preparation.retrieval or {}).get("sources", []))
173
+ # Shared context contract (v9.9.6): the document path records the same
174
+ # context_quality + assembly trace on the answer trace that chat does,
175
+ # so both surfaces answer "how well was this grounded?" identically.
176
+ context_quality = (preparation.retrieval or {}).get("context_quality")
177
+ if isinstance(trace_seed, dict):
178
+ if context_quality:
179
+ trace_seed["context_quality"] = context_quality
180
+ assembly_trace = (preparation.retrieval or {}).get("trace")
181
+ if assembly_trace:
182
+ trace_seed["context_assembly"] = assembly_trace
168
183
 
169
184
  if req.stream:
170
185
  async def stream_document():
@@ -245,6 +260,7 @@ class DocumentGenerationCoordinator:
245
260
  "response": response_text,
246
261
  "trace_id": trace_record["id"],
247
262
  "trace": trace_record,
263
+ **({"context_quality": context_quality} if context_quality else {}),
248
264
  }
249
265
  )
250
266