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.
Files changed (89) hide show
  1. package/README.md +39 -38
  2. package/docs/CHANGELOG.md +100 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/ONBOARDING.md +1 -1
  6. package/docs/OPERATIONS.md +1 -1
  7. package/docs/SURFACE_PARITY.md +53 -0
  8. package/docs/TRUST_MODEL.md +1 -1
  9. package/docs/WHY_LATTICE.md +1 -1
  10. package/docs/kg-schema.md +1 -1
  11. package/lattice_brain/__init__.py +1 -1
  12. package/lattice_brain/graph/_kg_common.py +335 -0
  13. package/lattice_brain/graph/discovery_index.py +8 -1
  14. package/lattice_brain/graph/ingest.py +43 -9
  15. package/lattice_brain/graph/projection.py +221 -37
  16. package/lattice_brain/graph/rerank.py +160 -0
  17. package/lattice_brain/graph/retrieval.py +90 -455
  18. package/lattice_brain/graph/retrieval_policy.py +174 -0
  19. package/lattice_brain/graph/retrieval_reads.py +459 -0
  20. package/lattice_brain/graph/retrieval_vector.py +122 -2
  21. package/lattice_brain/graph/store.py +2 -0
  22. package/lattice_brain/portability.py +41 -12
  23. package/lattice_brain/quality.py +26 -6
  24. package/lattice_brain/runtime/multi_agent.py +1 -1
  25. package/latticeai/__init__.py +1 -1
  26. package/latticeai/api/chat.py +6 -0
  27. package/latticeai/api/chat_agent_http.py +262 -52
  28. package/latticeai/api/chat_contracts.py +1 -1
  29. package/latticeai/api/chat_intents.py +37 -18
  30. package/latticeai/api/chat_stream.py +76 -2
  31. package/latticeai/api/knowledge_graph.py +35 -1
  32. package/latticeai/core/agent.py +420 -27
  33. package/latticeai/core/enterprise.py +5 -0
  34. package/latticeai/core/file_generation.py +130 -5
  35. package/latticeai/core/legacy_compatibility.py +1 -1
  36. package/latticeai/core/marketplace.py +1 -1
  37. package/latticeai/core/run_store.py +245 -0
  38. package/latticeai/core/workspace_os.py +1 -1
  39. package/latticeai/integrations/telegram_bot.py +82 -23
  40. package/latticeai/models/router.py +25 -12
  41. package/latticeai/services/architecture_readiness.py +1 -1
  42. package/latticeai/services/command_center.py +90 -1
  43. package/latticeai/services/folder_watch.py +5 -0
  44. package/latticeai/services/funnel_metrics.py +9 -1
  45. package/latticeai/services/product_readiness.py +1 -1
  46. package/latticeai/services/search_service.py +43 -13
  47. package/latticeai/services/tool_dispatch.py +56 -0
  48. package/package.json +2 -1
  49. package/scripts/bench_agent_smoke.py +410 -0
  50. package/scripts/check_current_release_docs.mjs +1 -1
  51. package/scripts/funnel_soft_gate.py +192 -0
  52. package/scripts/run_sidecar_e2e.mjs +159 -0
  53. package/src-tauri/Cargo.lock +1 -1
  54. package/src-tauri/Cargo.toml +1 -1
  55. package/src-tauri/tauri.conf.json +1 -1
  56. package/static/app/asset-manifest.json +29 -28
  57. package/static/app/assets/Act-DmxfbqBp.js +1 -0
  58. package/static/app/assets/{Brain-DEY9jLVt.js → Brain-DK_zKCda.js} +2 -2
  59. package/static/app/assets/BrainHome-CCE_0hni.js +2 -0
  60. package/static/app/assets/BrainSignals-r_J68_zh.js +1 -0
  61. package/static/app/assets/{Capture-CVZ09QXi.js → Capture-CvxwzpJZ.js} +1 -1
  62. package/static/app/assets/{CommandPalette-DepwOQFv.js → CommandPalette-CVLiFIQC.js} +1 -1
  63. package/static/app/assets/{Library-Bp0n-HlW.js → Library-4As9VUvy.js} +1 -1
  64. package/static/app/assets/{LivingBrain-DxP4efJF.js → LivingBrain-BVWk58MW.js} +1 -1
  65. package/static/app/assets/{ProductFlow-DRbm7NEq.js → ProductFlow-B-Tz0Um4.js} +1 -1
  66. package/static/app/assets/{ReviewCard-C4HAO7A3.js → ReviewCard-Va3fPQuj.js} +1 -1
  67. package/static/app/assets/{System-ByQcmJW-.js → System-DSR2zKWd.js} +1 -1
  68. package/static/app/assets/{bot-BNDyZLR7.js → bot-VmP0kkeA.js} +1 -1
  69. package/static/app/assets/circle-pause-T9POo4qy.js +1 -0
  70. package/static/app/assets/{circle-play-BkhdcHgd.js → circle-play-Bz5iTD0p.js} +1 -1
  71. package/static/app/assets/{cpu-C6jjYm6i.js → cpu-CJhjRtNq.js} +1 -1
  72. package/static/app/assets/{folder-open-DjGIvDBQ.js → folder-open-B2K_22VI.js} +1 -1
  73. package/static/app/assets/{hard-drive-BlSbwSaT.js → hard-drive-CjJtYqHf.js} +1 -1
  74. package/static/app/assets/{index-Bge3DXW7.css → index-7FAfYm4v.css} +1 -1
  75. package/static/app/assets/{index-CHu7cgj3.js → index-CEu0Wqjl.js} +3 -3
  76. package/static/app/assets/{input-DVDI0YR3.js → input-CSi6OBJ9.js} +1 -1
  77. package/static/app/assets/{navigation-BddhEWA0.js → navigation-BPQqEQwr.js} +1 -1
  78. package/static/app/assets/{network-pYQt5oBu.js → network-Ck1nqc-v.js} +1 -1
  79. package/static/app/assets/{primitives-D7gCdEvS.js → primitives-B2f9N7Dh.js} +1 -1
  80. package/static/app/assets/{shield-alert-K9RKGQeg.js → shield-alert-yA_Y8lOB.js} +1 -1
  81. package/static/app/assets/{textarea-sqQmoBKL.js → textarea-C4wM0D-Z.js} +1 -1
  82. package/static/app/assets/{useFocusTrap-7EV9dFP2.js → useFocusTrap-CsnIhsDJ.js} +1 -1
  83. package/static/app/assets/utils-BqoznzGP.js +7 -0
  84. package/static/app/index.html +4 -4
  85. package/static/sw.js +1 -1
  86. package/static/app/assets/Act-DmdruVKV.js +0 -1
  87. package/static/app/assets/BrainHome-CeNaxjP1.js +0 -2
  88. package/static/app/assets/BrainSignals-CStjIqYi.js +0 -1
  89. 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(self, req: AgentRequest, request: Request) -> Dict[str, Any]:
184
- """Plan and execute a natural-language local agent run."""
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
- context_id = secrets.token_urlsafe(16)
224
- with self._pending_lock:
225
- self._pending[context_id] = (
226
- ctx,
227
- req,
228
- language_hint,
229
- current_user,
230
- time.monotonic(),
231
- )
232
- return {
233
- "status": "waiting_approval",
234
- "context_id": context_id,
235
- "plan": ctx.plan,
236
- "steps": ctx.transcript,
237
- "state_history": ctx.state_history,
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
- return {
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
- now = time.monotonic()
424
- for context_id, pending in list(self._pending.items()):
425
- if now - pending[4] >= self._pending_ttl_seconds:
426
- self._pending.pop(context_id, None)
427
- entry = self._pending.get(req.context_id)
428
- if entry and entry[3] == current_user:
429
- self._pending.pop(req.context_id, None)
430
- if not entry:
431
- raise HTTPException(
432
- status_code=404,
433
- detail="Agent context not found or expired. Start a new request.",
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, original_request, language_hint, original_user, _created_at = entry
437
- if original_user != current_user:
438
- raise HTTPException(
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
- ctx.plan = req.modified_plan
446
- ctx.transcript[-1].update(ctx.plan)
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="Approval token expired. Start a new request.",
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
- supplied = req.approval_token or ""
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.10): run_id + short-TTL approval token.
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 agent_payload_stream, single_answer_response
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
- result = await self.agent_controller.agent(agent_request, request)
600
- answer = str(result.get("response") or "파일 작업을 처리했습니다.")
601
- # UX funnel (backlog #16): a file-intent request that the agent loop
602
- # finished without producing a single artifact is the "code-only"
603
- # failure mode the >95% real-file goal watches. Paused/failed runs
604
- # count neither way they did not answer with code instead of files.
605
- delivered = bool(result.get("created_files") or result.get("artifacts"))
606
- if delivered:
607
- self._funnel_increment("real_file_delivered")
608
- elif str(result.get("status") or "") == "ok":
609
- self._funnel_increment("code_only_responses")
610
- self.notify("user", req.message, req.source)
611
- self.notify("assistant", answer, req.source)
612
- result["routed_to_agent"] = True
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
- agent_payload_stream(
616
- answer,
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__ = ["agent_payload_stream", "single_answer_response", "stream_chat"]
208
+ __all__ = [
209
+ "agent_live_stream",
210
+ "agent_payload_stream",
211
+ "single_answer_response",
212
+ "stream_chat",
213
+ ]