ltcai 9.9.4 → 9.9.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -42
- package/docs/CHANGELOG.md +35 -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 +10 -4
- 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/rerank.py +160 -0
- package/lattice_brain/graph/retrieval.py +28 -442
- package/lattice_brain/graph/retrieval_reads.py +459 -0
- package/lattice_brain/graph/store.py +2 -0
- package/lattice_brain/quality.py +26 -6
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/chat_agent_http.py +87 -46
- package/latticeai/api/chat_contracts.py +1 -1
- package/latticeai/core/agent.py +156 -18
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/run_store.py +2 -0
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/integrations/telegram_bot.py +82 -23
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/product_readiness.py +1 -1
- package/latticeai/services/tool_dispatch.py +56 -0
- package/package.json +2 -1
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/run_sidecar_e2e.mjs +159 -0
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +28 -28
- package/static/app/assets/{Act-BCmTU0E2.js → Act-DmxfbqBp.js} +1 -1
- package/static/app/assets/{Brain-CTnjox7w.js → Brain-DK_zKCda.js} +1 -1
- package/static/app/assets/{BrainHome-BJ3sFNX0.js → BrainHome-CCE_0hni.js} +1 -1
- package/static/app/assets/{BrainSignals-C52lwZVD.js → BrainSignals-r_J68_zh.js} +1 -1
- package/static/app/assets/{Capture-B6vBhFa3.js → Capture-CvxwzpJZ.js} +1 -1
- package/static/app/assets/{CommandPalette-90u9FWFH.js → CommandPalette-CVLiFIQC.js} +1 -1
- package/static/app/assets/{Library-pKCK0_tk.js → Library-4As9VUvy.js} +1 -1
- package/static/app/assets/{LivingBrain-Jlf2wFqI.js → LivingBrain-BVWk58MW.js} +1 -1
- package/static/app/assets/{ProductFlow-yg1fKP1P.js → ProductFlow-B-Tz0Um4.js} +1 -1
- package/static/app/assets/{ReviewCard-DWvD7n9h.js → ReviewCard-Va3fPQuj.js} +1 -1
- package/static/app/assets/{System-BAEuHqNY.js → System-DSR2zKWd.js} +1 -1
- package/static/app/assets/{bot-nB_buEZD.js → bot-VmP0kkeA.js} +1 -1
- package/static/app/assets/{circle-pause-DLNw6Ucp.js → circle-pause-T9POo4qy.js} +1 -1
- package/static/app/assets/{circle-play-B-IsFL1y.js → circle-play-Bz5iTD0p.js} +1 -1
- package/static/app/assets/{cpu-CEPBHaBl.js → cpu-CJhjRtNq.js} +1 -1
- package/static/app/assets/{folder-open-hmN0N9cX.js → folder-open-B2K_22VI.js} +1 -1
- package/static/app/assets/{hard-drive-CBV_B_Yd.js → hard-drive-CjJtYqHf.js} +1 -1
- package/static/app/assets/{index-DrmOCySv.js → index-CEu0Wqjl.js} +3 -3
- package/static/app/assets/{input-gtVCg-ll.js → input-CSi6OBJ9.js} +1 -1
- package/static/app/assets/{navigation-Bot0hvuv.js → navigation-BPQqEQwr.js} +1 -1
- package/static/app/assets/{network-jE42eKfT.js → network-Ck1nqc-v.js} +1 -1
- package/static/app/assets/{primitives-CX2Komon.js → primitives-B2f9N7Dh.js} +1 -1
- package/static/app/assets/{shield-alert-BftATuAA.js → shield-alert-yA_Y8lOB.js} +1 -1
- package/static/app/assets/{textarea-CiMJfOSI.js → textarea-C4wM0D-Z.js} +1 -1
- package/static/app/assets/{useFocusTrap-B7RPGfFy.js → useFocusTrap-CsnIhsDJ.js} +1 -1
- package/static/app/assets/{utils-SJUNVOj5.js → utils-BqoznzGP.js} +1 -1
- package/static/app/index.html +3 -3
- package/static/sw.js +1 -1
|
@@ -65,9 +65,7 @@ class AgentHTTPController:
|
|
|
65
65
|
self.agent_root = Path(agent_root)
|
|
66
66
|
self.ensure_agent_root = ensure_agent_root
|
|
67
67
|
self.funnel_metrics = funnel_metrics
|
|
68
|
-
self._pending: Dict[str, tuple] = {}
|
|
69
68
|
self._pending_lock = threading.Lock()
|
|
70
|
-
self._pending_ttl_seconds = 15 * 60
|
|
71
69
|
# awaiting_approval runs: run_id -> paused run state + approval token.
|
|
72
70
|
# Fail-closed: governed steps only ever execute after resume presents
|
|
73
71
|
# the matching, unexpired token for this run (or via the legacy
|
|
@@ -250,33 +248,29 @@ class AgentHTTPController:
|
|
|
250
248
|
model_id=req.planning_model,
|
|
251
249
|
)
|
|
252
250
|
|
|
251
|
+
requirements_probe = getattr(self.runtime, "approval_requirements", None)
|
|
252
|
+
|
|
253
253
|
if req.human_in_loop:
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
)
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
"planning_model": req.planning_model or self.model_router.current_model_id,
|
|
270
|
-
"executing_model": req.executing_model or self.model_router.current_model_id,
|
|
271
|
-
"reviewing_model": req.reviewing_model or self.model_router.current_model_id,
|
|
272
|
-
"loop": ctx.trace.summary(),
|
|
273
|
-
}
|
|
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
|
+
)
|
|
274
269
|
|
|
275
270
|
# Interactive approval: when the plan needs human approval, pause the
|
|
276
271
|
# run as awaiting_approval (short-TTL token) instead of failing it.
|
|
277
272
|
# ``getattr`` keeps injected fake runtimes without the preview method
|
|
278
273
|
# on the historical fail-closed path.
|
|
279
|
-
requirements_probe = getattr(self.runtime, "approval_requirements", None)
|
|
280
274
|
if callable(requirements_probe):
|
|
281
275
|
requirements = requirements_probe(ctx)
|
|
282
276
|
if requirements.get("requires_approval"):
|
|
@@ -301,8 +295,16 @@ class AgentHTTPController:
|
|
|
301
295
|
language_hint: str,
|
|
302
296
|
current_user: str,
|
|
303
297
|
requirements: Dict[str, Any],
|
|
298
|
+
*,
|
|
299
|
+
legacy_context: bool = False,
|
|
304
300
|
) -> Dict[str, Any]:
|
|
305
|
-
"""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
|
+
"""
|
|
306
308
|
run_id = secrets.token_urlsafe(16)
|
|
307
309
|
approval_token = secrets.token_urlsafe(32)
|
|
308
310
|
now_monotonic = time.monotonic()
|
|
@@ -319,6 +321,7 @@ class AgentHTTPController:
|
|
|
319
321
|
"token": approval_token,
|
|
320
322
|
"expires_monotonic": now_monotonic + self._approval_ttl_seconds,
|
|
321
323
|
"expires_at": expires_at,
|
|
324
|
+
"legacy_context": legacy_context,
|
|
322
325
|
}
|
|
323
326
|
# Durable mirror: a restart between pause and resume must not orphan
|
|
324
327
|
# the run. Best-effort — the in-memory pause still answers on failure.
|
|
@@ -332,6 +335,7 @@ class AgentHTTPController:
|
|
|
332
335
|
token=approval_token,
|
|
333
336
|
expires_epoch=time.time() + self._approval_ttl_seconds,
|
|
334
337
|
expires_at=expires_at,
|
|
338
|
+
legacy_context=legacy_context,
|
|
335
339
|
)
|
|
336
340
|
except Exception as exc: # noqa: BLE001
|
|
337
341
|
logging.warning("agent run store persist failed: %s", exc)
|
|
@@ -345,8 +349,8 @@ class AgentHTTPController:
|
|
|
345
349
|
"이 작업에는 승인이 필요한 단계가 있어 실행을 잠시 멈췄습니다. "
|
|
346
350
|
"계획을 확인한 뒤 승인하면 이어서 실행합니다."
|
|
347
351
|
)
|
|
348
|
-
|
|
349
|
-
"status": "awaiting_approval",
|
|
352
|
+
payload = {
|
|
353
|
+
"status": "waiting_approval" if legacy_context else "awaiting_approval",
|
|
350
354
|
"run_id": run_id,
|
|
351
355
|
"approval": {
|
|
352
356
|
"token": approval_token,
|
|
@@ -364,6 +368,10 @@ class AgentHTTPController:
|
|
|
364
368
|
"reviewing_model": req.reviewing_model or self.model_router.current_model_id,
|
|
365
369
|
"loop": ctx.trace.summary(),
|
|
366
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
|
|
367
375
|
|
|
368
376
|
def _purge_expired_approvals_locked(self, now_monotonic: float) -> None:
|
|
369
377
|
for run_id, entry in list(self._approvals.items()):
|
|
@@ -450,6 +458,7 @@ class AgentHTTPController:
|
|
|
450
458
|
"token_hash": str(record.get("token_hash") or ""),
|
|
451
459
|
"expires_monotonic": time.monotonic() + remaining,
|
|
452
460
|
"expires_at": record.get("expires_at"),
|
|
461
|
+
"legacy_context": bool(record.get("legacy_context")),
|
|
453
462
|
}
|
|
454
463
|
|
|
455
464
|
@staticmethod
|
|
@@ -565,31 +574,63 @@ class AgentHTTPController:
|
|
|
565
574
|
status_code=400,
|
|
566
575
|
detail="run_id (with approval_token) or context_id is required.",
|
|
567
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()
|
|
568
582
|
with self._pending_lock:
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
self.
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
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)
|
|
581
620
|
|
|
582
|
-
ctx
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
status_code=403,
|
|
586
|
-
detail="Agent context belongs to another user.",
|
|
587
|
-
)
|
|
621
|
+
ctx = entry["ctx"]
|
|
622
|
+
original_request = entry["req"]
|
|
623
|
+
language_hint = entry["language_hint"]
|
|
588
624
|
if not req.approved:
|
|
589
625
|
return {"status": "cancelled", "response": "사용자가 계획을 취소했습니다."}
|
|
590
626
|
if req.modified_plan:
|
|
591
|
-
|
|
592
|
-
ctx.
|
|
627
|
+
plan, plan_fixes = normalize_plan(req.modified_plan, original_request.message)
|
|
628
|
+
ctx.plan = plan
|
|
629
|
+
ctx.transcript.append({
|
|
630
|
+
"state": AgentState.WAITING_APPROVAL.value,
|
|
631
|
+
"edited_plan": True,
|
|
632
|
+
**({"plan_fixes": plan_fixes} if plan_fixes else {}),
|
|
633
|
+
})
|
|
593
634
|
ctx.executing_model = req.executing_model or ctx.executing_model
|
|
594
635
|
ctx.reviewing_model = req.reviewing_model or ctx.reviewing_model
|
|
595
636
|
# The authenticated owner of this pending context explicitly approved
|
|
@@ -45,7 +45,7 @@ class AgentResumeRequest(BaseModel):
|
|
|
45
45
|
modified_plan: Optional[dict] = None
|
|
46
46
|
executing_model: Optional[str] = None
|
|
47
47
|
reviewing_model: Optional[str] = None
|
|
48
|
-
# awaiting_approval flow (v9.
|
|
48
|
+
# awaiting_approval flow (v9.9.x): run_id + short-TTL approval token.
|
|
49
49
|
run_id: Optional[str] = None
|
|
50
50
|
approval_token: Optional[str] = None
|
|
51
51
|
approve: Optional[bool] = None
|
package/latticeai/core/agent.py
CHANGED
|
@@ -340,6 +340,77 @@ def compact_transcript(
|
|
|
340
340
|
return summarized
|
|
341
341
|
|
|
342
342
|
|
|
343
|
+
def files_written(
|
|
344
|
+
transcript: List[Dict[str, Any]],
|
|
345
|
+
file_create_actions: FrozenSet[str],
|
|
346
|
+
) -> List[str]:
|
|
347
|
+
"""Ordered unique paths of files this run successfully wrote (review L5).
|
|
348
|
+
|
|
349
|
+
Later executor steps get this as explicit context, so "만들고 이어서
|
|
350
|
+
설명해" multi-step work sees its own output instead of a stale
|
|
351
|
+
workspace picture.
|
|
352
|
+
"""
|
|
353
|
+
seen: List[str] = []
|
|
354
|
+
for step in transcript:
|
|
355
|
+
if step.get("state") != AgentState.EXECUTING.value:
|
|
356
|
+
continue
|
|
357
|
+
if step.get("action") not in file_create_actions:
|
|
358
|
+
continue
|
|
359
|
+
if not isinstance(step.get("result"), dict):
|
|
360
|
+
continue
|
|
361
|
+
path = step["result"].get("path") or (step.get("args") or {}).get("path")
|
|
362
|
+
if path and str(path) not in seen:
|
|
363
|
+
seen.append(str(path))
|
|
364
|
+
return seen
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def artifact_checklist(
|
|
368
|
+
transcript: List[Dict[str, Any]],
|
|
369
|
+
file_create_actions: FrozenSet[str],
|
|
370
|
+
) -> List[Dict[str, Any]]:
|
|
371
|
+
"""Deterministic artifact facts for the critic (review L4).
|
|
372
|
+
|
|
373
|
+
The critic previously judged file work from prose alone; this surfaces
|
|
374
|
+
the sanitize/repair honesty flags per written file so a repaired
|
|
375
|
+
placeholder can never pass as a fulfilled request unchecked.
|
|
376
|
+
"""
|
|
377
|
+
checklist: List[Dict[str, Any]] = []
|
|
378
|
+
for step in transcript:
|
|
379
|
+
if step.get("state") != AgentState.EXECUTING.value:
|
|
380
|
+
continue
|
|
381
|
+
if step.get("action") not in file_create_actions:
|
|
382
|
+
continue
|
|
383
|
+
if not isinstance(step.get("result"), dict):
|
|
384
|
+
continue
|
|
385
|
+
path = step["result"].get("path") or (step.get("args") or {}).get("path")
|
|
386
|
+
if not path:
|
|
387
|
+
continue
|
|
388
|
+
sanitize_meta = step.get("content_sanitize") or {}
|
|
389
|
+
checklist.append({
|
|
390
|
+
"path": str(path),
|
|
391
|
+
"sanitized": bool(sanitize_meta.get("sanitized")),
|
|
392
|
+
"repaired": bool(sanitize_meta.get("repaired")),
|
|
393
|
+
})
|
|
394
|
+
return checklist
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _format_artifact_checklist(checklist: List[Dict[str, Any]]) -> str:
|
|
398
|
+
lines = []
|
|
399
|
+
for item in checklist:
|
|
400
|
+
state = (
|
|
401
|
+
"auto-REPAIRED scaffold" if item["repaired"]
|
|
402
|
+
else ("sanitized model output" if item["sanitized"] else "written as produced")
|
|
403
|
+
)
|
|
404
|
+
lines.append(f"- {item['path']}: {state}")
|
|
405
|
+
return (
|
|
406
|
+
"Artifact checklist (deterministic, from the transcript):\n"
|
|
407
|
+
+ "\n".join(lines)
|
|
408
|
+
+ "\nVerify each artifact actually fulfills the user's request. An "
|
|
409
|
+
"auto-repaired scaffold is NOT completion unless its content "
|
|
410
|
+
"satisfies what was asked."
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
|
|
343
414
|
@dataclass(frozen=True)
|
|
344
415
|
class TranscriptBudget:
|
|
345
416
|
"""Executor/critic prompt shaping caps (review Wave 0.3).
|
|
@@ -450,6 +521,16 @@ class AgentDeps:
|
|
|
450
521
|
# state machine does not shell out directly. Tests can pass a recorder.
|
|
451
522
|
rollback_file: Optional[Callable[[str], Dict[str, Any]]] = None
|
|
452
523
|
|
|
524
|
+
# ── snapshot rollback ports (optional, review L7) ────────────────
|
|
525
|
+
# git-only rollback left non-git workspaces and newly created files
|
|
526
|
+
# unrecoverable. ``snapshot_file(path)`` captures pre-write state
|
|
527
|
+
# ({"existed", "content", "too_large"}) before a file-create action;
|
|
528
|
+
# ``restore_snapshot(path, content)`` restores it (content=None deletes
|
|
529
|
+
# a file the run created). Both are production-wired with workspace
|
|
530
|
+
# path safety; tests pass recorders.
|
|
531
|
+
snapshot_file: Optional[Callable[[str], Dict[str, Any]]] = None
|
|
532
|
+
restore_snapshot: Optional[Callable[[str, Optional[str]], Dict[str, Any]]] = None
|
|
533
|
+
|
|
453
534
|
# ── lifecycle hooks port (optional) ──────────────────────────────
|
|
454
535
|
# When present, every tool execution fires the shared pre_tool/post_tool
|
|
455
536
|
# lifecycle, so the agent tool path no longer bypasses hooks.
|
|
@@ -791,11 +872,18 @@ class SingleAgentRuntime:
|
|
|
791
872
|
window=budget.window,
|
|
792
873
|
result_chars=budget.result_chars,
|
|
793
874
|
)
|
|
875
|
+
# Mid-run workspace awareness (review L5): later steps must see what
|
|
876
|
+
# this run already produced instead of a stale workspace picture.
|
|
877
|
+
written = files_written(ctx.transcript, d.file_create_actions)
|
|
878
|
+
written_hint = (
|
|
879
|
+
"\n\nFiles written by this run so far (they exist in the workspace now):\n"
|
|
880
|
+
+ "\n".join(f"- {path}" for path in written)
|
|
881
|
+
) if written else ""
|
|
794
882
|
return (
|
|
795
883
|
f"{d.executor_prompt}\n\n"
|
|
796
884
|
f"[LANGUAGE HINT: {lang_hint}]\n"
|
|
797
885
|
f"Workspace root: {d.agent_root}\n\n"
|
|
798
|
-
f"PLAN:\n{json.dumps(ctx.plan, ensure_ascii=False)}\n\n"
|
|
886
|
+
f"PLAN:\n{json.dumps(ctx.plan, ensure_ascii=False)}{written_hint}\n\n"
|
|
799
887
|
f"Recent conversation:\n{recent_conversation}\n\n"
|
|
800
888
|
f"User request: {req.message}{corrections_hint}\n\n"
|
|
801
889
|
f"Execution transcript:\n{json.dumps(bounded_transcript, ensure_ascii=False, indent=2)}"
|
|
@@ -961,6 +1049,22 @@ class SingleAgentRuntime:
|
|
|
961
1049
|
if s.get("state") == AgentState.EXECUTING.value
|
|
962
1050
|
and s.get("action") not in (None, "final", "parse_error")
|
|
963
1051
|
)
|
|
1052
|
+
if (
|
|
1053
|
+
name in d.file_create_actions
|
|
1054
|
+
and d.snapshot_file is not None
|
|
1055
|
+
and args.get("path")
|
|
1056
|
+
):
|
|
1057
|
+
# Pre-write snapshot (review L7): the first capture per path is
|
|
1058
|
+
# the true pre-run state — later writes to the same path must
|
|
1059
|
+
# not overwrite it. Best-effort: a snapshot failure never
|
|
1060
|
+
# blocks the write, it only narrows rollback options.
|
|
1061
|
+
path_str = str(args["path"])
|
|
1062
|
+
if not any(entry.get("path") == path_str for entry in ctx.rollback_log):
|
|
1063
|
+
try:
|
|
1064
|
+
pre = d.snapshot_file(path_str)
|
|
1065
|
+
ctx.rollback_log.append({"path": path_str, **(pre or {})})
|
|
1066
|
+
except Exception as exc: # noqa: BLE001
|
|
1067
|
+
logging.warning("pre-write snapshot failed for %s: %s", path_str, exc)
|
|
964
1068
|
try:
|
|
965
1069
|
d.check_role(name, current_user)
|
|
966
1070
|
# Shared tool lifecycle: pre_tool (may block) → execute → post_tool.
|
|
@@ -1025,11 +1129,17 @@ class SingleAgentRuntime:
|
|
|
1025
1129
|
verify_transcript = _truncate_strings(
|
|
1026
1130
|
ctx.transcript, self.transcript_budget.verify_chars
|
|
1027
1131
|
)
|
|
1132
|
+
# Deterministic artifact facts (review L4): the critic sees the
|
|
1133
|
+
# sanitize/repair honesty flags per written file, not just prose.
|
|
1134
|
+
checklist = artifact_checklist(ctx.transcript, d.file_create_actions)
|
|
1135
|
+
checklist_hint = (
|
|
1136
|
+
f"\n\n{_format_artifact_checklist(checklist)}" if checklist else ""
|
|
1137
|
+
)
|
|
1028
1138
|
context = (
|
|
1029
1139
|
f"{d.critic_prompt}\n\n"
|
|
1030
1140
|
f"[LANGUAGE HINT: {lang_hint}]\n\n"
|
|
1031
1141
|
f"Original request: {req.message}\n"
|
|
1032
|
-
f"Plan goal: {ctx.plan.get('goal', req.message)}\n\n"
|
|
1142
|
+
f"Plan goal: {ctx.plan.get('goal', req.message)}{checklist_hint}\n\n"
|
|
1033
1143
|
f"Full transcript:\n{json.dumps(verify_transcript, ensure_ascii=False, indent=2)}"
|
|
1034
1144
|
)
|
|
1035
1145
|
raw = await d.generate_as(
|
|
@@ -1162,40 +1272,68 @@ class SingleAgentRuntime:
|
|
|
1162
1272
|
ctx.state = AgentState.FAILED
|
|
1163
1273
|
|
|
1164
1274
|
# ── ROLLBACK ─────────────────────────────────────────────────────
|
|
1275
|
+
def _snapshot_for(self, ctx: AgentRunContext, path: str) -> Optional[Dict[str, Any]]:
|
|
1276
|
+
for entry in ctx.rollback_log:
|
|
1277
|
+
if entry.get("path") == path:
|
|
1278
|
+
return entry
|
|
1279
|
+
return None
|
|
1280
|
+
|
|
1281
|
+
def _rollback_one(self, ctx: AgentRunContext, path: str, gov: Dict[str, Any]) -> Dict[str, Any]:
|
|
1282
|
+
"""Recover one path: git when governed and available, else the
|
|
1283
|
+
pre-write snapshot, else an honest ``mode="none"`` (review L7)."""
|
|
1284
|
+
d = self.deps
|
|
1285
|
+
if gov.get("rollback") == "git" and d.rollback_file is not None:
|
|
1286
|
+
try:
|
|
1287
|
+
result = dict(d.rollback_file(str(path)))
|
|
1288
|
+
except Exception as exc: # noqa: BLE001
|
|
1289
|
+
result = {"path": path, "ok": False, "error": str(exc)}
|
|
1290
|
+
if result.get("ok"):
|
|
1291
|
+
result["mode"] = "git"
|
|
1292
|
+
return result
|
|
1293
|
+
snapshot = self._snapshot_for(ctx, str(path))
|
|
1294
|
+
if snapshot is not None and d.restore_snapshot is not None and not snapshot.get("too_large"):
|
|
1295
|
+
content = snapshot.get("content") if snapshot.get("existed") else None
|
|
1296
|
+
try:
|
|
1297
|
+
restored = dict(d.restore_snapshot(str(path), content))
|
|
1298
|
+
except Exception as exc: # noqa: BLE001
|
|
1299
|
+
restored = {"path": path, "ok": False, "error": str(exc)}
|
|
1300
|
+
restored.setdefault("path", path)
|
|
1301
|
+
restored["mode"] = "snapshot"
|
|
1302
|
+
return restored
|
|
1303
|
+
return {
|
|
1304
|
+
"path": path, "ok": False, "mode": "none",
|
|
1305
|
+
"error": "no rollback available (git not applicable, no usable snapshot)",
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1165
1308
|
def rollback(self, ctx: AgentRunContext, current_user: str) -> None:
|
|
1166
|
-
"""ROLLBACK:
|
|
1309
|
+
"""ROLLBACK: recover written files (git → snapshot → none), then FAILED."""
|
|
1167
1310
|
d = self.deps
|
|
1168
1311
|
rolled: List[dict] = []
|
|
1312
|
+
seen_paths: set = set()
|
|
1169
1313
|
for step in ctx.transcript:
|
|
1170
1314
|
if step.get("state") != AgentState.EXECUTING.value:
|
|
1171
1315
|
continue
|
|
1172
|
-
|
|
1173
|
-
if gov.get("rollback") != "git":
|
|
1316
|
+
if not isinstance(step.get("result"), dict):
|
|
1174
1317
|
continue
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
path = result.get("path") or (step.get("args") or {}).get("path", "")
|
|
1179
|
-
if not path:
|
|
1318
|
+
gov = step.get("governance", {}) or {}
|
|
1319
|
+
path = step["result"].get("path") or (step.get("args") or {}).get("path", "")
|
|
1320
|
+
if not path or str(path) in seen_paths:
|
|
1180
1321
|
continue
|
|
1181
|
-
if
|
|
1182
|
-
rolled.append({"path": path, "ok": False, "error": "rollback_file port is not configured"})
|
|
1322
|
+
if gov.get("rollback") != "git" and step.get("action") not in d.file_create_actions:
|
|
1183
1323
|
continue
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
except Exception as exc:
|
|
1187
|
-
rolled.append({"path": path, "ok": False, "error": str(exc)})
|
|
1324
|
+
seen_paths.add(str(path))
|
|
1325
|
+
rolled.append(self._rollback_one(ctx, str(path), gov))
|
|
1188
1326
|
|
|
1189
1327
|
ctx.transcript.append({"state": AgentState.ROLLBACK.value, "rolled_back": rolled})
|
|
1190
1328
|
ctx.trace.decision(
|
|
1191
1329
|
"rollback", decision="rolled_back",
|
|
1192
1330
|
attempted=len(rolled), recovered=sum(1 for r in rolled if r.get("ok")),
|
|
1193
1331
|
)
|
|
1194
|
-
recovered = [r[
|
|
1332
|
+
recovered = [f"{r['path']} ({r.get('mode')})" for r in rolled if r.get("ok")]
|
|
1195
1333
|
ctx.final_message = (
|
|
1196
1334
|
f"실행 실패로 롤백했습니다. 복구 파일: {recovered}"
|
|
1197
1335
|
if recovered
|
|
1198
|
-
else "롤백을 시도했으나 복구할 파일이 없거나 git
|
|
1336
|
+
else "롤백을 시도했으나 복구할 파일이 없거나 git/스냅샷 복구 수단이 없습니다."
|
|
1199
1337
|
)
|
|
1200
1338
|
d.audit("agent_rollback", user_email=current_user, rolled_back=rolled)
|
|
1201
1339
|
self._emit_step(ctx, "rollback", "rolled_back", recovered=len(recovered))
|
|
@@ -113,6 +113,7 @@ class AgentRunStore:
|
|
|
113
113
|
token: str,
|
|
114
114
|
expires_epoch: float,
|
|
115
115
|
expires_at: str,
|
|
116
|
+
legacy_context: bool = False,
|
|
116
117
|
) -> bool:
|
|
117
118
|
"""Persist a paused run. Best-effort: returns False on any failure."""
|
|
118
119
|
path = self._path_for(run_id)
|
|
@@ -126,6 +127,7 @@ class AgentRunStore:
|
|
|
126
127
|
"token_hash": hash_approval_token(token),
|
|
127
128
|
"expires_epoch": float(expires_epoch),
|
|
128
129
|
"expires_at": expires_at,
|
|
130
|
+
"legacy_context": bool(legacy_context),
|
|
129
131
|
"req": req_payload,
|
|
130
132
|
"ctx": serialize_run_context(ctx),
|
|
131
133
|
}
|
|
@@ -49,7 +49,7 @@ __all__ = [
|
|
|
49
49
|
"remove_skill_directory",
|
|
50
50
|
]
|
|
51
51
|
|
|
52
|
-
WORKSPACE_OS_VERSION = "9.9.
|
|
52
|
+
WORKSPACE_OS_VERSION = "9.9.5"
|
|
53
53
|
|
|
54
54
|
# Workspace types separate single-user Personal workspaces from shared
|
|
55
55
|
# Organization workspaces. Both keep the same local-first JSON store; the type
|