ltcai 9.9.3 → 9.9.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -38
- package/docs/CHANGELOG.md +100 -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 +1 -1
- package/docs/SURFACE_PARITY.md +53 -0
- 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/graph/_kg_common.py +335 -0
- package/lattice_brain/graph/discovery_index.py +8 -1
- package/lattice_brain/graph/ingest.py +43 -9
- package/lattice_brain/graph/projection.py +221 -37
- package/lattice_brain/graph/rerank.py +160 -0
- package/lattice_brain/graph/retrieval.py +90 -455
- package/lattice_brain/graph/retrieval_policy.py +174 -0
- package/lattice_brain/graph/retrieval_reads.py +459 -0
- package/lattice_brain/graph/retrieval_vector.py +122 -2
- package/lattice_brain/graph/store.py +2 -0
- package/lattice_brain/portability.py +41 -12
- 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/chat.py +6 -0
- package/latticeai/api/chat_agent_http.py +262 -52
- package/latticeai/api/chat_contracts.py +1 -1
- package/latticeai/api/chat_intents.py +37 -18
- package/latticeai/api/chat_stream.py +76 -2
- package/latticeai/api/knowledge_graph.py +35 -1
- package/latticeai/core/agent.py +420 -27
- package/latticeai/core/enterprise.py +5 -0
- package/latticeai/core/file_generation.py +130 -5
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/run_store.py +245 -0
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/integrations/telegram_bot.py +82 -23
- package/latticeai/models/router.py +25 -12
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/command_center.py +90 -1
- package/latticeai/services/folder_watch.py +5 -0
- package/latticeai/services/funnel_metrics.py +9 -1
- package/latticeai/services/product_readiness.py +1 -1
- package/latticeai/services/search_service.py +43 -13
- package/latticeai/services/tool_dispatch.py +56 -0
- package/package.json +2 -1
- package/scripts/bench_agent_smoke.py +410 -0
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/funnel_soft_gate.py +192 -0
- package/scripts/run_sidecar_e2e.mjs +159 -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 +29 -28
- package/static/app/assets/Act-DmxfbqBp.js +1 -0
- package/static/app/assets/{Brain-DEY9jLVt.js → Brain-DK_zKCda.js} +2 -2
- package/static/app/assets/BrainHome-CCE_0hni.js +2 -0
- package/static/app/assets/BrainSignals-r_J68_zh.js +1 -0
- package/static/app/assets/{Capture-CVZ09QXi.js → Capture-CvxwzpJZ.js} +1 -1
- package/static/app/assets/{CommandPalette-DepwOQFv.js → CommandPalette-CVLiFIQC.js} +1 -1
- package/static/app/assets/{Library-Bp0n-HlW.js → Library-4As9VUvy.js} +1 -1
- package/static/app/assets/{LivingBrain-DxP4efJF.js → LivingBrain-BVWk58MW.js} +1 -1
- package/static/app/assets/{ProductFlow-DRbm7NEq.js → ProductFlow-B-Tz0Um4.js} +1 -1
- package/static/app/assets/{ReviewCard-C4HAO7A3.js → ReviewCard-Va3fPQuj.js} +1 -1
- package/static/app/assets/{System-ByQcmJW-.js → System-DSR2zKWd.js} +1 -1
- package/static/app/assets/{bot-BNDyZLR7.js → bot-VmP0kkeA.js} +1 -1
- package/static/app/assets/circle-pause-T9POo4qy.js +1 -0
- package/static/app/assets/{circle-play-BkhdcHgd.js → circle-play-Bz5iTD0p.js} +1 -1
- package/static/app/assets/{cpu-C6jjYm6i.js → cpu-CJhjRtNq.js} +1 -1
- package/static/app/assets/{folder-open-DjGIvDBQ.js → folder-open-B2K_22VI.js} +1 -1
- package/static/app/assets/{hard-drive-BlSbwSaT.js → hard-drive-CjJtYqHf.js} +1 -1
- package/static/app/assets/{index-Bge3DXW7.css → index-7FAfYm4v.css} +1 -1
- package/static/app/assets/{index-CHu7cgj3.js → index-CEu0Wqjl.js} +3 -3
- package/static/app/assets/{input-DVDI0YR3.js → input-CSi6OBJ9.js} +1 -1
- package/static/app/assets/{navigation-BddhEWA0.js → navigation-BPQqEQwr.js} +1 -1
- package/static/app/assets/{network-pYQt5oBu.js → network-Ck1nqc-v.js} +1 -1
- package/static/app/assets/{primitives-D7gCdEvS.js → primitives-B2f9N7Dh.js} +1 -1
- package/static/app/assets/{shield-alert-K9RKGQeg.js → shield-alert-yA_Y8lOB.js} +1 -1
- package/static/app/assets/{textarea-sqQmoBKL.js → textarea-C4wM0D-Z.js} +1 -1
- package/static/app/assets/{useFocusTrap-7EV9dFP2.js → useFocusTrap-CsnIhsDJ.js} +1 -1
- package/static/app/assets/utils-BqoznzGP.js +7 -0
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/static/app/assets/Act-DmdruVKV.js +0 -1
- package/static/app/assets/BrainHome-CeNaxjP1.js +0 -2
- package/static/app/assets/BrainSignals-CStjIqYi.js +0 -1
- package/static/app/assets/utils-uQYKXNeq.js +0 -7
|
@@ -11,7 +11,7 @@ import threading
|
|
|
11
11
|
import time
|
|
12
12
|
from datetime import datetime, timedelta, timezone
|
|
13
13
|
from pathlib import Path
|
|
14
|
-
from typing import Any, Dict
|
|
14
|
+
from typing import Any, Dict, Optional
|
|
15
15
|
|
|
16
16
|
from fastapi import APIRouter, HTTPException, Request
|
|
17
17
|
|
|
@@ -19,6 +19,11 @@ from lattice_brain.runtime.hooks import dispatch_tool
|
|
|
19
19
|
from latticeai.api.chat_contracts import AgentEvalRequest, AgentRequest, AgentResumeRequest
|
|
20
20
|
from latticeai.api.chat_helpers import _LANG_HINT, detect_language, workspace_scope_from_request
|
|
21
21
|
from latticeai.core.agent import AgentRunContext, AgentState, normalize_plan
|
|
22
|
+
from latticeai.core.run_store import (
|
|
23
|
+
AgentRunStore,
|
|
24
|
+
hash_approval_token,
|
|
25
|
+
restore_run_context,
|
|
26
|
+
)
|
|
22
27
|
from latticeai.services.tool_dispatch import collect_artifacts, collect_created_files
|
|
23
28
|
|
|
24
29
|
|
|
@@ -42,6 +47,7 @@ class AgentHTTPController:
|
|
|
42
47
|
agent_root: Path,
|
|
43
48
|
ensure_agent_root: Any,
|
|
44
49
|
funnel_metrics: Any = None,
|
|
50
|
+
run_store: Any = None,
|
|
45
51
|
) -> None:
|
|
46
52
|
self.runtime = runtime
|
|
47
53
|
self.model_router = model_router
|
|
@@ -59,15 +65,23 @@ class AgentHTTPController:
|
|
|
59
65
|
self.agent_root = Path(agent_root)
|
|
60
66
|
self.ensure_agent_root = ensure_agent_root
|
|
61
67
|
self.funnel_metrics = funnel_metrics
|
|
62
|
-
self._pending: Dict[str, tuple] = {}
|
|
63
68
|
self._pending_lock = threading.Lock()
|
|
64
|
-
self._pending_ttl_seconds = 15 * 60
|
|
65
69
|
# awaiting_approval runs: run_id -> paused run state + approval token.
|
|
66
70
|
# Fail-closed: governed steps only ever execute after resume presents
|
|
67
71
|
# the matching, unexpired token for this run (or via the legacy
|
|
68
72
|
# explicit human-in-loop context flow above).
|
|
69
73
|
self._approvals: Dict[str, Dict[str, Any]] = {}
|
|
70
74
|
self._approval_ttl_seconds = 10 * 60
|
|
75
|
+
# Durable side of the approval loop (review Wave 0.1): paused runs are
|
|
76
|
+
# mirrored to disk so a valid token still resumes after a restart or
|
|
77
|
+
# from another worker process. In-memory stays the fast path.
|
|
78
|
+
self.run_store = run_store if run_store is not None else AgentRunStore(
|
|
79
|
+
Path(base_dir) / "data" / "agent_runs"
|
|
80
|
+
)
|
|
81
|
+
try:
|
|
82
|
+
self.run_store.sweep_expired()
|
|
83
|
+
except Exception as exc: # noqa: BLE001 — hygiene must not break startup
|
|
84
|
+
logging.warning("agent run store sweep failed: %s", exc)
|
|
71
85
|
self._background_tasks: set[asyncio.Task] = set()
|
|
72
86
|
|
|
73
87
|
def register_routes(self, router: APIRouter) -> None:
|
|
@@ -83,6 +97,10 @@ class AgentHTTPController:
|
|
|
83
97
|
async def agent_resume(req: AgentResumeRequest, request: Request):
|
|
84
98
|
return await self.resume(req, request)
|
|
85
99
|
|
|
100
|
+
@router.get("/agent/approvals")
|
|
101
|
+
async def agent_approvals(request: Request):
|
|
102
|
+
return self.pending_approvals(request)
|
|
103
|
+
|
|
86
104
|
def _schedule_background_task(self, coro: Any) -> None:
|
|
87
105
|
task = asyncio.create_task(coro)
|
|
88
106
|
self._background_tasks.add(task)
|
|
@@ -180,8 +198,17 @@ class AgentHTTPController:
|
|
|
180
198
|
"results": results,
|
|
181
199
|
}
|
|
182
200
|
|
|
183
|
-
async def agent(
|
|
184
|
-
|
|
201
|
+
async def agent(
|
|
202
|
+
self,
|
|
203
|
+
req: AgentRequest,
|
|
204
|
+
request: Request,
|
|
205
|
+
on_step: Any = None,
|
|
206
|
+
) -> Dict[str, Any]:
|
|
207
|
+
"""Plan and execute a natural-language local agent run.
|
|
208
|
+
|
|
209
|
+
``on_step`` (optional) is a per-run step observer — the live SSE
|
|
210
|
+
route attaches one so the client sees progress while EXECUTING.
|
|
211
|
+
"""
|
|
185
212
|
current_user = self.require_user(request)
|
|
186
213
|
self.enforce_rate_limit(current_user, "agent")
|
|
187
214
|
effective_email = self.authenticated_identity(current_user, req.user_email)
|
|
@@ -207,6 +234,8 @@ class AgentHTTPController:
|
|
|
207
234
|
max_steps = max(1, min(req.max_steps, 50))
|
|
208
235
|
max_retry = 3
|
|
209
236
|
ctx = AgentRunContext()
|
|
237
|
+
if on_step is not None:
|
|
238
|
+
ctx.on_step = on_step
|
|
210
239
|
ctx.executing_model = req.executing_model
|
|
211
240
|
ctx.reviewing_model = req.reviewing_model
|
|
212
241
|
ctx.state = AgentState.PLANNING
|
|
@@ -219,33 +248,29 @@ class AgentHTTPController:
|
|
|
219
248
|
model_id=req.planning_model,
|
|
220
249
|
)
|
|
221
250
|
|
|
251
|
+
requirements_probe = getattr(self.runtime, "approval_requirements", None)
|
|
252
|
+
|
|
222
253
|
if req.human_in_loop:
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
)
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
"planning_model": req.planning_model or self.model_router.current_model_id,
|
|
239
|
-
"executing_model": req.executing_model or self.model_router.current_model_id,
|
|
240
|
-
"reviewing_model": req.reviewing_model or self.model_router.current_model_id,
|
|
241
|
-
"loop": ctx.trace.summary(),
|
|
242
|
-
}
|
|
254
|
+
# Deprecated explicit pause (L1 unification, 9.9.5): the legacy
|
|
255
|
+
# ``human_in_loop`` flow now rides the same durable approval
|
|
256
|
+
# store as ``awaiting_approval`` — one pause path, one resume
|
|
257
|
+
# path, restart-safe. The legacy wire contract is preserved:
|
|
258
|
+
# ``status="waiting_approval"`` + ``context_id`` (token-less,
|
|
259
|
+
# same-user resume).
|
|
260
|
+
requirements = (
|
|
261
|
+
requirements_probe(ctx)
|
|
262
|
+
if callable(requirements_probe)
|
|
263
|
+
else {"requires_approval": True, "non_auto_steps": [], "plan_summary": ""}
|
|
264
|
+
)
|
|
265
|
+
return self._pause_for_approval(
|
|
266
|
+
ctx, req, language_hint, current_user, requirements,
|
|
267
|
+
legacy_context=True,
|
|
268
|
+
)
|
|
243
269
|
|
|
244
270
|
# Interactive approval: when the plan needs human approval, pause the
|
|
245
271
|
# run as awaiting_approval (short-TTL token) instead of failing it.
|
|
246
272
|
# ``getattr`` keeps injected fake runtimes without the preview method
|
|
247
273
|
# on the historical fail-closed path.
|
|
248
|
-
requirements_probe = getattr(self.runtime, "approval_requirements", None)
|
|
249
274
|
if callable(requirements_probe):
|
|
250
275
|
requirements = requirements_probe(ctx)
|
|
251
276
|
if requirements.get("requires_approval"):
|
|
@@ -270,8 +295,16 @@ class AgentHTTPController:
|
|
|
270
295
|
language_hint: str,
|
|
271
296
|
current_user: str,
|
|
272
297
|
requirements: Dict[str, Any],
|
|
298
|
+
*,
|
|
299
|
+
legacy_context: bool = False,
|
|
273
300
|
) -> Dict[str, Any]:
|
|
274
|
-
"""Park a plan that needs approval and hand the user a resume token.
|
|
301
|
+
"""Park a plan that needs approval and hand the user a resume token.
|
|
302
|
+
|
|
303
|
+
``legacy_context=True`` marks a deprecated ``human_in_loop`` pause:
|
|
304
|
+
same durable storage, but the response speaks the historical
|
|
305
|
+
``waiting_approval``/``context_id`` contract and resume may present
|
|
306
|
+
the ``context_id`` instead of the approval token (same user only).
|
|
307
|
+
"""
|
|
275
308
|
run_id = secrets.token_urlsafe(16)
|
|
276
309
|
approval_token = secrets.token_urlsafe(32)
|
|
277
310
|
now_monotonic = time.monotonic()
|
|
@@ -288,14 +321,36 @@ class AgentHTTPController:
|
|
|
288
321
|
"token": approval_token,
|
|
289
322
|
"expires_monotonic": now_monotonic + self._approval_ttl_seconds,
|
|
290
323
|
"expires_at": expires_at,
|
|
324
|
+
"legacy_context": legacy_context,
|
|
291
325
|
}
|
|
326
|
+
# Durable mirror: a restart between pause and resume must not orphan
|
|
327
|
+
# the run. Best-effort — the in-memory pause still answers on failure.
|
|
328
|
+
try:
|
|
329
|
+
self.run_store.save(
|
|
330
|
+
run_id,
|
|
331
|
+
ctx=ctx,
|
|
332
|
+
req_payload=req.model_dump(),
|
|
333
|
+
language_hint=language_hint,
|
|
334
|
+
user=current_user,
|
|
335
|
+
token=approval_token,
|
|
336
|
+
expires_epoch=time.time() + self._approval_ttl_seconds,
|
|
337
|
+
expires_at=expires_at,
|
|
338
|
+
legacy_context=legacy_context,
|
|
339
|
+
)
|
|
340
|
+
except Exception as exc: # noqa: BLE001
|
|
341
|
+
logging.warning("agent run store persist failed: %s", exc)
|
|
342
|
+
if self.funnel_metrics is not None:
|
|
343
|
+
try:
|
|
344
|
+
self.funnel_metrics.increment("approval_pauses")
|
|
345
|
+
except Exception as exc: # noqa: BLE001 — advisory only
|
|
346
|
+
logging.warning("funnel metrics increment failed: %s", exc)
|
|
292
347
|
ctx.state_history.append(AgentState.WAITING_APPROVAL.value)
|
|
293
348
|
message = (
|
|
294
349
|
"이 작업에는 승인이 필요한 단계가 있어 실행을 잠시 멈췄습니다. "
|
|
295
350
|
"계획을 확인한 뒤 승인하면 이어서 실행합니다."
|
|
296
351
|
)
|
|
297
|
-
|
|
298
|
-
"status": "awaiting_approval",
|
|
352
|
+
payload = {
|
|
353
|
+
"status": "waiting_approval" if legacy_context else "awaiting_approval",
|
|
299
354
|
"run_id": run_id,
|
|
300
355
|
"approval": {
|
|
301
356
|
"token": approval_token,
|
|
@@ -313,11 +368,111 @@ class AgentHTTPController:
|
|
|
313
368
|
"reviewing_model": req.reviewing_model or self.model_router.current_model_id,
|
|
314
369
|
"loop": ctx.trace.summary(),
|
|
315
370
|
}
|
|
371
|
+
if legacy_context:
|
|
372
|
+
# Historical wire field — the run id doubles as the context id.
|
|
373
|
+
payload["context_id"] = run_id
|
|
374
|
+
return payload
|
|
316
375
|
|
|
317
376
|
def _purge_expired_approvals_locked(self, now_monotonic: float) -> None:
|
|
318
377
|
for run_id, entry in list(self._approvals.items()):
|
|
319
378
|
if now_monotonic >= entry["expires_monotonic"]:
|
|
320
379
|
self._approvals.pop(run_id, None)
|
|
380
|
+
try:
|
|
381
|
+
self.run_store.delete(run_id)
|
|
382
|
+
except Exception: # noqa: BLE001 — hygiene only
|
|
383
|
+
pass
|
|
384
|
+
|
|
385
|
+
def pending_approvals(self, request: Request) -> Dict[str, Any]:
|
|
386
|
+
"""Unexpired paused runs for the current user (memory ∪ disk).
|
|
387
|
+
|
|
388
|
+
Lets the UI re-surface an approval card after a reload/restart instead
|
|
389
|
+
of the run silently vanishing.
|
|
390
|
+
"""
|
|
391
|
+
current_user = self.require_user(request)
|
|
392
|
+
now_monotonic = time.monotonic()
|
|
393
|
+
pending: Dict[str, Dict[str, Any]] = {}
|
|
394
|
+
with self._pending_lock:
|
|
395
|
+
for run_id, entry in self._approvals.items():
|
|
396
|
+
if entry["user"] != current_user:
|
|
397
|
+
continue
|
|
398
|
+
if now_monotonic >= entry["expires_monotonic"]:
|
|
399
|
+
continue
|
|
400
|
+
plan = getattr(entry["ctx"], "plan", {}) or {}
|
|
401
|
+
pending[run_id] = {
|
|
402
|
+
"run_id": run_id,
|
|
403
|
+
"goal": str(plan.get("goal") or "")[:200],
|
|
404
|
+
"expires_at": entry["expires_at"],
|
|
405
|
+
}
|
|
406
|
+
try:
|
|
407
|
+
for summary in self.run_store.pending_summaries(current_user):
|
|
408
|
+
run_id = summary.get("run_id")
|
|
409
|
+
if run_id and run_id not in pending:
|
|
410
|
+
pending[run_id] = {
|
|
411
|
+
"run_id": run_id,
|
|
412
|
+
"goal": summary.get("goal") or "",
|
|
413
|
+
"expires_at": summary.get("expires_at"),
|
|
414
|
+
}
|
|
415
|
+
except Exception as exc: # noqa: BLE001 — disk listing is best-effort
|
|
416
|
+
logging.warning("agent run store listing failed: %s", exc)
|
|
417
|
+
return {"pending": sorted(pending.values(), key=lambda p: p["run_id"])}
|
|
418
|
+
|
|
419
|
+
def _restore_persisted_approval(self, run_id: str) -> Optional[Dict[str, Any]]:
|
|
420
|
+
"""Rebuild an approval entry from disk after a restart.
|
|
421
|
+
|
|
422
|
+
Returns an entry shaped like the in-memory ones but carrying
|
|
423
|
+
``token_hash`` instead of the plaintext token. Expired records are
|
|
424
|
+
deleted and reported via the same 410 contract as the memory path.
|
|
425
|
+
"""
|
|
426
|
+
try:
|
|
427
|
+
record = self.run_store.load(run_id)
|
|
428
|
+
except Exception as exc: # noqa: BLE001 — load error == not found
|
|
429
|
+
logging.warning("agent run store load failed: %s", exc)
|
|
430
|
+
return None
|
|
431
|
+
if record is None:
|
|
432
|
+
return None
|
|
433
|
+
req_payload = record.get("req") or {}
|
|
434
|
+
if time.time() >= float(record.get("expires_epoch") or 0):
|
|
435
|
+
try:
|
|
436
|
+
self.run_store.delete(run_id)
|
|
437
|
+
except Exception: # noqa: BLE001
|
|
438
|
+
pass
|
|
439
|
+
raise HTTPException(
|
|
440
|
+
status_code=410,
|
|
441
|
+
detail={
|
|
442
|
+
"error": "approval_expired",
|
|
443
|
+
"message": "Approval token expired. Start a new request.",
|
|
444
|
+
"replan": {"message": str(req_payload.get("message") or "")},
|
|
445
|
+
},
|
|
446
|
+
)
|
|
447
|
+
try:
|
|
448
|
+
restored_req = AgentRequest(**req_payload)
|
|
449
|
+
except Exception as exc: # noqa: BLE001 — unreconstructable == not found
|
|
450
|
+
logging.warning("agent run store request restore failed: %s", exc)
|
|
451
|
+
return None
|
|
452
|
+
remaining = max(1.0, float(record["expires_epoch"]) - time.time())
|
|
453
|
+
return {
|
|
454
|
+
"ctx": restore_run_context(record.get("ctx") or {}),
|
|
455
|
+
"req": restored_req,
|
|
456
|
+
"language_hint": str(record.get("language_hint") or "English"),
|
|
457
|
+
"user": record.get("user"),
|
|
458
|
+
"token_hash": str(record.get("token_hash") or ""),
|
|
459
|
+
"expires_monotonic": time.monotonic() + remaining,
|
|
460
|
+
"expires_at": record.get("expires_at"),
|
|
461
|
+
"legacy_context": bool(record.get("legacy_context")),
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
@staticmethod
|
|
465
|
+
def _token_matches(entry: Dict[str, Any], supplied: str) -> bool:
|
|
466
|
+
"""Constant-time token check for both plaintext and hashed entries."""
|
|
467
|
+
if not supplied:
|
|
468
|
+
return False
|
|
469
|
+
stored = entry.get("token")
|
|
470
|
+
if stored is not None:
|
|
471
|
+
return secrets.compare_digest(str(stored), supplied)
|
|
472
|
+
stored_hash = str(entry.get("token_hash") or "")
|
|
473
|
+
return bool(stored_hash) and secrets.compare_digest(
|
|
474
|
+
stored_hash, hash_approval_token(supplied)
|
|
475
|
+
)
|
|
321
476
|
|
|
322
477
|
async def _finish(
|
|
323
478
|
self,
|
|
@@ -419,31 +574,63 @@ class AgentHTTPController:
|
|
|
419
574
|
status_code=400,
|
|
420
575
|
detail="run_id (with approval_token) or context_id is required.",
|
|
421
576
|
)
|
|
577
|
+
# Deprecated context_id resume (L1 unification, 9.9.5): the legacy
|
|
578
|
+
# pause lives in the same durable approval store now. Token-less
|
|
579
|
+
# resume stays allowed for it — bound to the pausing user, and only
|
|
580
|
+
# for entries that were created through the legacy pause.
|
|
581
|
+
now_monotonic = time.monotonic()
|
|
422
582
|
with self._pending_lock:
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
self.
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
583
|
+
entry = self._approvals.get(req.context_id)
|
|
584
|
+
if entry is None:
|
|
585
|
+
try:
|
|
586
|
+
entry = self._restore_persisted_approval(req.context_id)
|
|
587
|
+
except HTTPException as exc:
|
|
588
|
+
if exc.status_code == 410:
|
|
589
|
+
# Legacy contract never had a distinct expiry answer.
|
|
590
|
+
raise HTTPException(
|
|
591
|
+
status_code=404,
|
|
592
|
+
detail="Agent context not found or expired. Start a new request.",
|
|
593
|
+
) from exc
|
|
594
|
+
raise
|
|
595
|
+
if entry is None or not entry.get("legacy_context"):
|
|
596
|
+
raise HTTPException(
|
|
597
|
+
status_code=404,
|
|
598
|
+
detail="Agent context not found or expired. Start a new request.",
|
|
599
|
+
)
|
|
600
|
+
if entry["user"] != current_user:
|
|
601
|
+
raise HTTPException(
|
|
602
|
+
status_code=403,
|
|
603
|
+
detail="Agent context belongs to another user.",
|
|
604
|
+
)
|
|
605
|
+
if now_monotonic >= entry["expires_monotonic"]:
|
|
606
|
+
self._approvals.pop(req.context_id, None)
|
|
607
|
+
try:
|
|
608
|
+
self.run_store.delete(req.context_id)
|
|
609
|
+
except Exception: # noqa: BLE001
|
|
610
|
+
pass
|
|
611
|
+
raise HTTPException(
|
|
612
|
+
status_code=404,
|
|
613
|
+
detail="Agent context not found or expired. Start a new request.",
|
|
614
|
+
)
|
|
615
|
+
self._approvals.pop(req.context_id, None)
|
|
616
|
+
try:
|
|
617
|
+
self.run_store.delete(req.context_id)
|
|
618
|
+
except Exception as exc: # noqa: BLE001
|
|
619
|
+
logging.warning("agent run store delete failed: %s", exc)
|
|
435
620
|
|
|
436
|
-
ctx
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
status_code=403,
|
|
440
|
-
detail="Agent context belongs to another user.",
|
|
441
|
-
)
|
|
621
|
+
ctx = entry["ctx"]
|
|
622
|
+
original_request = entry["req"]
|
|
623
|
+
language_hint = entry["language_hint"]
|
|
442
624
|
if not req.approved:
|
|
443
625
|
return {"status": "cancelled", "response": "사용자가 계획을 취소했습니다."}
|
|
444
626
|
if req.modified_plan:
|
|
445
|
-
|
|
446
|
-
ctx.
|
|
627
|
+
plan, plan_fixes = normalize_plan(req.modified_plan, original_request.message)
|
|
628
|
+
ctx.plan = plan
|
|
629
|
+
ctx.transcript.append({
|
|
630
|
+
"state": AgentState.WAITING_APPROVAL.value,
|
|
631
|
+
"edited_plan": True,
|
|
632
|
+
**({"plan_fixes": plan_fixes} if plan_fixes else {}),
|
|
633
|
+
})
|
|
447
634
|
ctx.executing_model = req.executing_model or ctx.executing_model
|
|
448
635
|
ctx.reviewing_model = req.reviewing_model or ctx.reviewing_model
|
|
449
636
|
# The authenticated owner of this pending context explicitly approved
|
|
@@ -468,6 +655,10 @@ class AgentHTTPController:
|
|
|
468
655
|
now_monotonic = time.monotonic()
|
|
469
656
|
with self._pending_lock:
|
|
470
657
|
entry = self._approvals.get(req.run_id or "")
|
|
658
|
+
if entry is None:
|
|
659
|
+
# Restart survival (review Wave 0.1): the in-memory dict is
|
|
660
|
+
# gone but the durable record may still be valid.
|
|
661
|
+
entry = self._restore_persisted_approval(req.run_id or "")
|
|
471
662
|
if entry is None:
|
|
472
663
|
raise HTTPException(
|
|
473
664
|
status_code=404,
|
|
@@ -480,24 +671,43 @@ class AgentHTTPController:
|
|
|
480
671
|
)
|
|
481
672
|
if now_monotonic >= entry["expires_monotonic"]:
|
|
482
673
|
self._approvals.pop(req.run_id, None)
|
|
674
|
+
try:
|
|
675
|
+
self.run_store.delete(req.run_id or "")
|
|
676
|
+
except Exception: # noqa: BLE001
|
|
677
|
+
pass
|
|
483
678
|
raise HTTPException(
|
|
484
679
|
status_code=410,
|
|
485
|
-
detail=
|
|
680
|
+
detail={
|
|
681
|
+
"error": "approval_expired",
|
|
682
|
+
"message": "Approval token expired. Start a new request.",
|
|
683
|
+
"replan": {"message": getattr(entry.get("req"), "message", "")},
|
|
684
|
+
},
|
|
486
685
|
)
|
|
487
|
-
|
|
488
|
-
if not supplied or not secrets.compare_digest(entry["token"], supplied):
|
|
686
|
+
if not self._token_matches(entry, req.approval_token or ""):
|
|
489
687
|
raise HTTPException(
|
|
490
688
|
status_code=403,
|
|
491
689
|
detail="Invalid approval token for this run.",
|
|
492
690
|
)
|
|
493
691
|
# Token validated — the pending run is consumed either way.
|
|
494
692
|
self._approvals.pop(req.run_id, None)
|
|
693
|
+
try:
|
|
694
|
+
self.run_store.delete(req.run_id or "")
|
|
695
|
+
except Exception as exc: # noqa: BLE001 — consumption must proceed
|
|
696
|
+
logging.warning("agent run store delete failed: %s", exc)
|
|
495
697
|
self._purge_expired_approvals_locked(now_monotonic)
|
|
496
698
|
|
|
497
699
|
ctx: AgentRunContext = entry["ctx"]
|
|
498
700
|
original_request: AgentRequest = entry["req"]
|
|
499
701
|
language_hint: str = entry["language_hint"]
|
|
500
702
|
|
|
703
|
+
if self.funnel_metrics is not None:
|
|
704
|
+
# approval_resume_rate = resumes / pauses (review §4.3): counted on
|
|
705
|
+
# every token-valid resume decision, approve and deny alike.
|
|
706
|
+
try:
|
|
707
|
+
self.funnel_metrics.increment("approval_resumes")
|
|
708
|
+
except Exception as exc: # noqa: BLE001 — advisory only
|
|
709
|
+
logging.warning("funnel metrics increment failed: %s", exc)
|
|
710
|
+
|
|
501
711
|
approved = req.approve if req.approve is not None else req.approved
|
|
502
712
|
if not approved:
|
|
503
713
|
message = "사용자가 계획을 취소했습니다."
|
|
@@ -45,7 +45,7 @@ class AgentResumeRequest(BaseModel):
|
|
|
45
45
|
modified_plan: Optional[dict] = None
|
|
46
46
|
executing_model: Optional[str] = None
|
|
47
47
|
reviewing_model: Optional[str] = None
|
|
48
|
-
# awaiting_approval flow (v9.
|
|
48
|
+
# awaiting_approval flow (v9.9.x): run_id + short-TTL approval token.
|
|
49
49
|
run_id: Optional[str] = None
|
|
50
50
|
approval_token: Optional[str] = None
|
|
51
51
|
approve: Optional[bool] = None
|
|
@@ -17,7 +17,11 @@ from latticeai.api.chat_helpers import (
|
|
|
17
17
|
format_network_status,
|
|
18
18
|
inline_file_action_content,
|
|
19
19
|
)
|
|
20
|
-
from latticeai.api.chat_stream import
|
|
20
|
+
from latticeai.api.chat_stream import (
|
|
21
|
+
agent_live_stream,
|
|
22
|
+
agent_payload_stream,
|
|
23
|
+
single_answer_response,
|
|
24
|
+
)
|
|
21
25
|
from latticeai.core.agent import AgentState
|
|
22
26
|
from latticeai.core.file_generation import (
|
|
23
27
|
PREVIEWABLE_EXTENSIONS,
|
|
@@ -596,31 +600,46 @@ class ChatIntentController:
|
|
|
596
600
|
user_nickname=req.user_nickname,
|
|
597
601
|
workspace_id=workspace_id,
|
|
598
602
|
)
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
603
|
+
|
|
604
|
+
def finalize(result: Dict) -> str:
|
|
605
|
+
"""History/funnel side effects once the run has a terminal payload."""
|
|
606
|
+
answer = str(result.get("response") or "파일 작업을 처리했습니다.")
|
|
607
|
+
# UX funnel (backlog #16): a file-intent request that the agent loop
|
|
608
|
+
# finished without producing a single artifact is the "code-only"
|
|
609
|
+
# failure mode the >95% real-file goal watches. Paused/failed runs
|
|
610
|
+
# count neither way — they did not answer with code instead of files.
|
|
611
|
+
delivered = bool(result.get("created_files") or result.get("artifacts"))
|
|
612
|
+
if delivered:
|
|
613
|
+
self._funnel_increment("real_file_delivered")
|
|
614
|
+
elif str(result.get("status") or "") == "ok":
|
|
615
|
+
self._funnel_increment("code_only_responses")
|
|
616
|
+
self.notify("user", req.message, req.source)
|
|
617
|
+
self.notify("assistant", answer, req.source)
|
|
618
|
+
result["routed_to_agent"] = True
|
|
619
|
+
return answer
|
|
620
|
+
|
|
613
621
|
if req.stream:
|
|
622
|
+
# Live loop visibility (review Wave 1.1): run the agent inside the
|
|
623
|
+
# SSE generator so step events stream while EXECUTING; the final
|
|
624
|
+
# frames keep the exact historical payload shape.
|
|
625
|
+
async def start(observer):
|
|
626
|
+
return await self.agent_controller.agent(
|
|
627
|
+
agent_request, request, on_step=observer
|
|
628
|
+
)
|
|
629
|
+
|
|
614
630
|
return StreamingResponse(
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
result,
|
|
631
|
+
agent_live_stream(
|
|
632
|
+
start,
|
|
618
633
|
router=self.router,
|
|
619
634
|
model_id=model_id,
|
|
635
|
+
finalize=finalize,
|
|
620
636
|
),
|
|
621
637
|
media_type="text/event-stream",
|
|
622
638
|
headers={"X-Model": model_id or "agent", "X-Routed-To": "agent"},
|
|
623
639
|
)
|
|
640
|
+
|
|
641
|
+
result = await self.agent_controller.agent(agent_request, request)
|
|
642
|
+
finalize(result)
|
|
624
643
|
return JSONResponse(content=result)
|
|
625
644
|
|
|
626
645
|
|
|
@@ -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__ = [
|
|
208
|
+
__all__ = [
|
|
209
|
+
"agent_live_stream",
|
|
210
|
+
"agent_payload_stream",
|
|
211
|
+
"single_answer_response",
|
|
212
|
+
"stream_chat",
|
|
213
|
+
]
|