ltcai 9.9.2 → 9.9.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/README.md +44 -39
  2. package/docs/CHANGELOG.md +109 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/ONBOARDING.md +1 -1
  6. package/docs/OPERATIONS.md +1 -1
  7. package/docs/SURFACE_PARITY.md +47 -0
  8. package/docs/TRUST_MODEL.md +1 -1
  9. package/docs/WHY_LATTICE.md +1 -1
  10. package/docs/kg-schema.md +1 -1
  11. package/lattice_brain/__init__.py +1 -1
  12. package/lattice_brain/graph/_kg_common.py +335 -0
  13. package/lattice_brain/graph/curator.py +141 -0
  14. package/lattice_brain/graph/discovery_index.py +8 -1
  15. package/lattice_brain/graph/documents.py +111 -0
  16. package/lattice_brain/graph/fusion.py +179 -0
  17. package/lattice_brain/graph/ingest.py +43 -9
  18. package/lattice_brain/graph/projection.py +382 -37
  19. package/lattice_brain/graph/retrieval.py +71 -4
  20. package/lattice_brain/graph/retrieval_policy.py +174 -0
  21. package/lattice_brain/graph/retrieval_vector.py +122 -2
  22. package/lattice_brain/ingestion.py +66 -0
  23. package/lattice_brain/portability.py +41 -12
  24. package/lattice_brain/runtime/multi_agent.py +1 -1
  25. package/latticeai/__init__.py +1 -1
  26. package/latticeai/api/automation_intelligence.py +159 -0
  27. package/latticeai/api/browser.py +15 -4
  28. package/latticeai/api/chat.py +33 -2
  29. package/latticeai/api/chat_agent_http.py +363 -6
  30. package/latticeai/api/chat_contracts.py +7 -1
  31. package/latticeai/api/chat_helpers.py +147 -0
  32. package/latticeai/api/chat_intents.py +217 -10
  33. package/latticeai/api/chat_stream.py +93 -3
  34. package/latticeai/api/funnel_metrics.py +31 -0
  35. package/latticeai/api/knowledge_graph.py +65 -1
  36. package/latticeai/api/local_files.py +90 -0
  37. package/latticeai/api/setup.py +134 -1
  38. package/latticeai/api/tools.py +33 -3
  39. package/latticeai/app_factory.py +14 -0
  40. package/latticeai/core/agent.py +355 -18
  41. package/latticeai/core/agent_eval.py +107 -0
  42. package/latticeai/core/enterprise.py +5 -0
  43. package/latticeai/core/file_generation.py +404 -2
  44. package/latticeai/core/legacy_compatibility.py +1 -1
  45. package/latticeai/core/marketplace.py +1 -1
  46. package/latticeai/core/run_store.py +243 -0
  47. package/latticeai/core/workspace_os.py +1 -1
  48. package/latticeai/models/router.py +25 -12
  49. package/latticeai/runtime/persistence_runtime.py +19 -1
  50. package/latticeai/runtime/router_registration.py +8 -1
  51. package/latticeai/services/app_context.py +3 -0
  52. package/latticeai/services/architecture_readiness.py +1 -1
  53. package/latticeai/services/automation_execution.py +266 -0
  54. package/latticeai/services/automation_intelligence.py +7 -0
  55. package/latticeai/services/command_center.py +110 -2
  56. package/latticeai/services/folder_watch.py +396 -0
  57. package/latticeai/services/funnel_metrics.py +210 -0
  58. package/latticeai/services/product_readiness.py +1 -1
  59. package/latticeai/services/search_service.py +51 -5
  60. package/latticeai/setup/demo_corpus.py +98 -0
  61. package/latticeai/tools/filesystem.py +38 -1
  62. package/package.json +1 -1
  63. package/scripts/bench_agent_smoke.py +410 -0
  64. package/scripts/bench_models.py +234 -5
  65. package/scripts/check_current_release_docs.mjs +1 -1
  66. package/scripts/funnel_soft_gate.py +192 -0
  67. package/src-tauri/Cargo.lock +1 -1
  68. package/src-tauri/Cargo.toml +1 -1
  69. package/src-tauri/tauri.conf.json +1 -1
  70. package/static/app/asset-manifest.json +29 -27
  71. package/static/app/assets/Act-BCmTU0E2.js +1 -0
  72. package/static/app/assets/{Brain-BnGbytHa.js → Brain-CTnjox7w.js} +2 -2
  73. package/static/app/assets/BrainHome-BJ3sFNX0.js +2 -0
  74. package/static/app/assets/BrainSignals-C52lwZVD.js +1 -0
  75. package/static/app/assets/{Capture-CAKnLcb0.js → Capture-B6vBhFa3.js} +1 -1
  76. package/static/app/assets/CommandPalette-90u9FWFH.js +1 -0
  77. package/static/app/assets/Library-pKCK0_tk.js +1 -0
  78. package/static/app/assets/{LivingBrain-C3VbogVo.js → LivingBrain-Jlf2wFqI.js} +1 -1
  79. package/static/app/assets/{ProductFlow-BLau7c7N.js → ProductFlow-yg1fKP1P.js} +1 -1
  80. package/static/app/assets/ReviewCard-DWvD7n9h.js +3 -0
  81. package/static/app/assets/{System-DRHc_6Su.js → System-BAEuHqNY.js} +1 -1
  82. package/static/app/assets/{bot-DZkDdh-A.js → bot-nB_buEZD.js} +1 -1
  83. package/static/app/assets/circle-pause-DLNw6Ucp.js +1 -0
  84. package/static/app/assets/circle-play-B-IsFL1y.js +1 -0
  85. package/static/app/assets/{cpu-DyvsDBbj.js → cpu-CEPBHaBl.js} +1 -1
  86. package/static/app/assets/{folder-open-CffoIr9t.js → folder-open-hmN0N9cX.js} +1 -1
  87. package/static/app/assets/{hard-drive-C1oojrHb.js → hard-drive-CBV_B_Yd.js} +1 -1
  88. package/static/app/assets/index-7FAfYm4v.css +2 -0
  89. package/static/app/assets/{index-BS_3aN1n.js → index-DrmOCySv.js} +3 -3
  90. package/static/app/assets/{input-B_U7zvbR.js → input-gtVCg-ll.js} +1 -1
  91. package/static/app/assets/{navigation-xG-gyjbe.js → navigation-Bot0hvuv.js} +1 -1
  92. package/static/app/assets/{network-BF13HJJF.js → network-jE42eKfT.js} +1 -1
  93. package/static/app/assets/{primitives-DsuKh3M8.js → primitives-CX2Komon.js} +1 -1
  94. package/static/app/assets/shield-alert-BftATuAA.js +1 -0
  95. package/static/app/assets/{textarea-DsDL4ET8.js → textarea-CiMJfOSI.js} +1 -1
  96. package/static/app/assets/useFocusTrap-B7RPGfFy.js +1 -0
  97. package/static/app/assets/utils-SJUNVOj5.js +7 -0
  98. package/static/app/index.html +4 -4
  99. package/static/sw.js +1 -1
  100. package/static/app/assets/Act-Cx_gZNbE.js +0 -1
  101. package/static/app/assets/BrainHome-CRUMJL93.js +0 -2
  102. package/static/app/assets/BrainSignals-BP8-Qh2g.js +0 -1
  103. package/static/app/assets/CommandPalette-BoUTXBfm.js +0 -1
  104. package/static/app/assets/Library-PcfVUco6.js +0 -1
  105. package/static/app/assets/ReviewCard-CX5RqRqx.js +0 -1
  106. package/static/app/assets/download-C7yxYHvz.js +0 -1
  107. package/static/app/assets/index-DU78Kogz.css +0 -2
  108. package/static/app/assets/sunrise-CIP2ELJ1.js +0 -1
  109. package/static/app/assets/utils-Dh-Dwtkb.js +0 -7
@@ -9,15 +9,21 @@ import re
9
9
  import secrets
10
10
  import threading
11
11
  import time
12
+ from datetime import datetime, timedelta, timezone
12
13
  from pathlib import Path
13
- from typing import Any, Dict
14
+ from typing import Any, Dict, Optional
14
15
 
15
16
  from fastapi import APIRouter, HTTPException, Request
16
17
 
17
18
  from lattice_brain.runtime.hooks import dispatch_tool
18
19
  from latticeai.api.chat_contracts import AgentEvalRequest, AgentRequest, AgentResumeRequest
19
20
  from latticeai.api.chat_helpers import _LANG_HINT, detect_language, workspace_scope_from_request
20
- from latticeai.core.agent import AgentRunContext, AgentState
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
+ )
21
27
  from latticeai.services.tool_dispatch import collect_artifacts, collect_created_files
22
28
 
23
29
 
@@ -40,6 +46,8 @@ class AgentHTTPController:
40
46
  base_dir: Path,
41
47
  agent_root: Path,
42
48
  ensure_agent_root: Any,
49
+ funnel_metrics: Any = None,
50
+ run_store: Any = None,
43
51
  ) -> None:
44
52
  self.runtime = runtime
45
53
  self.model_router = model_router
@@ -56,9 +64,26 @@ class AgentHTTPController:
56
64
  self.base_dir = Path(base_dir)
57
65
  self.agent_root = Path(agent_root)
58
66
  self.ensure_agent_root = ensure_agent_root
67
+ self.funnel_metrics = funnel_metrics
59
68
  self._pending: Dict[str, tuple] = {}
60
69
  self._pending_lock = threading.Lock()
61
70
  self._pending_ttl_seconds = 15 * 60
71
+ # awaiting_approval runs: run_id -> paused run state + approval token.
72
+ # Fail-closed: governed steps only ever execute after resume presents
73
+ # the matching, unexpired token for this run (or via the legacy
74
+ # explicit human-in-loop context flow above).
75
+ self._approvals: Dict[str, Dict[str, Any]] = {}
76
+ self._approval_ttl_seconds = 10 * 60
77
+ # Durable side of the approval loop (review Wave 0.1): paused runs are
78
+ # mirrored to disk so a valid token still resumes after a restart or
79
+ # from another worker process. In-memory stays the fast path.
80
+ self.run_store = run_store if run_store is not None else AgentRunStore(
81
+ Path(base_dir) / "data" / "agent_runs"
82
+ )
83
+ try:
84
+ self.run_store.sweep_expired()
85
+ except Exception as exc: # noqa: BLE001 — hygiene must not break startup
86
+ logging.warning("agent run store sweep failed: %s", exc)
62
87
  self._background_tasks: set[asyncio.Task] = set()
63
88
 
64
89
  def register_routes(self, router: APIRouter) -> None:
@@ -74,6 +99,10 @@ class AgentHTTPController:
74
99
  async def agent_resume(req: AgentResumeRequest, request: Request):
75
100
  return await self.resume(req, request)
76
101
 
102
+ @router.get("/agent/approvals")
103
+ async def agent_approvals(request: Request):
104
+ return self.pending_approvals(request)
105
+
77
106
  def _schedule_background_task(self, coro: Any) -> None:
78
107
  task = asyncio.create_task(coro)
79
108
  self._background_tasks.add(task)
@@ -171,8 +200,17 @@ class AgentHTTPController:
171
200
  "results": results,
172
201
  }
173
202
 
174
- async def agent(self, req: AgentRequest, request: Request) -> Dict[str, Any]:
175
- """Plan and execute a natural-language local agent run."""
203
+ async def agent(
204
+ self,
205
+ req: AgentRequest,
206
+ request: Request,
207
+ on_step: Any = None,
208
+ ) -> Dict[str, Any]:
209
+ """Plan and execute a natural-language local agent run.
210
+
211
+ ``on_step`` (optional) is a per-run step observer — the live SSE
212
+ route attaches one so the client sees progress while EXECUTING.
213
+ """
176
214
  current_user = self.require_user(request)
177
215
  self.enforce_rate_limit(current_user, "agent")
178
216
  effective_email = self.authenticated_identity(current_user, req.user_email)
@@ -198,6 +236,8 @@ class AgentHTTPController:
198
236
  max_steps = max(1, min(req.max_steps, 50))
199
237
  max_retry = 3
200
238
  ctx = AgentRunContext()
239
+ if on_step is not None:
240
+ ctx.on_step = on_step
201
241
  ctx.executing_model = req.executing_model
202
242
  ctx.reviewing_model = req.reviewing_model
203
243
  ctx.state = AgentState.PLANNING
@@ -232,6 +272,18 @@ class AgentHTTPController:
232
272
  "loop": ctx.trace.summary(),
233
273
  }
234
274
 
275
+ # Interactive approval: when the plan needs human approval, pause the
276
+ # run as awaiting_approval (short-TTL token) instead of failing it.
277
+ # ``getattr`` keeps injected fake runtimes without the preview method
278
+ # on the historical fail-closed path.
279
+ requirements_probe = getattr(self.runtime, "approval_requirements", None)
280
+ if callable(requirements_probe):
281
+ requirements = requirements_probe(ctx)
282
+ if requirements.get("requires_approval"):
283
+ return self._pause_for_approval(
284
+ ctx, req, language_hint, current_user, requirements,
285
+ )
286
+
235
287
  self.runtime.approve(ctx, current_user, approved_by_human=False)
236
288
  return await self._finish(
237
289
  ctx,
@@ -242,6 +294,177 @@ class AgentHTTPController:
242
294
  max_retry,
243
295
  )
244
296
 
297
+ def _pause_for_approval(
298
+ self,
299
+ ctx: AgentRunContext,
300
+ req: AgentRequest,
301
+ language_hint: str,
302
+ current_user: str,
303
+ requirements: Dict[str, Any],
304
+ ) -> Dict[str, Any]:
305
+ """Park a plan that needs approval and hand the user a resume token."""
306
+ run_id = secrets.token_urlsafe(16)
307
+ approval_token = secrets.token_urlsafe(32)
308
+ now_monotonic = time.monotonic()
309
+ expires_at = (
310
+ datetime.now(timezone.utc) + timedelta(seconds=self._approval_ttl_seconds)
311
+ ).isoformat(timespec="seconds")
312
+ with self._pending_lock:
313
+ self._purge_expired_approvals_locked(now_monotonic)
314
+ self._approvals[run_id] = {
315
+ "ctx": ctx,
316
+ "req": req,
317
+ "language_hint": language_hint,
318
+ "user": current_user,
319
+ "token": approval_token,
320
+ "expires_monotonic": now_monotonic + self._approval_ttl_seconds,
321
+ "expires_at": expires_at,
322
+ }
323
+ # Durable mirror: a restart between pause and resume must not orphan
324
+ # the run. Best-effort — the in-memory pause still answers on failure.
325
+ try:
326
+ self.run_store.save(
327
+ run_id,
328
+ ctx=ctx,
329
+ req_payload=req.model_dump(),
330
+ language_hint=language_hint,
331
+ user=current_user,
332
+ token=approval_token,
333
+ expires_epoch=time.time() + self._approval_ttl_seconds,
334
+ expires_at=expires_at,
335
+ )
336
+ except Exception as exc: # noqa: BLE001
337
+ logging.warning("agent run store persist failed: %s", exc)
338
+ if self.funnel_metrics is not None:
339
+ try:
340
+ self.funnel_metrics.increment("approval_pauses")
341
+ except Exception as exc: # noqa: BLE001 — advisory only
342
+ logging.warning("funnel metrics increment failed: %s", exc)
343
+ ctx.state_history.append(AgentState.WAITING_APPROVAL.value)
344
+ message = (
345
+ "이 작업에는 승인이 필요한 단계가 있어 실행을 잠시 멈췄습니다. "
346
+ "계획을 확인한 뒤 승인하면 이어서 실행합니다."
347
+ )
348
+ return {
349
+ "status": "awaiting_approval",
350
+ "run_id": run_id,
351
+ "approval": {
352
+ "token": approval_token,
353
+ "expires_at": expires_at,
354
+ "plan_summary": requirements.get("plan_summary", ""),
355
+ },
356
+ "response": message,
357
+ "plan": ctx.plan,
358
+ "steps": ctx.transcript,
359
+ "state_history": ctx.state_history,
360
+ "final_state": AgentState.WAITING_APPROVAL.value,
361
+ "non_auto_steps": requirements.get("non_auto_steps", []),
362
+ "planning_model": req.planning_model or self.model_router.current_model_id,
363
+ "executing_model": req.executing_model or self.model_router.current_model_id,
364
+ "reviewing_model": req.reviewing_model or self.model_router.current_model_id,
365
+ "loop": ctx.trace.summary(),
366
+ }
367
+
368
+ def _purge_expired_approvals_locked(self, now_monotonic: float) -> None:
369
+ for run_id, entry in list(self._approvals.items()):
370
+ if now_monotonic >= entry["expires_monotonic"]:
371
+ self._approvals.pop(run_id, None)
372
+ try:
373
+ self.run_store.delete(run_id)
374
+ except Exception: # noqa: BLE001 — hygiene only
375
+ pass
376
+
377
+ def pending_approvals(self, request: Request) -> Dict[str, Any]:
378
+ """Unexpired paused runs for the current user (memory ∪ disk).
379
+
380
+ Lets the UI re-surface an approval card after a reload/restart instead
381
+ of the run silently vanishing.
382
+ """
383
+ current_user = self.require_user(request)
384
+ now_monotonic = time.monotonic()
385
+ pending: Dict[str, Dict[str, Any]] = {}
386
+ with self._pending_lock:
387
+ for run_id, entry in self._approvals.items():
388
+ if entry["user"] != current_user:
389
+ continue
390
+ if now_monotonic >= entry["expires_monotonic"]:
391
+ continue
392
+ plan = getattr(entry["ctx"], "plan", {}) or {}
393
+ pending[run_id] = {
394
+ "run_id": run_id,
395
+ "goal": str(plan.get("goal") or "")[:200],
396
+ "expires_at": entry["expires_at"],
397
+ }
398
+ try:
399
+ for summary in self.run_store.pending_summaries(current_user):
400
+ run_id = summary.get("run_id")
401
+ if run_id and run_id not in pending:
402
+ pending[run_id] = {
403
+ "run_id": run_id,
404
+ "goal": summary.get("goal") or "",
405
+ "expires_at": summary.get("expires_at"),
406
+ }
407
+ except Exception as exc: # noqa: BLE001 — disk listing is best-effort
408
+ logging.warning("agent run store listing failed: %s", exc)
409
+ return {"pending": sorted(pending.values(), key=lambda p: p["run_id"])}
410
+
411
+ def _restore_persisted_approval(self, run_id: str) -> Optional[Dict[str, Any]]:
412
+ """Rebuild an approval entry from disk after a restart.
413
+
414
+ Returns an entry shaped like the in-memory ones but carrying
415
+ ``token_hash`` instead of the plaintext token. Expired records are
416
+ deleted and reported via the same 410 contract as the memory path.
417
+ """
418
+ try:
419
+ record = self.run_store.load(run_id)
420
+ except Exception as exc: # noqa: BLE001 — load error == not found
421
+ logging.warning("agent run store load failed: %s", exc)
422
+ return None
423
+ if record is None:
424
+ return None
425
+ req_payload = record.get("req") or {}
426
+ if time.time() >= float(record.get("expires_epoch") or 0):
427
+ try:
428
+ self.run_store.delete(run_id)
429
+ except Exception: # noqa: BLE001
430
+ pass
431
+ raise HTTPException(
432
+ status_code=410,
433
+ detail={
434
+ "error": "approval_expired",
435
+ "message": "Approval token expired. Start a new request.",
436
+ "replan": {"message": str(req_payload.get("message") or "")},
437
+ },
438
+ )
439
+ try:
440
+ restored_req = AgentRequest(**req_payload)
441
+ except Exception as exc: # noqa: BLE001 — unreconstructable == not found
442
+ logging.warning("agent run store request restore failed: %s", exc)
443
+ return None
444
+ remaining = max(1.0, float(record["expires_epoch"]) - time.time())
445
+ return {
446
+ "ctx": restore_run_context(record.get("ctx") or {}),
447
+ "req": restored_req,
448
+ "language_hint": str(record.get("language_hint") or "English"),
449
+ "user": record.get("user"),
450
+ "token_hash": str(record.get("token_hash") or ""),
451
+ "expires_monotonic": time.monotonic() + remaining,
452
+ "expires_at": record.get("expires_at"),
453
+ }
454
+
455
+ @staticmethod
456
+ def _token_matches(entry: Dict[str, Any], supplied: str) -> bool:
457
+ """Constant-time token check for both plaintext and hashed entries."""
458
+ if not supplied:
459
+ return False
460
+ stored = entry.get("token")
461
+ if stored is not None:
462
+ return secrets.compare_digest(str(stored), supplied)
463
+ stored_hash = str(entry.get("token_hash") or "")
464
+ return bool(stored_hash) and secrets.compare_digest(
465
+ stored_hash, hash_approval_token(supplied)
466
+ )
467
+
245
468
  async def _finish(
246
469
  self,
247
470
  ctx: AgentRunContext,
@@ -299,6 +522,16 @@ class AgentHTTPController:
299
522
  )
300
523
  except Exception as exc:
301
524
  logging.warning("workspace agent run record failed: %s", exc)
525
+ if self.funnel_metrics is not None:
526
+ # UX funnel (backlog #16): every completed agent run counts, and
527
+ # NEEDS_REVIEW terminals feed the needs_review_rate the review
528
+ # gates watch. Advisory only — a metrics failure never fails chat.
529
+ try:
530
+ self.funnel_metrics.increment("agent_runs")
531
+ if ctx.state == AgentState.NEEDS_REVIEW:
532
+ self.funnel_metrics.increment("needs_review_runs")
533
+ except Exception as exc: # noqa: BLE001
534
+ logging.warning("funnel metrics increment failed: %s", exc)
302
535
  return {
303
536
  "status": "ok" if ctx.state == AgentState.DONE else "failed",
304
537
  "response": message,
@@ -316,8 +549,22 @@ class AgentHTTPController:
316
549
  req: AgentResumeRequest,
317
550
  request: Request,
318
551
  ) -> Dict[str, Any]:
319
- """Resume a paused agent after human approval of the plan."""
552
+ """Resume a paused agent after human approval of the plan.
553
+
554
+ Two entry modes share this endpoint:
555
+
556
+ * ``run_id`` + ``approval_token`` — an ``awaiting_approval`` run
557
+ (token-validated, short TTL, bound to the pausing user);
558
+ * ``context_id`` — the legacy explicit human-in-loop pause.
559
+ """
320
560
  current_user = self.require_user(request)
561
+ if req.run_id:
562
+ return await self._resume_approval(req, current_user)
563
+ if not req.context_id:
564
+ raise HTTPException(
565
+ status_code=400,
566
+ detail="run_id (with approval_token) or context_id is required.",
567
+ )
321
568
  with self._pending_lock:
322
569
  now = time.monotonic()
323
570
  for context_id, pending in list(self._pending.items()):
@@ -345,7 +592,117 @@ class AgentHTTPController:
345
592
  ctx.transcript[-1].update(ctx.plan)
346
593
  ctx.executing_model = req.executing_model or ctx.executing_model
347
594
  ctx.reviewing_model = req.reviewing_model or ctx.reviewing_model
348
- self.runtime.approve(ctx, current_user)
595
+ # The authenticated owner of this pending context explicitly approved
596
+ # the plan — record it as a human approval so approval-gated steps can
597
+ # actually run instead of terminating the run as FAILED.
598
+ self.runtime.approve(ctx, current_user, approved_by_human=True)
599
+ return await self._finish(
600
+ ctx,
601
+ original_request,
602
+ language_hint,
603
+ current_user,
604
+ max(1, min(original_request.max_steps, 50)),
605
+ 3,
606
+ )
607
+
608
+ async def _resume_approval(
609
+ self,
610
+ req: AgentResumeRequest,
611
+ current_user: str,
612
+ ) -> Dict[str, Any]:
613
+ """Validate an awaiting_approval token, then continue or cancel the run."""
614
+ now_monotonic = time.monotonic()
615
+ with self._pending_lock:
616
+ entry = self._approvals.get(req.run_id or "")
617
+ if entry is None:
618
+ # Restart survival (review Wave 0.1): the in-memory dict is
619
+ # gone but the durable record may still be valid.
620
+ entry = self._restore_persisted_approval(req.run_id or "")
621
+ if entry is None:
622
+ raise HTTPException(
623
+ status_code=404,
624
+ detail="Agent run not found. It may have expired — start a new request.",
625
+ )
626
+ if entry["user"] != current_user:
627
+ raise HTTPException(
628
+ status_code=403,
629
+ detail="Agent run belongs to another user.",
630
+ )
631
+ if now_monotonic >= entry["expires_monotonic"]:
632
+ self._approvals.pop(req.run_id, None)
633
+ try:
634
+ self.run_store.delete(req.run_id or "")
635
+ except Exception: # noqa: BLE001
636
+ pass
637
+ raise HTTPException(
638
+ status_code=410,
639
+ detail={
640
+ "error": "approval_expired",
641
+ "message": "Approval token expired. Start a new request.",
642
+ "replan": {"message": getattr(entry.get("req"), "message", "")},
643
+ },
644
+ )
645
+ if not self._token_matches(entry, req.approval_token or ""):
646
+ raise HTTPException(
647
+ status_code=403,
648
+ detail="Invalid approval token for this run.",
649
+ )
650
+ # Token validated — the pending run is consumed either way.
651
+ self._approvals.pop(req.run_id, None)
652
+ try:
653
+ self.run_store.delete(req.run_id or "")
654
+ except Exception as exc: # noqa: BLE001 — consumption must proceed
655
+ logging.warning("agent run store delete failed: %s", exc)
656
+ self._purge_expired_approvals_locked(now_monotonic)
657
+
658
+ ctx: AgentRunContext = entry["ctx"]
659
+ original_request: AgentRequest = entry["req"]
660
+ language_hint: str = entry["language_hint"]
661
+
662
+ if self.funnel_metrics is not None:
663
+ # approval_resume_rate = resumes / pauses (review §4.3): counted on
664
+ # every token-valid resume decision, approve and deny alike.
665
+ try:
666
+ self.funnel_metrics.increment("approval_resumes")
667
+ except Exception as exc: # noqa: BLE001 — advisory only
668
+ logging.warning("funnel metrics increment failed: %s", exc)
669
+
670
+ approved = req.approve if req.approve is not None else req.approved
671
+ if not approved:
672
+ message = "사용자가 계획을 취소했습니다."
673
+ try:
674
+ self.workspace_store.record_agent_run(
675
+ agent_id="agent:executor",
676
+ status="cancelled",
677
+ input_text=original_request.message,
678
+ output_text=message,
679
+ user_email=current_user or None,
680
+ workspace_id=original_request.workspace_id,
681
+ mode="llm",
682
+ timeline=ctx.transcript,
683
+ relationships=["agent:planner", "agent:reviewer"],
684
+ graph=self.workspace_graph(),
685
+ )
686
+ except Exception as exc:
687
+ logging.warning("workspace agent run record failed: %s", exc)
688
+ return {
689
+ "status": "cancelled",
690
+ "run_id": req.run_id,
691
+ "response": message,
692
+ }
693
+
694
+ edited_plan = req.edited_plan or req.modified_plan
695
+ if edited_plan:
696
+ plan, plan_fixes = normalize_plan(edited_plan, original_request.message)
697
+ ctx.plan = plan
698
+ ctx.transcript.append({
699
+ "state": AgentState.WAITING_APPROVAL.value,
700
+ "edited_plan": True,
701
+ **({"plan_fixes": plan_fixes} if plan_fixes else {}),
702
+ })
703
+ ctx.executing_model = req.executing_model or ctx.executing_model
704
+ ctx.reviewing_model = req.reviewing_model or ctx.reviewing_model
705
+ self.runtime.approve(ctx, current_user, approved_by_human=True)
349
706
  return await self._finish(
350
707
  ctx,
351
708
  original_request,
@@ -39,11 +39,17 @@ class AgentRequest(BaseModel):
39
39
 
40
40
 
41
41
  class AgentResumeRequest(BaseModel):
42
- context_id: str
42
+ # Legacy human-in-loop pause (kept working): context_id + approved.
43
+ context_id: Optional[str] = None
43
44
  approved: bool = True
44
45
  modified_plan: Optional[dict] = None
45
46
  executing_model: Optional[str] = None
46
47
  reviewing_model: Optional[str] = None
48
+ # awaiting_approval flow (v9.10): run_id + short-TTL approval token.
49
+ run_id: Optional[str] = None
50
+ approval_token: Optional[str] = None
51
+ approve: Optional[bool] = None
52
+ edited_plan: Optional[dict] = None
47
53
 
48
54
 
49
55
  class AgentEvalRequest(BaseModel):
@@ -245,6 +245,153 @@ def build_context_quality(
245
245
  return context_quality_signal("lexical_only", len(matches))
246
246
 
247
247
 
248
+ # ── answer-citation binding (backlog #11, review §7.2 E #4) ──────────────────
249
+ _GROUNDING_TOKEN_RE = re.compile(r"[0-9A-Za-z가-힣]{2,}")
250
+ # High-frequency tokens that overlap by accident, not by grounding.
251
+ _GROUNDING_STOP_TOKENS = frozenset({
252
+ "그리고", "그러나", "하지만", "그래서", "있습니다", "입니다", "합니다",
253
+ "있는", "없는", "위해", "통해", "대한", "관련", "경우", "내용", "answer",
254
+ "the", "and", "for", "with", "this", "that", "from", "have", "are",
255
+ "was", "were", "can", "will", "not", "you", "your",
256
+ })
257
+ GROUNDING_MIN_OVERLAP_TOKENS = 2
258
+ GROUNDING_MIN_OVERLAP_RATIO = 0.08
259
+
260
+
261
+ def _grounding_tokens(text: str) -> set:
262
+ return {
263
+ token.lower()
264
+ for token in _GROUNDING_TOKEN_RE.findall(str(text or ""))
265
+ if token.lower() not in _GROUNDING_STOP_TOKENS
266
+ }
267
+
268
+
269
+ def assess_answer_grounding(
270
+ answer: str,
271
+ *,
272
+ trace: Optional[Dict[str, object]] = None,
273
+ context_quality: Optional[Dict[str, object]] = None,
274
+ ) -> Dict[str, object]:
275
+ """Bind an answer to its retrieved sources — honestly (never blocks).
276
+
277
+ Heuristic: for each retrieved source (the answer trace's ``graph_nodes``
278
+ / ``source_files``), measure content-token overlap between the answer and
279
+ the source's title+summary, and accept explicit citations (the answer
280
+ names the source title or filename). Returns::
281
+
282
+ {"status": "supported", "label": "근거 있음", "source_ids": [...],
283
+ "cited": [...], "overlap": float}
284
+ {"status": "unsupported", "label": "근거 없음", "source_ids": [], ...}
285
+ {"status": "no_context", "label": "근거 없음", ...} # nothing retrieved
286
+
287
+ ``no_context`` (still labeled 근거 없음) lets the UI distinguish "the
288
+ Brain had nothing" from "the Brain had sources but the answer ignored
289
+ them". Annotation only — the answer is never modified or blocked.
290
+ """
291
+ answer_text = str(answer or "").strip()
292
+ trace = trace if isinstance(trace, dict) else {}
293
+ sources: List[Dict[str, object]] = []
294
+ seen_ids = set()
295
+ for node in list(trace.get("graph_nodes") or []):
296
+ if not isinstance(node, dict):
297
+ continue
298
+ node_id = str(node.get("id") or node.get("node_id") or "")
299
+ if not node_id or node_id in seen_ids:
300
+ continue
301
+ seen_ids.add(node_id)
302
+ meta = node.get("metadata") if isinstance(node.get("metadata"), dict) else {}
303
+ sources.append({
304
+ "id": node_id,
305
+ "title": str(node.get("title") or ""),
306
+ "body": " ".join(
307
+ str(part or "")
308
+ for part in (node.get("title"), node.get("summary"), meta.get("filename"))
309
+ ),
310
+ })
311
+ for src in list(trace.get("source_files") or []):
312
+ if not isinstance(src, dict):
313
+ continue
314
+ node_id = str(src.get("node_id") or src.get("source") or "")
315
+ if not node_id or node_id in seen_ids:
316
+ continue
317
+ seen_ids.add(node_id)
318
+ sources.append({
319
+ "id": node_id,
320
+ "title": str(src.get("node_title") or ""),
321
+ "body": " ".join(
322
+ str(part or "") for part in (src.get("node_title"), src.get("source"))
323
+ ),
324
+ })
325
+
326
+ mode = str((context_quality or {}).get("mode") or "none")
327
+ if not sources:
328
+ return {
329
+ "status": "no_context",
330
+ "label": "근거 없음",
331
+ "source_ids": [],
332
+ "cited": [],
333
+ "overlap": 0.0,
334
+ "reason": (
335
+ "검색된 출처가 없습니다"
336
+ if mode == "none"
337
+ else "출처 후보를 답변에 연결하지 못했습니다"
338
+ ),
339
+ }
340
+ if not answer_text:
341
+ return {
342
+ "status": "unsupported",
343
+ "label": "근거 없음",
344
+ "source_ids": [],
345
+ "cited": [],
346
+ "overlap": 0.0,
347
+ "reason": "답변이 비어 있습니다",
348
+ }
349
+
350
+ answer_tokens = _grounding_tokens(answer_text)
351
+ answer_lower = answer_text.lower()
352
+ cited: List[Dict[str, object]] = []
353
+ best_overlap = 0.0
354
+ for source in sources:
355
+ source_tokens = _grounding_tokens(source["body"])
356
+ if not source_tokens:
357
+ continue
358
+ shared = answer_tokens & source_tokens
359
+ denominator = max(1, min(len(source_tokens), 60))
360
+ ratio = len(shared) / denominator
361
+ best_overlap = max(best_overlap, ratio)
362
+ title = str(source.get("title") or "").strip().lower()
363
+ explicit = bool(title) and len(title) >= 4 and title in answer_lower
364
+ # Explicit-citation OR token-overlap binding. Comparisons are explicit
365
+ # (a 0.0 score is falsy but a valid value — never use `or` defaults).
366
+ if explicit or (
367
+ len(shared) >= GROUNDING_MIN_OVERLAP_TOKENS
368
+ and ratio >= GROUNDING_MIN_OVERLAP_RATIO
369
+ ):
370
+ cited.append({
371
+ "id": source["id"],
372
+ "title": source.get("title"),
373
+ "overlap": round(ratio, 4),
374
+ "explicit": explicit,
375
+ })
376
+ if cited:
377
+ return {
378
+ "status": "supported",
379
+ "label": "근거 있음",
380
+ "source_ids": [item["id"] for item in cited],
381
+ "cited": cited,
382
+ "overlap": round(best_overlap, 4),
383
+ "reason": None,
384
+ }
385
+ return {
386
+ "status": "unsupported",
387
+ "label": "근거 없음",
388
+ "source_ids": [],
389
+ "cited": [],
390
+ "overlap": round(best_overlap, 4),
391
+ "reason": "답변이 검색된 출처의 내용을 사용하지 않았습니다",
392
+ }
393
+
394
+
248
395
  def workspace_scope_from_request(request: Request) -> Optional[str]:
249
396
  header = request.headers.get("X-Workspace-Id")
250
397
  if header and header.strip():