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.
- package/README.md +43 -42
- package/docs/CHANGELOG.md +228 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/ONBOARDING.md +1 -1
- package/docs/OPERATIONS.md +3 -1
- package/docs/PERMISSION_MODE.md +107 -0
- package/docs/SURFACE_PARITY.md +45 -9
- package/docs/TRUST_MODEL.md +1 -1
- package/docs/WHY_LATTICE.md +1 -1
- package/docs/kg-schema.md +1 -1
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/context.py +39 -4
- package/lattice_brain/graph/_kg_common.py +189 -14
- package/lattice_brain/graph/curator.py +62 -0
- package/lattice_brain/graph/discovery.py +76 -0
- package/lattice_brain/graph/ingest.py +37 -7
- package/lattice_brain/graph/rerank.py +160 -0
- package/lattice_brain/graph/retrieval.py +28 -442
- package/lattice_brain/graph/retrieval_reads.py +459 -0
- package/lattice_brain/graph/retrieval_vector.py +10 -0
- package/lattice_brain/graph/store.py +2 -0
- package/lattice_brain/ingestion.py +9 -106
- package/lattice_brain/ingestion_jobs.py +133 -0
- package/lattice_brain/quality.py +26 -6
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/brain_intelligence.py +8 -0
- package/latticeai/api/chat.py +9 -0
- package/latticeai/api/chat_agent_http.py +206 -48
- package/latticeai/api/chat_contracts.py +10 -1
- package/latticeai/api/chat_documents.py +16 -0
- package/latticeai/api/evidence_actions.py +48 -0
- package/latticeai/api/permission_mode.py +61 -0
- package/latticeai/api/project_sessions.py +112 -0
- package/latticeai/api/voice_capture.py +89 -0
- package/latticeai/app_factory.py +53 -0
- package/latticeai/core/agent.py +536 -60
- package/latticeai/core/agent_permission.py +160 -0
- package/latticeai/core/agent_profiles.py +110 -0
- package/latticeai/core/artifact_ledger.py +109 -0
- package/latticeai/core/context_builder.py +101 -2
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/permission_mode.py +296 -0
- package/latticeai/core/project_sessions.py +337 -0
- package/latticeai/core/run_explain.py +421 -0
- package/latticeai/core/run_store.py +7 -0
- package/latticeai/core/workspace_os.py +15 -80
- package/latticeai/core/workspace_review_items.py +107 -0
- package/latticeai/integrations/telegram_bot.py +270 -23
- package/latticeai/runtime/chat_wiring.py +8 -0
- package/latticeai/runtime/context_runtime.py +11 -0
- package/latticeai/runtime/permission_mode_wiring.py +116 -0
- package/latticeai/runtime/router_registration.py +9 -0
- package/latticeai/services/app_context.py +3 -0
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/brain_intelligence.py +105 -0
- package/latticeai/services/evidence_actions.py +254 -0
- package/latticeai/services/funnel_metrics.py +111 -16
- package/latticeai/services/local_knowledge.py +26 -0
- package/latticeai/services/memory_service.py +12 -0
- package/latticeai/services/permission_mode_service.py +170 -0
- package/latticeai/services/product_readiness.py +1 -1
- package/latticeai/services/tool_dispatch.py +129 -4
- package/latticeai/services/voice_capture.py +189 -0
- package/package.json +6 -3
- package/scripts/check_bundle_budget.mjs +11 -0
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/check_i18n_namespace_coverage.mjs +148 -0
- package/scripts/run_sidecar_e2e.mjs +159 -0
- package/skills/meeting_notes/SKILL.md +82 -0
- package/skills/meeting_notes/examples.md +41 -0
- package/skills/meeting_notes/risk.json +9 -0
- package/skills/meeting_notes/schema.json +55 -0
- package/skills/weekly_review/SKILL.md +75 -0
- package/skills/weekly_review/examples.md +39 -0
- package/skills/weekly_review/risk.json +9 -0
- package/skills/weekly_review/schema.json +55 -0
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +37 -29
- package/static/app/assets/Act-BFbpl2_Y.js +1 -0
- package/static/app/assets/AdminConsole-DaZPha9Y.js +1 -0
- package/static/app/assets/Brain-DGrV0heZ.js +321 -0
- package/static/app/assets/BrainHome-Re6Zxh_W.js +2 -0
- package/static/app/assets/BrainSignals-BQ11jDpJ.js +1 -0
- package/static/app/assets/Capture-DRaf1zLH.js +1 -0
- package/static/app/assets/CommandPalette-DAKPm4uJ.js +1 -0
- package/static/app/assets/LanguageSwitcher-BgTwHoWv.js +1 -0
- package/static/app/assets/Library-BVtrSt12.js +1 -0
- package/static/app/assets/LivingBrain-7glOmK0R.js +1 -0
- package/static/app/assets/ProductFlow-cB3EFinZ.js +1 -0
- package/static/app/assets/ReviewCard-CShhHS-w.js +3 -0
- package/static/app/assets/System-B4nQDy8a.js +1 -0
- package/static/app/assets/{bot-nB_buEZD.js → bot-DtKkLfXj.js} +1 -1
- package/static/app/assets/brain-wVV63_1z.js +1 -0
- package/static/app/assets/button-BTxZDMFJ.js +1 -0
- package/static/app/assets/{circle-pause-DLNw6Ucp.js → circle-pause--qxF9-EO.js} +1 -1
- package/static/app/assets/{circle-play-B-IsFL1y.js → circle-play-89x3S6jm.js} +1 -1
- package/static/app/assets/{cpu-CEPBHaBl.js → cpu-DTUI63vo.js} +1 -1
- package/static/app/assets/download-EJS5_O0f.js +1 -0
- package/static/app/assets/{folder-open-hmN0N9cX.js → folder-open-BL9zYTnc.js} +1 -1
- package/static/app/assets/{hard-drive-CBV_B_Yd.js → hard-drive-BFnwi1Lu.js} +1 -1
- package/static/app/assets/index-BFtDYI6l.js +10 -0
- package/static/app/assets/index-BY9bpxyx.css +2 -0
- package/static/app/assets/input-DEz5UjBz.js +1 -0
- package/static/app/assets/{network-jE42eKfT.js → network-zKa3jAwl.js} +1 -1
- package/static/app/assets/primitives-jNrZm2O9.js +1 -0
- package/static/app/assets/search-ku6-_6wY.js +1 -0
- package/static/app/assets/shield-alert-CpHeCsaF.js +1 -0
- package/static/app/assets/textarea-DpcQkn75.js +1 -0
- package/static/app/assets/{useFocusTrap-B7RPGfFy.js → useFocusTrap-58TNCONM.js} +1 -1
- package/static/app/assets/{navigation-Bot0hvuv.js → useQuery-CcVlXlBd.js} +1 -1
- package/static/app/assets/users-C0cFMj6o.js +1 -0
- package/static/app/assets/utils-DYf7pqrR.js +7 -0
- package/static/app/assets/workspace-CjthHsLN.js +1 -0
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/static/app/assets/Act-BCmTU0E2.js +0 -1
- package/static/app/assets/Brain-CTnjox7w.js +0 -321
- package/static/app/assets/BrainHome-BJ3sFNX0.js +0 -2
- package/static/app/assets/BrainSignals-C52lwZVD.js +0 -1
- package/static/app/assets/Capture-B6vBhFa3.js +0 -1
- package/static/app/assets/CommandPalette-90u9FWFH.js +0 -1
- package/static/app/assets/Library-pKCK0_tk.js +0 -1
- package/static/app/assets/LivingBrain-Jlf2wFqI.js +0 -1
- package/static/app/assets/ProductFlow-yg1fKP1P.js +0 -1
- package/static/app/assets/ReviewCard-DWvD7n9h.js +0 -3
- package/static/app/assets/System-BAEuHqNY.js +0 -1
- package/static/app/assets/index-7FAfYm4v.css +0 -2
- package/static/app/assets/index-DrmOCySv.js +0 -10
- package/static/app/assets/input-gtVCg-ll.js +0 -1
- package/static/app/assets/primitives-CX2Komon.js +0 -1
- package/static/app/assets/shield-alert-BftATuAA.js +0 -1
- package/static/app/assets/textarea-CiMJfOSI.js +0 -1
- 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"]
|
package/lattice_brain/quality.py
CHANGED
|
@@ -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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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.
|
|
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")
|
package/latticeai/__init__.py
CHANGED
|
@@ -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).
|
package/latticeai/api/chat.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
)
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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":
|
|
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
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
self.
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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
|
|
583
|
-
|
|
584
|
-
|
|
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
|
-
|
|
592
|
-
ctx.
|
|
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.
|
|
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
|
|