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.
- package/README.md +44 -39
- package/docs/CHANGELOG.md +109 -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 +47 -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/curator.py +141 -0
- package/lattice_brain/graph/discovery_index.py +8 -1
- package/lattice_brain/graph/documents.py +111 -0
- package/lattice_brain/graph/fusion.py +179 -0
- package/lattice_brain/graph/ingest.py +43 -9
- package/lattice_brain/graph/projection.py +382 -37
- package/lattice_brain/graph/retrieval.py +71 -4
- package/lattice_brain/graph/retrieval_policy.py +174 -0
- package/lattice_brain/graph/retrieval_vector.py +122 -2
- package/lattice_brain/ingestion.py +66 -0
- package/lattice_brain/portability.py +41 -12
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/automation_intelligence.py +159 -0
- package/latticeai/api/browser.py +15 -4
- package/latticeai/api/chat.py +33 -2
- package/latticeai/api/chat_agent_http.py +363 -6
- package/latticeai/api/chat_contracts.py +7 -1
- package/latticeai/api/chat_helpers.py +147 -0
- package/latticeai/api/chat_intents.py +217 -10
- package/latticeai/api/chat_stream.py +93 -3
- package/latticeai/api/funnel_metrics.py +31 -0
- package/latticeai/api/knowledge_graph.py +65 -1
- package/latticeai/api/local_files.py +90 -0
- package/latticeai/api/setup.py +134 -1
- package/latticeai/api/tools.py +33 -3
- package/latticeai/app_factory.py +14 -0
- package/latticeai/core/agent.py +355 -18
- package/latticeai/core/agent_eval.py +107 -0
- package/latticeai/core/enterprise.py +5 -0
- package/latticeai/core/file_generation.py +404 -2
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/run_store.py +243 -0
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/models/router.py +25 -12
- package/latticeai/runtime/persistence_runtime.py +19 -1
- package/latticeai/runtime/router_registration.py +8 -1
- package/latticeai/services/app_context.py +3 -0
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/automation_execution.py +266 -0
- package/latticeai/services/automation_intelligence.py +7 -0
- package/latticeai/services/command_center.py +110 -2
- package/latticeai/services/folder_watch.py +396 -0
- package/latticeai/services/funnel_metrics.py +210 -0
- package/latticeai/services/product_readiness.py +1 -1
- package/latticeai/services/search_service.py +51 -5
- package/latticeai/setup/demo_corpus.py +98 -0
- package/latticeai/tools/filesystem.py +38 -1
- package/package.json +1 -1
- package/scripts/bench_agent_smoke.py +410 -0
- package/scripts/bench_models.py +234 -5
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/funnel_soft_gate.py +192 -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 -27
- package/static/app/assets/Act-BCmTU0E2.js +1 -0
- package/static/app/assets/{Brain-BnGbytHa.js → Brain-CTnjox7w.js} +2 -2
- package/static/app/assets/BrainHome-BJ3sFNX0.js +2 -0
- package/static/app/assets/BrainSignals-C52lwZVD.js +1 -0
- package/static/app/assets/{Capture-CAKnLcb0.js → Capture-B6vBhFa3.js} +1 -1
- package/static/app/assets/CommandPalette-90u9FWFH.js +1 -0
- package/static/app/assets/Library-pKCK0_tk.js +1 -0
- package/static/app/assets/{LivingBrain-C3VbogVo.js → LivingBrain-Jlf2wFqI.js} +1 -1
- package/static/app/assets/{ProductFlow-BLau7c7N.js → ProductFlow-yg1fKP1P.js} +1 -1
- package/static/app/assets/ReviewCard-DWvD7n9h.js +3 -0
- package/static/app/assets/{System-DRHc_6Su.js → System-BAEuHqNY.js} +1 -1
- package/static/app/assets/{bot-DZkDdh-A.js → bot-nB_buEZD.js} +1 -1
- package/static/app/assets/circle-pause-DLNw6Ucp.js +1 -0
- package/static/app/assets/circle-play-B-IsFL1y.js +1 -0
- package/static/app/assets/{cpu-DyvsDBbj.js → cpu-CEPBHaBl.js} +1 -1
- package/static/app/assets/{folder-open-CffoIr9t.js → folder-open-hmN0N9cX.js} +1 -1
- package/static/app/assets/{hard-drive-C1oojrHb.js → hard-drive-CBV_B_Yd.js} +1 -1
- package/static/app/assets/index-7FAfYm4v.css +2 -0
- package/static/app/assets/{index-BS_3aN1n.js → index-DrmOCySv.js} +3 -3
- package/static/app/assets/{input-B_U7zvbR.js → input-gtVCg-ll.js} +1 -1
- package/static/app/assets/{navigation-xG-gyjbe.js → navigation-Bot0hvuv.js} +1 -1
- package/static/app/assets/{network-BF13HJJF.js → network-jE42eKfT.js} +1 -1
- package/static/app/assets/{primitives-DsuKh3M8.js → primitives-CX2Komon.js} +1 -1
- package/static/app/assets/shield-alert-BftATuAA.js +1 -0
- package/static/app/assets/{textarea-DsDL4ET8.js → textarea-CiMJfOSI.js} +1 -1
- package/static/app/assets/useFocusTrap-B7RPGfFy.js +1 -0
- package/static/app/assets/utils-SJUNVOj5.js +7 -0
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/static/app/assets/Act-Cx_gZNbE.js +0 -1
- package/static/app/assets/BrainHome-CRUMJL93.js +0 -2
- package/static/app/assets/BrainSignals-BP8-Qh2g.js +0 -1
- package/static/app/assets/CommandPalette-BoUTXBfm.js +0 -1
- package/static/app/assets/Library-PcfVUco6.js +0 -1
- package/static/app/assets/ReviewCard-CX5RqRqx.js +0 -1
- package/static/app/assets/download-C7yxYHvz.js +0 -1
- package/static/app/assets/index-DU78Kogz.css +0 -2
- package/static/app/assets/sunrise-CIP2ELJ1.js +0 -1
- 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(
|
|
175
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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():
|