ltcai 9.9.4 → 9.9.9
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 +43 -42
- package/docs/CHANGELOG.md +228 -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 +3 -1
- package/docs/PERMISSION_MODE.md +107 -0
- package/docs/SURFACE_PARITY.md +45 -9
- 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/context.py +39 -4
- package/lattice_brain/graph/_kg_common.py +189 -14
- package/lattice_brain/graph/curator.py +62 -0
- package/lattice_brain/graph/discovery.py +76 -0
- package/lattice_brain/graph/ingest.py +37 -7
- 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/retrieval_vector.py +10 -0
- package/lattice_brain/graph/store.py +2 -0
- package/lattice_brain/ingestion.py +9 -106
- package/lattice_brain/ingestion_jobs.py +133 -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/brain_intelligence.py +8 -0
- package/latticeai/api/chat.py +9 -0
- package/latticeai/api/chat_agent_http.py +206 -48
- package/latticeai/api/chat_contracts.py +10 -1
- package/latticeai/api/chat_documents.py +16 -0
- package/latticeai/api/evidence_actions.py +48 -0
- package/latticeai/api/permission_mode.py +61 -0
- package/latticeai/api/project_sessions.py +112 -0
- package/latticeai/api/voice_capture.py +89 -0
- package/latticeai/app_factory.py +53 -0
- package/latticeai/core/agent.py +536 -60
- package/latticeai/core/agent_permission.py +160 -0
- package/latticeai/core/agent_profiles.py +110 -0
- package/latticeai/core/artifact_ledger.py +109 -0
- package/latticeai/core/context_builder.py +101 -2
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/permission_mode.py +296 -0
- package/latticeai/core/project_sessions.py +337 -0
- package/latticeai/core/run_explain.py +421 -0
- package/latticeai/core/run_store.py +7 -0
- package/latticeai/core/workspace_os.py +15 -80
- package/latticeai/core/workspace_review_items.py +107 -0
- package/latticeai/integrations/telegram_bot.py +270 -23
- package/latticeai/runtime/chat_wiring.py +8 -0
- package/latticeai/runtime/context_runtime.py +11 -0
- package/latticeai/runtime/permission_mode_wiring.py +116 -0
- package/latticeai/runtime/router_registration.py +9 -0
- package/latticeai/services/app_context.py +3 -0
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/brain_intelligence.py +105 -0
- package/latticeai/services/evidence_actions.py +254 -0
- package/latticeai/services/funnel_metrics.py +111 -16
- package/latticeai/services/local_knowledge.py +26 -0
- package/latticeai/services/memory_service.py +12 -0
- package/latticeai/services/permission_mode_service.py +170 -0
- package/latticeai/services/product_readiness.py +1 -1
- package/latticeai/services/tool_dispatch.py +129 -4
- package/latticeai/services/voice_capture.py +189 -0
- package/package.json +6 -3
- package/scripts/check_bundle_budget.mjs +11 -0
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/check_i18n_namespace_coverage.mjs +148 -0
- package/scripts/run_sidecar_e2e.mjs +159 -0
- package/skills/meeting_notes/SKILL.md +82 -0
- package/skills/meeting_notes/examples.md +41 -0
- package/skills/meeting_notes/risk.json +9 -0
- package/skills/meeting_notes/schema.json +55 -0
- package/skills/weekly_review/SKILL.md +75 -0
- package/skills/weekly_review/examples.md +39 -0
- package/skills/weekly_review/risk.json +9 -0
- package/skills/weekly_review/schema.json +55 -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 +37 -29
- package/static/app/assets/Act-BFbpl2_Y.js +1 -0
- package/static/app/assets/AdminConsole-DaZPha9Y.js +1 -0
- package/static/app/assets/Brain-DGrV0heZ.js +321 -0
- package/static/app/assets/BrainHome-Re6Zxh_W.js +2 -0
- package/static/app/assets/BrainSignals-BQ11jDpJ.js +1 -0
- package/static/app/assets/Capture-DRaf1zLH.js +1 -0
- package/static/app/assets/CommandPalette-DAKPm4uJ.js +1 -0
- package/static/app/assets/LanguageSwitcher-BgTwHoWv.js +1 -0
- package/static/app/assets/Library-BVtrSt12.js +1 -0
- package/static/app/assets/LivingBrain-7glOmK0R.js +1 -0
- package/static/app/assets/ProductFlow-cB3EFinZ.js +1 -0
- package/static/app/assets/ReviewCard-CShhHS-w.js +3 -0
- package/static/app/assets/System-B4nQDy8a.js +1 -0
- package/static/app/assets/{bot-nB_buEZD.js → bot-DtKkLfXj.js} +1 -1
- package/static/app/assets/brain-wVV63_1z.js +1 -0
- package/static/app/assets/button-BTxZDMFJ.js +1 -0
- package/static/app/assets/{circle-pause-DLNw6Ucp.js → circle-pause--qxF9-EO.js} +1 -1
- package/static/app/assets/{circle-play-B-IsFL1y.js → circle-play-89x3S6jm.js} +1 -1
- package/static/app/assets/{cpu-CEPBHaBl.js → cpu-DTUI63vo.js} +1 -1
- package/static/app/assets/download-EJS5_O0f.js +1 -0
- package/static/app/assets/{folder-open-hmN0N9cX.js → folder-open-BL9zYTnc.js} +1 -1
- package/static/app/assets/{hard-drive-CBV_B_Yd.js → hard-drive-BFnwi1Lu.js} +1 -1
- package/static/app/assets/index-BFtDYI6l.js +10 -0
- package/static/app/assets/index-BY9bpxyx.css +2 -0
- package/static/app/assets/input-DEz5UjBz.js +1 -0
- package/static/app/assets/{network-jE42eKfT.js → network-zKa3jAwl.js} +1 -1
- package/static/app/assets/primitives-jNrZm2O9.js +1 -0
- package/static/app/assets/search-ku6-_6wY.js +1 -0
- package/static/app/assets/shield-alert-CpHeCsaF.js +1 -0
- package/static/app/assets/textarea-DpcQkn75.js +1 -0
- package/static/app/assets/{useFocusTrap-B7RPGfFy.js → useFocusTrap-58TNCONM.js} +1 -1
- package/static/app/assets/{navigation-Bot0hvuv.js → useQuery-CcVlXlBd.js} +1 -1
- package/static/app/assets/users-C0cFMj6o.js +1 -0
- package/static/app/assets/utils-DYf7pqrR.js +7 -0
- package/static/app/assets/workspace-CjthHsLN.js +1 -0
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/static/app/assets/Act-BCmTU0E2.js +0 -1
- package/static/app/assets/Brain-CTnjox7w.js +0 -321
- package/static/app/assets/BrainHome-BJ3sFNX0.js +0 -2
- package/static/app/assets/BrainSignals-C52lwZVD.js +0 -1
- package/static/app/assets/Capture-B6vBhFa3.js +0 -1
- package/static/app/assets/CommandPalette-90u9FWFH.js +0 -1
- package/static/app/assets/Library-pKCK0_tk.js +0 -1
- package/static/app/assets/LivingBrain-Jlf2wFqI.js +0 -1
- package/static/app/assets/ProductFlow-yg1fKP1P.js +0 -1
- package/static/app/assets/ReviewCard-DWvD7n9h.js +0 -3
- package/static/app/assets/System-BAEuHqNY.js +0 -1
- package/static/app/assets/index-7FAfYm4v.css +0 -2
- package/static/app/assets/index-DrmOCySv.js +0 -10
- package/static/app/assets/input-gtVCg-ll.js +0 -1
- package/static/app/assets/primitives-CX2Komon.js +0 -1
- package/static/app/assets/shield-alert-BftATuAA.js +0 -1
- package/static/app/assets/textarea-CiMJfOSI.js +0 -1
- package/static/app/assets/utils-SJUNVOj5.js +0 -7
package/latticeai/core/agent.py
CHANGED
|
@@ -31,8 +31,21 @@ from typing import Any, Awaitable, Callable, Dict, FrozenSet, List, Mapping, Opt
|
|
|
31
31
|
|
|
32
32
|
from lattice_brain.runtime.hooks import dispatch_tool
|
|
33
33
|
from lattice_brain.runtime.contracts import runtime_boundary_contract, single_agent_contract
|
|
34
|
+
from latticeai.core.agent_permission import (
|
|
35
|
+
block_reason_for_tool,
|
|
36
|
+
non_auto_plan_steps,
|
|
37
|
+
resolve_deps_mode,
|
|
38
|
+
)
|
|
39
|
+
from latticeai.core.agent_profiles import AgentProfile, profile_for_model
|
|
34
40
|
from latticeai.core.agent_trace import LoopTrace
|
|
41
|
+
from latticeai.core.permission_mode import (
|
|
42
|
+
PermissionMode,
|
|
43
|
+
is_circuit_breaker,
|
|
44
|
+
plan_requires_approval,
|
|
45
|
+
should_stage_proposal,
|
|
46
|
+
)
|
|
35
47
|
from latticeai.core.file_generation import (
|
|
48
|
+
generate_file_content,
|
|
36
49
|
infer_file_target,
|
|
37
50
|
infer_project_manifest,
|
|
38
51
|
sanitize_write_content,
|
|
@@ -67,7 +80,7 @@ class AgentRunContext:
|
|
|
67
80
|
__slots__ = ("state", "plan", "transcript", "retry_count",
|
|
68
81
|
"state_history", "corrections", "final_message", "rollback_log",
|
|
69
82
|
"executing_model", "reviewing_model", "approved_by_human", "trace",
|
|
70
|
-
"on_step")
|
|
83
|
+
"on_step", "project_context", "permission_mode")
|
|
71
84
|
|
|
72
85
|
def __init__(self) -> None:
|
|
73
86
|
self.state: AgentState = AgentState.IDLE
|
|
@@ -86,6 +99,16 @@ class AgentRunContext:
|
|
|
86
99
|
# callback here so live SSE clients see progress while EXECUTING.
|
|
87
100
|
# Never serialized; a broken observer never breaks the loop.
|
|
88
101
|
self.on_step: Optional[Callable[[Dict[str, Any]], None]] = None
|
|
102
|
+
# Multi-turn project loop (v9.9.6): a prompt block describing where the
|
|
103
|
+
# project stands — files already produced, open TODOs, the last honest
|
|
104
|
+
# verification. Empty for a standalone run, which behaves exactly as
|
|
105
|
+
# before. Set by the HTTP layer, read by plan/execute/verify.
|
|
106
|
+
self.project_context: str = ""
|
|
107
|
+
# Autonomy dial resolved once per run (v9.9.8). The HTTP layer stamps
|
|
108
|
+
# the user/workspace-scoped mode here so the plan gate and every
|
|
109
|
+
# per-tool gate in the same run agree; ``None`` falls back to the
|
|
110
|
+
# process-wide resolver on ``deps``.
|
|
111
|
+
self.permission_mode: Optional[str] = None
|
|
89
112
|
|
|
90
113
|
|
|
91
114
|
_THINK_BLOCK_RE = re.compile(
|
|
@@ -340,6 +363,146 @@ def compact_transcript(
|
|
|
340
363
|
return summarized
|
|
341
364
|
|
|
342
365
|
|
|
366
|
+
def files_written(
|
|
367
|
+
transcript: List[Dict[str, Any]],
|
|
368
|
+
file_create_actions: FrozenSet[str],
|
|
369
|
+
) -> List[str]:
|
|
370
|
+
"""Ordered unique paths of files this run successfully wrote (review L5).
|
|
371
|
+
|
|
372
|
+
Later executor steps get this as explicit context, so "만들고 이어서
|
|
373
|
+
설명해" multi-step work sees its own output instead of a stale
|
|
374
|
+
workspace picture.
|
|
375
|
+
"""
|
|
376
|
+
seen: List[str] = []
|
|
377
|
+
for step in transcript:
|
|
378
|
+
if step.get("state") != AgentState.EXECUTING.value:
|
|
379
|
+
continue
|
|
380
|
+
if step.get("action") not in file_create_actions:
|
|
381
|
+
continue
|
|
382
|
+
if not isinstance(step.get("result"), dict):
|
|
383
|
+
continue
|
|
384
|
+
path = step["result"].get("path") or (step.get("args") or {}).get("path")
|
|
385
|
+
if path and str(path) not in seen:
|
|
386
|
+
seen.append(str(path))
|
|
387
|
+
return seen
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def artifact_checklist(
|
|
391
|
+
transcript: List[Dict[str, Any]],
|
|
392
|
+
file_create_actions: FrozenSet[str],
|
|
393
|
+
) -> List[Dict[str, Any]]:
|
|
394
|
+
"""Deterministic artifact facts for the critic (review L4).
|
|
395
|
+
|
|
396
|
+
The critic previously judged file work from prose alone; this surfaces
|
|
397
|
+
the sanitize/repair honesty flags per written file so a repaired
|
|
398
|
+
placeholder can never pass as a fulfilled request unchecked.
|
|
399
|
+
"""
|
|
400
|
+
checklist: List[Dict[str, Any]] = []
|
|
401
|
+
for step in transcript:
|
|
402
|
+
if step.get("state") != AgentState.EXECUTING.value:
|
|
403
|
+
continue
|
|
404
|
+
if step.get("action") not in file_create_actions:
|
|
405
|
+
continue
|
|
406
|
+
if not isinstance(step.get("result"), dict):
|
|
407
|
+
continue
|
|
408
|
+
path = step["result"].get("path") or (step.get("args") or {}).get("path")
|
|
409
|
+
if not path:
|
|
410
|
+
continue
|
|
411
|
+
sanitize_meta = step.get("content_sanitize") or {}
|
|
412
|
+
checklist.append({
|
|
413
|
+
"path": str(path),
|
|
414
|
+
"sanitized": bool(sanitize_meta.get("sanitized")),
|
|
415
|
+
"repaired": bool(sanitize_meta.get("repaired")),
|
|
416
|
+
})
|
|
417
|
+
return checklist
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
# Explicit requirement lines a user writes out: "- 다크모드", "1. 검색 기능",
|
|
421
|
+
# "* dark mode". Free prose is deliberately NOT parsed — a wrong requirement
|
|
422
|
+
# is worse than no requirement.
|
|
423
|
+
_REQUIREMENT_LINE_RE = re.compile(r"^\s*(?:[-*•]|\d+[.)])\s+(.{3,120})$", re.MULTILINE)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def requirement_coverage(
|
|
427
|
+
user_message: str,
|
|
428
|
+
transcript: List[Dict[str, Any]],
|
|
429
|
+
file_create_actions: FrozenSet[str],
|
|
430
|
+
) -> Dict[str, Any]:
|
|
431
|
+
"""Did the run produce what the request actually asked for? (review 루프 §2)
|
|
432
|
+
|
|
433
|
+
The critic judged prose against prose, so "make an HTML+CSS+JS todo app"
|
|
434
|
+
could pass with one file written. This is the deterministic half of the
|
|
435
|
+
answer, built from two sources that are honest about their own limits:
|
|
436
|
+
|
|
437
|
+
* **manifest files** — when the request maps to a known multi-file project
|
|
438
|
+
(:func:`infer_project_manifest`), every declared path must have been
|
|
439
|
+
written. A missing manifest file is a *hard* miss: it is not a matter of
|
|
440
|
+
taste whether ``style.css`` exists.
|
|
441
|
+
* **explicit requirement lines** — bullet/numbered lines the user wrote
|
|
442
|
+
out. These are reported to the critic as a checklist but never block on
|
|
443
|
+
their own: matching a feature to a transcript is a judgement call, and
|
|
444
|
+
guessing it wrong would either fake completion or block real work.
|
|
445
|
+
|
|
446
|
+
Returns ``{"files": {...}, "requirements": [...], "missing_files": [...],
|
|
447
|
+
"complete": bool}`` where ``complete`` is false only when a declared
|
|
448
|
+
manifest file is missing.
|
|
449
|
+
"""
|
|
450
|
+
written = files_written(transcript, file_create_actions)
|
|
451
|
+
written_names = {Path(path).name.lower() for path in written}
|
|
452
|
+
manifest = infer_project_manifest(user_message) or {}
|
|
453
|
+
declared = [str(spec.get("path") or "") for spec in manifest.get("files", [])]
|
|
454
|
+
missing = [
|
|
455
|
+
path for path in declared
|
|
456
|
+
if path and Path(path).name.lower() not in written_names
|
|
457
|
+
]
|
|
458
|
+
requirements = [
|
|
459
|
+
line.strip()
|
|
460
|
+
for line in _REQUIREMENT_LINE_RE.findall(str(user_message or ""))
|
|
461
|
+
][:10]
|
|
462
|
+
return {
|
|
463
|
+
"files": {"declared": declared, "written": written},
|
|
464
|
+
"missing_files": missing,
|
|
465
|
+
"requirements": requirements,
|
|
466
|
+
"complete": not missing,
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _format_requirement_coverage(coverage: Dict[str, Any]) -> str:
|
|
471
|
+
"""Requirement facts for the critic prompt, or "" when there is nothing."""
|
|
472
|
+
lines: List[str] = []
|
|
473
|
+
declared = coverage["files"]["declared"]
|
|
474
|
+
if declared:
|
|
475
|
+
written = set(coverage["files"]["written"])
|
|
476
|
+
lines.append("Requested files (deterministic, from the request):")
|
|
477
|
+
for path in declared:
|
|
478
|
+
got = any(Path(item).name.lower() == Path(path).name.lower() for item in written)
|
|
479
|
+
lines.append(f"- {path}: {'written' if got else 'MISSING'}")
|
|
480
|
+
if coverage["requirements"]:
|
|
481
|
+
lines.append(
|
|
482
|
+
"Requirements the user listed explicitly — check each one against "
|
|
483
|
+
"the artifacts, not against the plan:"
|
|
484
|
+
)
|
|
485
|
+
lines.extend(f"- {item}" for item in coverage["requirements"])
|
|
486
|
+
return "\n\n" + "\n".join(lines) if lines else ""
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def _format_artifact_checklist(checklist: List[Dict[str, Any]]) -> str:
|
|
490
|
+
lines = []
|
|
491
|
+
for item in checklist:
|
|
492
|
+
state = (
|
|
493
|
+
"auto-REPAIRED scaffold" if item["repaired"]
|
|
494
|
+
else ("sanitized model output" if item["sanitized"] else "written as produced")
|
|
495
|
+
)
|
|
496
|
+
lines.append(f"- {item['path']}: {state}")
|
|
497
|
+
return (
|
|
498
|
+
"Artifact checklist (deterministic, from the transcript):\n"
|
|
499
|
+
+ "\n".join(lines)
|
|
500
|
+
+ "\nVerify each artifact actually fulfills the user's request. An "
|
|
501
|
+
"auto-repaired scaffold is NOT completion unless its content "
|
|
502
|
+
"satisfies what was asked."
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
|
|
343
506
|
@dataclass(frozen=True)
|
|
344
507
|
class TranscriptBudget:
|
|
345
508
|
"""Executor/critic prompt shaping caps (review Wave 0.3).
|
|
@@ -450,6 +613,16 @@ class AgentDeps:
|
|
|
450
613
|
# state machine does not shell out directly. Tests can pass a recorder.
|
|
451
614
|
rollback_file: Optional[Callable[[str], Dict[str, Any]]] = None
|
|
452
615
|
|
|
616
|
+
# ── snapshot rollback ports (optional, review L7) ────────────────
|
|
617
|
+
# git-only rollback left non-git workspaces and newly created files
|
|
618
|
+
# unrecoverable. ``snapshot_file(path)`` captures pre-write state
|
|
619
|
+
# ({"existed", "content", "too_large"}) before a file-create action;
|
|
620
|
+
# ``restore_snapshot(path, content)`` restores it (content=None deletes
|
|
621
|
+
# a file the run created). Both are production-wired with workspace
|
|
622
|
+
# path safety; tests pass recorders.
|
|
623
|
+
snapshot_file: Optional[Callable[[str], Dict[str, Any]]] = None
|
|
624
|
+
restore_snapshot: Optional[Callable[[str, Optional[str]], Dict[str, Any]]] = None
|
|
625
|
+
|
|
453
626
|
# ── lifecycle hooks port (optional) ──────────────────────────────
|
|
454
627
|
# When present, every tool execution fires the shared pre_tool/post_tool
|
|
455
628
|
# lifecycle, so the agent tool path no longer bypasses hooks.
|
|
@@ -484,6 +657,19 @@ class AgentDeps:
|
|
|
484
657
|
# can also be attached on AgentRunContext.on_step. Both are advisory.
|
|
485
658
|
on_step: Optional[Callable[[Dict[str, Any]], None]] = None
|
|
486
659
|
|
|
660
|
+
# ── agent profile (optional, v9.9.7) ─────────────────────────────
|
|
661
|
+
# How hard the loop works to keep a weak model on contract. None selects
|
|
662
|
+
# per-run from the executing model id (``profile_for_model``); tests
|
|
663
|
+
# inject a fixed profile.
|
|
664
|
+
agent_profile: Optional["AgentProfile"] = None
|
|
665
|
+
|
|
666
|
+
# ── permission mode port (optional, v9.9.8) ──────────────────────
|
|
667
|
+
# The autonomy dial. Either a static mode, or a resolver callable that
|
|
668
|
+
# accepts ``user_email``/``workspace_id`` scope kwargs (preferred) or no
|
|
669
|
+
# arguments at all — see ``call_mode_source``. ``None`` means strict,
|
|
670
|
+
# which is exactly the pre-9.9.8 behaviour.
|
|
671
|
+
permission_mode: Any = None
|
|
672
|
+
|
|
487
673
|
|
|
488
674
|
class SingleAgentRuntime:
|
|
489
675
|
"""Drives the agent state machine over injected :class:`AgentDeps`."""
|
|
@@ -513,6 +699,44 @@ class SingleAgentRuntime:
|
|
|
513
699
|
self._env_transcript_budget = TranscriptBudget.from_env()
|
|
514
700
|
return self._env_transcript_budget
|
|
515
701
|
|
|
702
|
+
# ── permission mode (v9.9.8) ─────────────────────────────────────
|
|
703
|
+
def resolve_permission_mode(
|
|
704
|
+
self,
|
|
705
|
+
ctx: Optional[AgentRunContext] = None,
|
|
706
|
+
*,
|
|
707
|
+
user_email: Optional[str] = None,
|
|
708
|
+
workspace_id: Optional[str] = None,
|
|
709
|
+
) -> PermissionMode:
|
|
710
|
+
"""Autonomy dial for this run.
|
|
711
|
+
|
|
712
|
+
A mode stamped on ``ctx`` wins, so the plan a user approved and every
|
|
713
|
+
tool step in the same run are judged by one dial even if the stored
|
|
714
|
+
preference changes mid-run. Otherwise the resolver on ``deps`` is
|
|
715
|
+
consulted with the caller's scope — resolving unscoped would collapse
|
|
716
|
+
every caller onto the process-wide default.
|
|
717
|
+
"""
|
|
718
|
+
return resolve_deps_mode(
|
|
719
|
+
self.deps, ctx, user_email=user_email, workspace_id=workspace_id,
|
|
720
|
+
)
|
|
721
|
+
|
|
722
|
+
def _governed_tools(self) -> FrozenSet[str]:
|
|
723
|
+
governor = getattr(self.deps, "change_governor", None)
|
|
724
|
+
if governor is None:
|
|
725
|
+
return frozenset()
|
|
726
|
+
return frozenset(getattr(governor, "governed_tools", frozenset()))
|
|
727
|
+
|
|
728
|
+
def profile_for(self, model_id: Optional[str]) -> AgentProfile:
|
|
729
|
+
"""Loop profile for the model actually executing this run (v9.9.7).
|
|
730
|
+
|
|
731
|
+
An injected ``AgentDeps.agent_profile`` wins (tests, explicit config);
|
|
732
|
+
otherwise the profile is derived from the model id, so a small local
|
|
733
|
+
model gets the compact loop without any extra configuration.
|
|
734
|
+
"""
|
|
735
|
+
injected = getattr(self.deps, "agent_profile", None)
|
|
736
|
+
if injected is not None:
|
|
737
|
+
return injected
|
|
738
|
+
return profile_for_model(model_id)
|
|
739
|
+
|
|
516
740
|
def _emit_step(self, ctx: AgentRunContext, phase: str, event: str, **details: Any) -> None:
|
|
517
741
|
"""Fire the per-run / deps step observers (review Wave 1.1).
|
|
518
742
|
|
|
@@ -532,6 +756,17 @@ class SingleAgentRuntime:
|
|
|
532
756
|
except Exception as exc: # noqa: BLE001 — observers are advisory
|
|
533
757
|
logging.warning("agent step observer failed: %s", exc)
|
|
534
758
|
|
|
759
|
+
@staticmethod
|
|
760
|
+
def _project_block(ctx: AgentRunContext) -> str:
|
|
761
|
+
"""Project-session context for prompts, or "" for a standalone run.
|
|
762
|
+
|
|
763
|
+
Multi-turn project loop (v9.9.6): a later run must see the files the
|
|
764
|
+
project already produced and what is still open, instead of planning
|
|
765
|
+
from a blank workspace every time.
|
|
766
|
+
"""
|
|
767
|
+
summary = str(getattr(ctx, "project_context", "") or "").strip()
|
|
768
|
+
return f"\n\n[PROJECT SESSION]\n{summary}" if summary else ""
|
|
769
|
+
|
|
535
770
|
def boundary(self) -> Dict[str, Any]:
|
|
536
771
|
return runtime_boundary_contract(
|
|
537
772
|
name="SingleAgentRuntime",
|
|
@@ -561,10 +796,11 @@ class SingleAgentRuntime:
|
|
|
561
796
|
) -> None:
|
|
562
797
|
"""PLAN: Planner role produces a structured plan JSON."""
|
|
563
798
|
d = self.deps
|
|
799
|
+
project_block = self._project_block(ctx)
|
|
564
800
|
context = (
|
|
565
801
|
f"{d.planner_prompt}\n\n"
|
|
566
802
|
f"[LANGUAGE HINT: {lang_hint}]\n"
|
|
567
|
-
f"Workspace root: {d.agent_root}\n\n"
|
|
803
|
+
f"Workspace root: {d.agent_root}{project_block}\n\n"
|
|
568
804
|
f"User request: {req.message}"
|
|
569
805
|
)
|
|
570
806
|
raw = await d.generate_as(
|
|
@@ -614,21 +850,20 @@ class SingleAgentRuntime:
|
|
|
614
850
|
weakening the gate itself.
|
|
615
851
|
"""
|
|
616
852
|
d = self.deps
|
|
617
|
-
|
|
853
|
+
mode = self.resolve_permission_mode(ctx)
|
|
618
854
|
# Governor-managed tools never hard-block the plan: each call is
|
|
619
855
|
# classified at execution time — additive creates run, mutations and
|
|
620
856
|
# deletions of existing content become review proposals.
|
|
621
|
-
governed_tools = (
|
|
622
|
-
frozenset(getattr(d.change_governor, "governed_tools", frozenset()))
|
|
623
|
-
if d.change_governor is not None else frozenset()
|
|
624
|
-
)
|
|
857
|
+
governed_tools = self._governed_tools()
|
|
625
858
|
steps = ctx.plan.get("steps", [])
|
|
626
|
-
non_auto =
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
859
|
+
non_auto = non_auto_plan_steps(
|
|
860
|
+
mode, steps, d.tool_governance or {}, governed_tools=governed_tools,
|
|
861
|
+
)
|
|
862
|
+
requires = plan_requires_approval(
|
|
863
|
+
mode,
|
|
864
|
+
non_auto_steps=non_auto,
|
|
865
|
+
plan_flag=bool(ctx.plan.get("requires_approval", False)),
|
|
866
|
+
)
|
|
632
867
|
lines = [
|
|
633
868
|
f"{index}. {step.get('description') or step.get('action') or '?'}"
|
|
634
869
|
for index, step in enumerate(steps, start=1)
|
|
@@ -639,6 +874,7 @@ class SingleAgentRuntime:
|
|
|
639
874
|
return {
|
|
640
875
|
"requires_approval": requires,
|
|
641
876
|
"non_auto_steps": non_auto,
|
|
877
|
+
"permission_mode": mode.value,
|
|
642
878
|
"plan_summary": summary,
|
|
643
879
|
}
|
|
644
880
|
|
|
@@ -681,13 +917,16 @@ class SingleAgentRuntime:
|
|
|
681
917
|
) -> None:
|
|
682
918
|
"""EXECUTE: Executor role calls tools one at a time until final or budget exhausted."""
|
|
683
919
|
d = self.deps
|
|
920
|
+
profile = self.profile_for(model_id)
|
|
684
921
|
exec_count = sum(1 for s in ctx.transcript if s.get("state") == AgentState.EXECUTING.value)
|
|
685
922
|
budget = max(1, max_steps - exec_count)
|
|
686
923
|
parse_failures = 0
|
|
687
924
|
|
|
688
925
|
for _ in range(budget):
|
|
689
926
|
request_workspace = getattr(req, "workspace_id", None)
|
|
690
|
-
context = self._executor_context(
|
|
927
|
+
context = self._executor_context(
|
|
928
|
+
ctx, req, lang_hint, current_user, request_workspace, profile=profile
|
|
929
|
+
)
|
|
691
930
|
raw = await d.generate_as(
|
|
692
931
|
model_id,
|
|
693
932
|
message="Execute the next step.",
|
|
@@ -700,7 +939,15 @@ class SingleAgentRuntime:
|
|
|
700
939
|
ctx.trace.repair("execute", repairs=exec_repairs)
|
|
701
940
|
except ValueError as exc:
|
|
702
941
|
parse_failures += 1
|
|
703
|
-
if self._note_parse_failure(ctx, raw, exc, parse_failures):
|
|
942
|
+
if self._note_parse_failure(ctx, raw, exc, parse_failures, profile):
|
|
943
|
+
# Direct-path fallback (v9.9.7): a small model that cannot
|
|
944
|
+
# hold the tool-call protocol can still write a file. Run
|
|
945
|
+
# the plan's own file steps without asking for any JSON.
|
|
946
|
+
if profile.direct_path_fallback and await self._direct_file_path(
|
|
947
|
+
ctx, req, current_user, model_id
|
|
948
|
+
):
|
|
949
|
+
ctx.state = AgentState.VERIFYING
|
|
950
|
+
return
|
|
704
951
|
break
|
|
705
952
|
continue
|
|
706
953
|
|
|
@@ -767,6 +1014,7 @@ class SingleAgentRuntime:
|
|
|
767
1014
|
def _executor_context(
|
|
768
1015
|
self, ctx: AgentRunContext, req: Any, lang_hint: str,
|
|
769
1016
|
current_user: str, request_workspace: Optional[str],
|
|
1017
|
+
profile: Optional[AgentProfile] = None,
|
|
770
1018
|
) -> str:
|
|
771
1019
|
"""Assemble one executor turn's prompt (plan, corrections, recent chat)."""
|
|
772
1020
|
d = self.deps
|
|
@@ -786,16 +1034,26 @@ class SingleAgentRuntime:
|
|
|
786
1034
|
recent_kwargs["workspace_id"] = request_workspace
|
|
787
1035
|
recent_conversation = d.recent_chat_context(**recent_kwargs) or "(none)"
|
|
788
1036
|
budget = self.transcript_budget
|
|
1037
|
+
# A small model drowns in a long transcript far sooner than a large
|
|
1038
|
+
# one, so the profile may narrow the window (v9.9.7).
|
|
1039
|
+
window = min(budget.window, profile.transcript_window) if profile else budget.window
|
|
789
1040
|
bounded_transcript = compact_transcript(
|
|
790
1041
|
ctx.transcript,
|
|
791
|
-
window=
|
|
1042
|
+
window=window,
|
|
792
1043
|
result_chars=budget.result_chars,
|
|
793
1044
|
)
|
|
1045
|
+
# Mid-run workspace awareness (review L5): later steps must see what
|
|
1046
|
+
# this run already produced instead of a stale workspace picture.
|
|
1047
|
+
written = files_written(ctx.transcript, d.file_create_actions)
|
|
1048
|
+
written_hint = (
|
|
1049
|
+
"\n\nFiles written by this run so far (they exist in the workspace now):\n"
|
|
1050
|
+
+ "\n".join(f"- {path}" for path in written)
|
|
1051
|
+
) if written else ""
|
|
794
1052
|
return (
|
|
795
1053
|
f"{d.executor_prompt}\n\n"
|
|
796
1054
|
f"[LANGUAGE HINT: {lang_hint}]\n"
|
|
797
|
-
f"Workspace root: {d.agent_root}\n\n"
|
|
798
|
-
f"PLAN:\n{json.dumps(ctx.plan, ensure_ascii=False)}\n\n"
|
|
1055
|
+
f"Workspace root: {d.agent_root}{self._project_block(ctx)}\n\n"
|
|
1056
|
+
f"PLAN:\n{json.dumps(ctx.plan, ensure_ascii=False)}{written_hint}\n\n"
|
|
799
1057
|
f"Recent conversation:\n{recent_conversation}\n\n"
|
|
800
1058
|
f"User request: {req.message}{corrections_hint}\n\n"
|
|
801
1059
|
f"Execution transcript:\n{json.dumps(bounded_transcript, ensure_ascii=False, indent=2)}"
|
|
@@ -803,13 +1061,15 @@ class SingleAgentRuntime:
|
|
|
803
1061
|
|
|
804
1062
|
def _note_parse_failure(
|
|
805
1063
|
self, ctx: AgentRunContext, raw: Any, exc: ValueError, parse_failures: int,
|
|
1064
|
+
profile: Optional[AgentProfile] = None,
|
|
806
1065
|
) -> bool:
|
|
807
1066
|
"""Record one executor parse slip; True when the run should stop retrying."""
|
|
1067
|
+
profile = profile or self.profile_for(None)
|
|
808
1068
|
ctx.transcript.append({
|
|
809
1069
|
"state": AgentState.EXECUTING.value, "action": "parse_error",
|
|
810
1070
|
"raw": str(raw)[:400], "error": str(exc),
|
|
811
1071
|
})
|
|
812
|
-
if parse_failures >=
|
|
1072
|
+
if parse_failures >= profile.parse_failure_budget:
|
|
813
1073
|
ctx.trace.parse_error("execute", error=str(exc), recovered=False)
|
|
814
1074
|
self._emit_step(ctx, "execute", "parse_error", recovered=False)
|
|
815
1075
|
return True
|
|
@@ -823,9 +1083,10 @@ class SingleAgentRuntime:
|
|
|
823
1083
|
'EXACTLY one JSON object like {"thoughts": "...", "action": '
|
|
824
1084
|
'"tool_name", "args": {...}} and nothing else.'
|
|
825
1085
|
)
|
|
826
|
-
if parse_failures >=
|
|
1086
|
+
if parse_failures >= profile.escalate_after:
|
|
827
1087
|
# Escalate: name the valid tools so the model stops
|
|
828
|
-
# inventing action names or prose.
|
|
1088
|
+
# inventing action names or prose. The compact profile escalates
|
|
1089
|
+
# a slip earlier — a small model needs the list sooner.
|
|
829
1090
|
valid = ", ".join(sorted(self.deps.tool_governance.keys()))
|
|
830
1091
|
hint = (
|
|
831
1092
|
f"{hint} Valid action values are: {valid}, final. "
|
|
@@ -836,6 +1097,84 @@ class SingleAgentRuntime:
|
|
|
836
1097
|
ctx.trace.correction("execute", hint=hint)
|
|
837
1098
|
return False
|
|
838
1099
|
|
|
1100
|
+
async def _direct_file_path(
|
|
1101
|
+
self, ctx: AgentRunContext, req: Any, current_user: str,
|
|
1102
|
+
model_id: Optional[str],
|
|
1103
|
+
) -> bool:
|
|
1104
|
+
"""Write the plan's file steps without asking the model for JSON (v9.9.7).
|
|
1105
|
+
|
|
1106
|
+
The compact profile's escape hatch. A 1–4B local model that cannot hold
|
|
1107
|
+
the tool-call protocol can still write a file, so when JSON tool calls
|
|
1108
|
+
are exhausted the loop drops the protocol entirely: it takes the paths
|
|
1109
|
+
the *planner* already chose and asks only for file content in plain
|
|
1110
|
+
text, through the same validated
|
|
1111
|
+
:func:`~latticeai.core.file_generation.generate_file_content` pipeline
|
|
1112
|
+
the direct chat path uses.
|
|
1113
|
+
|
|
1114
|
+
Returns True when at least one file was actually written. Honest
|
|
1115
|
+
failure modes: no planned paths, a governor that stages the write as a
|
|
1116
|
+
proposal, or a tool error all return False and leave the run to end as
|
|
1117
|
+
it would have — this never fabricates evidence.
|
|
1118
|
+
"""
|
|
1119
|
+
d = self.deps
|
|
1120
|
+
planned: List[str] = []
|
|
1121
|
+
for step in ctx.plan.get("steps") or []:
|
|
1122
|
+
if not isinstance(step, dict) or step.get("action") not in d.file_create_actions:
|
|
1123
|
+
continue
|
|
1124
|
+
path = str((step.get("args") or {}).get("path") or "").strip()
|
|
1125
|
+
if path and path not in planned:
|
|
1126
|
+
planned.append(path)
|
|
1127
|
+
if not planned:
|
|
1128
|
+
inferred = infer_file_target(getattr(req, "message", "") or "")
|
|
1129
|
+
if inferred:
|
|
1130
|
+
planned = [inferred]
|
|
1131
|
+
if not planned:
|
|
1132
|
+
return False
|
|
1133
|
+
|
|
1134
|
+
goal = str(ctx.plan.get("goal") or getattr(req, "message", "") or "")
|
|
1135
|
+
|
|
1136
|
+
async def _generate(context: str) -> Any:
|
|
1137
|
+
return await d.generate_as(
|
|
1138
|
+
model_id,
|
|
1139
|
+
message="Write the file content.",
|
|
1140
|
+
context=context,
|
|
1141
|
+
max_tokens=self.phase_budgets.execute_tokens,
|
|
1142
|
+
temperature=0.2,
|
|
1143
|
+
)
|
|
1144
|
+
|
|
1145
|
+
wrote = False
|
|
1146
|
+
for path in planned[:6]:
|
|
1147
|
+
try:
|
|
1148
|
+
content, meta = await generate_file_content(
|
|
1149
|
+
_generate,
|
|
1150
|
+
target_path=path,
|
|
1151
|
+
user_request=goal,
|
|
1152
|
+
bundle_files=planned if len(planned) > 1 else None,
|
|
1153
|
+
)
|
|
1154
|
+
except Exception as exc: # noqa: BLE001 — fallback must not raise
|
|
1155
|
+
logging.warning("direct file path generation failed for %s: %s", path, exc)
|
|
1156
|
+
continue
|
|
1157
|
+
ctx.trace.llm_call("execute", model=model_id)
|
|
1158
|
+
ctx.trace.repair("execute", repairs=["direct_path_fallback"])
|
|
1159
|
+
args = {"path": path, "content": content}
|
|
1160
|
+
policy = d.policy_for("write_file", args)
|
|
1161
|
+
risk = d.risk_level(policy)
|
|
1162
|
+
before = len(ctx.transcript)
|
|
1163
|
+
self._dispatch_step(ctx, "write_file", "direct path fallback", args, policy, risk, current_user)
|
|
1164
|
+
last = ctx.transcript[-1] if len(ctx.transcript) > before else {}
|
|
1165
|
+
if isinstance(last.get("result"), dict) and not last["result"].get("proposed"):
|
|
1166
|
+
wrote = True
|
|
1167
|
+
last["direct_path"] = True
|
|
1168
|
+
last["generation"] = {"repaired": bool(meta.get("repaired"))}
|
|
1169
|
+
if wrote:
|
|
1170
|
+
ctx.trace.decision("execute", decision="direct_path_fallback", files=len(planned))
|
|
1171
|
+
self._emit_step(ctx, "execute", "direct_path", files=len(planned))
|
|
1172
|
+
ctx.final_message = (
|
|
1173
|
+
"도구 호출 형식을 계속 벗어나서, 계획에 있던 파일을 직접 생성했습니다. "
|
|
1174
|
+
"내용을 확인해 주세요."
|
|
1175
|
+
)
|
|
1176
|
+
return wrote
|
|
1177
|
+
|
|
839
1178
|
def _is_repeated_create(self, ctx: AgentRunContext, name: Any, args: dict) -> bool:
|
|
840
1179
|
"""Loop guard: the same file-create action+args re-issued right after a result."""
|
|
841
1180
|
exec_steps = [s for s in ctx.transcript if s.get("state") == AgentState.EXECUTING.value]
|
|
@@ -858,10 +1197,37 @@ class SingleAgentRuntime:
|
|
|
858
1197
|
Returns ``(proposed, governor_allows_additive)``: ``proposed`` means the
|
|
859
1198
|
step was staged as a proposal (skip execution); ``allows_additive`` lets
|
|
860
1199
|
an additive create pass the classic approval gate.
|
|
1200
|
+
|
|
1201
|
+
Under a mode that does not stage proposals (``trusted`` / ``bypass``)
|
|
1202
|
+
the decision is made *before* the governor is consulted, because
|
|
1203
|
+
``review`` persists a proposal as a side effect — reviewing first and
|
|
1204
|
+
discarding the verdict afterwards would apply the change *and* leave an
|
|
1205
|
+
orphan proposal pending in the Review Center.
|
|
861
1206
|
"""
|
|
862
1207
|
d = self.deps
|
|
863
1208
|
if d.change_governor is None:
|
|
864
1209
|
return False, False
|
|
1210
|
+
|
|
1211
|
+
mode = self.resolve_permission_mode(
|
|
1212
|
+
ctx, user_email=current_user, workspace_id=request_workspace,
|
|
1213
|
+
)
|
|
1214
|
+
if not should_stage_proposal(mode, proposal_required=True):
|
|
1215
|
+
if name not in self._governed_tools():
|
|
1216
|
+
return False, False
|
|
1217
|
+
if policy.get("destructive") or policy.get("risk") == "destructive":
|
|
1218
|
+
# Let the destructive gate downstream own the block + transcript.
|
|
1219
|
+
return False, False
|
|
1220
|
+
d.audit(
|
|
1221
|
+
"agent_change_auto_applied",
|
|
1222
|
+
user_email=current_user,
|
|
1223
|
+
workspace_id=request_workspace,
|
|
1224
|
+
action=name,
|
|
1225
|
+
path=str(args.get("path") or "") or None,
|
|
1226
|
+
permission_mode=mode.value,
|
|
1227
|
+
note="permission mode auto-applies mutation with audit",
|
|
1228
|
+
)
|
|
1229
|
+
return False, True
|
|
1230
|
+
|
|
865
1231
|
verdict = d.change_governor.review(
|
|
866
1232
|
name, args, policy=dict(policy),
|
|
867
1233
|
user_email=current_user, workspace_id=request_workspace,
|
|
@@ -893,16 +1259,42 @@ class SingleAgentRuntime:
|
|
|
893
1259
|
self, ctx: AgentRunContext, req: Any, name: str, thoughts: str, args: dict,
|
|
894
1260
|
policy: dict, risk: str, current_user: str, governor_allows_additive: bool,
|
|
895
1261
|
) -> bool:
|
|
896
|
-
"""
|
|
1262
|
+
"""Destructive / circuit-breaker / explicit-approval gates.
|
|
1263
|
+
|
|
1264
|
+
Returns True when the step was blocked. The active permission mode can
|
|
1265
|
+
widen what runs without an extra approval prompt, but never widens a
|
|
1266
|
+
circuit breaker or the destructive gate.
|
|
1267
|
+
"""
|
|
897
1268
|
d = self.deps
|
|
898
|
-
|
|
1269
|
+
mode = self.resolve_permission_mode(
|
|
1270
|
+
ctx,
|
|
1271
|
+
user_email=current_user,
|
|
1272
|
+
workspace_id=getattr(req, "workspace_id", None),
|
|
1273
|
+
)
|
|
1274
|
+
# Hard denials first — mode-invariant. A circuit breaker (root/home
|
|
1275
|
+
# paths, `rm -rf /` style commands) and a destructive policy are both
|
|
1276
|
+
# audited as ``blocked`` with the reason that actually fired, rather
|
|
1277
|
+
# than being flattened into the approval path.
|
|
1278
|
+
breaker = is_circuit_breaker(name, policy, args)
|
|
1279
|
+
hard_deny = breaker or (
|
|
1280
|
+
"destructive policy"
|
|
1281
|
+
if policy["risk"] == "destructive" or policy.get("destructive")
|
|
1282
|
+
else None
|
|
1283
|
+
)
|
|
1284
|
+
if hard_deny:
|
|
1285
|
+
error = (
|
|
1286
|
+
f"BLOCKED: destructive action '{name}' not permitted in agent mode."
|
|
1287
|
+
if hard_deny == "destructive policy"
|
|
1288
|
+
else f"BLOCKED: {hard_deny}"
|
|
1289
|
+
)
|
|
899
1290
|
ctx.trace.tool("execute", name=name, outcome="blocked_destructive", risk=risk)
|
|
900
1291
|
self._emit_step(ctx, "execute", "blocked", action=name, reason="destructive")
|
|
901
1292
|
ctx.transcript.append({
|
|
902
1293
|
"state": AgentState.EXECUTING.value, "action": name,
|
|
903
1294
|
"thoughts": thoughts, "args": args, "risk": risk,
|
|
904
1295
|
"governance": dict(policy),
|
|
905
|
-
"
|
|
1296
|
+
"permission_mode": mode.value,
|
|
1297
|
+
"error": error,
|
|
906
1298
|
})
|
|
907
1299
|
d.audit(
|
|
908
1300
|
"agent_blocked", user_email=current_user, source=getattr(req, "source", None) or "agent",
|
|
@@ -910,25 +1302,33 @@ class SingleAgentRuntime:
|
|
|
910
1302
|
)
|
|
911
1303
|
return True
|
|
912
1304
|
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
1305
|
+
reason = block_reason_for_tool(
|
|
1306
|
+
mode, name, policy, args,
|
|
1307
|
+
approved_by_human=bool(ctx.approved_by_human),
|
|
1308
|
+
governor_allows_additive=governor_allows_additive,
|
|
1309
|
+
)
|
|
1310
|
+
if reason is None:
|
|
1311
|
+
return False
|
|
1312
|
+
|
|
1313
|
+
d.audit(
|
|
1314
|
+
"agent_exec", user_email=current_user, source=getattr(req, "source", None) or "agent",
|
|
1315
|
+
state=AgentState.EXECUTING.value, action=name, risk=risk,
|
|
1316
|
+
shell=policy["shell"], network=policy["network"],
|
|
1317
|
+
destructive=policy["destructive"], sandbox=policy["sandbox"],
|
|
1318
|
+
rollback=policy["rollback"],
|
|
1319
|
+
permission_mode=mode.value,
|
|
1320
|
+
args={k: v for k, v in args.items() if k != "content"},
|
|
1321
|
+
)
|
|
1322
|
+
ctx.trace.tool("execute", name=name, outcome="blocked_approval", risk=risk)
|
|
1323
|
+
self._emit_step(ctx, "execute", "blocked", action=name, reason="approval")
|
|
1324
|
+
ctx.transcript.append({
|
|
1325
|
+
"state": AgentState.EXECUTING.value, "action": name,
|
|
1326
|
+
"thoughts": thoughts, "args": args, "risk": risk,
|
|
1327
|
+
"governance": dict(policy),
|
|
1328
|
+
"permission_mode": mode.value,
|
|
1329
|
+
"error": reason,
|
|
1330
|
+
})
|
|
1331
|
+
return True
|
|
932
1332
|
|
|
933
1333
|
def _dispatch_step(
|
|
934
1334
|
self, ctx: AgentRunContext, name: str, thoughts: str, args: dict,
|
|
@@ -961,6 +1361,22 @@ class SingleAgentRuntime:
|
|
|
961
1361
|
if s.get("state") == AgentState.EXECUTING.value
|
|
962
1362
|
and s.get("action") not in (None, "final", "parse_error")
|
|
963
1363
|
)
|
|
1364
|
+
if (
|
|
1365
|
+
name in d.file_create_actions
|
|
1366
|
+
and d.snapshot_file is not None
|
|
1367
|
+
and args.get("path")
|
|
1368
|
+
):
|
|
1369
|
+
# Pre-write snapshot (review L7): the first capture per path is
|
|
1370
|
+
# the true pre-run state — later writes to the same path must
|
|
1371
|
+
# not overwrite it. Best-effort: a snapshot failure never
|
|
1372
|
+
# blocks the write, it only narrows rollback options.
|
|
1373
|
+
path_str = str(args["path"])
|
|
1374
|
+
if not any(entry.get("path") == path_str for entry in ctx.rollback_log):
|
|
1375
|
+
try:
|
|
1376
|
+
pre = d.snapshot_file(path_str)
|
|
1377
|
+
ctx.rollback_log.append({"path": path_str, **(pre or {})})
|
|
1378
|
+
except Exception as exc: # noqa: BLE001
|
|
1379
|
+
logging.warning("pre-write snapshot failed for %s: %s", path_str, exc)
|
|
964
1380
|
try:
|
|
965
1381
|
d.check_role(name, current_user)
|
|
966
1382
|
# Shared tool lifecycle: pre_tool (may block) → execute → post_tool.
|
|
@@ -1025,11 +1441,25 @@ class SingleAgentRuntime:
|
|
|
1025
1441
|
verify_transcript = _truncate_strings(
|
|
1026
1442
|
ctx.transcript, self.transcript_budget.verify_chars
|
|
1027
1443
|
)
|
|
1444
|
+
# Deterministic artifact facts (review L4): the critic sees the
|
|
1445
|
+
# sanitize/repair honesty flags per written file, not just prose.
|
|
1446
|
+
checklist = artifact_checklist(ctx.transcript, d.file_create_actions)
|
|
1447
|
+
checklist_hint = (
|
|
1448
|
+
f"\n\n{_format_artifact_checklist(checklist)}" if checklist else ""
|
|
1449
|
+
)
|
|
1450
|
+
# Requirement coverage (review 루프 §2): the critic previously judged
|
|
1451
|
+
# "did this fulfill the request?" from prose alone. It now also sees
|
|
1452
|
+
# which requested files actually exist and which requirements the user
|
|
1453
|
+
# spelled out.
|
|
1454
|
+
coverage = requirement_coverage(
|
|
1455
|
+
req.message, ctx.transcript, d.file_create_actions
|
|
1456
|
+
)
|
|
1028
1457
|
context = (
|
|
1029
1458
|
f"{d.critic_prompt}\n\n"
|
|
1030
1459
|
f"[LANGUAGE HINT: {lang_hint}]\n\n"
|
|
1031
1460
|
f"Original request: {req.message}\n"
|
|
1032
|
-
f"Plan goal: {ctx.plan.get('goal', req.message)}
|
|
1461
|
+
f"Plan goal: {ctx.plan.get('goal', req.message)}{checklist_hint}"
|
|
1462
|
+
f"{_format_requirement_coverage(coverage)}\n\n"
|
|
1033
1463
|
f"Full transcript:\n{json.dumps(verify_transcript, ensure_ascii=False, indent=2)}"
|
|
1034
1464
|
)
|
|
1035
1465
|
raw = await d.generate_as(
|
|
@@ -1129,6 +1559,24 @@ class SingleAgentRuntime:
|
|
|
1129
1559
|
)
|
|
1130
1560
|
ctx.state = AgentState.NEEDS_REVIEW
|
|
1131
1561
|
return
|
|
1562
|
+
if not coverage["complete"]:
|
|
1563
|
+
# A PASS that leaves a *requested file* unwritten is not a
|
|
1564
|
+
# completion — this is a fact, not a judgement, so it is
|
|
1565
|
+
# enforced rather than merely reported to the critic.
|
|
1566
|
+
missing = ", ".join(coverage["missing_files"])
|
|
1567
|
+
ctx.trace.decision(
|
|
1568
|
+
"verify", decision="needs_review_missing_files",
|
|
1569
|
+
missing=len(coverage["missing_files"]),
|
|
1570
|
+
)
|
|
1571
|
+
ctx.transcript.append({
|
|
1572
|
+
"state": AgentState.VERIFYING.value,
|
|
1573
|
+
"requirement_coverage": coverage,
|
|
1574
|
+
})
|
|
1575
|
+
ctx.final_message = (
|
|
1576
|
+
f"요청한 파일 중 일부가 만들어지지 않아 완료로 처리하지 않았습니다: {missing}"
|
|
1577
|
+
)
|
|
1578
|
+
ctx.state = AgentState.NEEDS_REVIEW
|
|
1579
|
+
return
|
|
1132
1580
|
if not ctx.final_message:
|
|
1133
1581
|
ctx.final_message = verdict.get("reason", "작업이 완료되었습니다.")
|
|
1134
1582
|
ctx.state = AgentState.DONE
|
|
@@ -1162,40 +1610,68 @@ class SingleAgentRuntime:
|
|
|
1162
1610
|
ctx.state = AgentState.FAILED
|
|
1163
1611
|
|
|
1164
1612
|
# ── ROLLBACK ─────────────────────────────────────────────────────
|
|
1613
|
+
def _snapshot_for(self, ctx: AgentRunContext, path: str) -> Optional[Dict[str, Any]]:
|
|
1614
|
+
for entry in ctx.rollback_log:
|
|
1615
|
+
if entry.get("path") == path:
|
|
1616
|
+
return entry
|
|
1617
|
+
return None
|
|
1618
|
+
|
|
1619
|
+
def _rollback_one(self, ctx: AgentRunContext, path: str, gov: Dict[str, Any]) -> Dict[str, Any]:
|
|
1620
|
+
"""Recover one path: git when governed and available, else the
|
|
1621
|
+
pre-write snapshot, else an honest ``mode="none"`` (review L7)."""
|
|
1622
|
+
d = self.deps
|
|
1623
|
+
if gov.get("rollback") == "git" and d.rollback_file is not None:
|
|
1624
|
+
try:
|
|
1625
|
+
result = dict(d.rollback_file(str(path)))
|
|
1626
|
+
except Exception as exc: # noqa: BLE001
|
|
1627
|
+
result = {"path": path, "ok": False, "error": str(exc)}
|
|
1628
|
+
if result.get("ok"):
|
|
1629
|
+
result["mode"] = "git"
|
|
1630
|
+
return result
|
|
1631
|
+
snapshot = self._snapshot_for(ctx, str(path))
|
|
1632
|
+
if snapshot is not None and d.restore_snapshot is not None and not snapshot.get("too_large"):
|
|
1633
|
+
content = snapshot.get("content") if snapshot.get("existed") else None
|
|
1634
|
+
try:
|
|
1635
|
+
restored = dict(d.restore_snapshot(str(path), content))
|
|
1636
|
+
except Exception as exc: # noqa: BLE001
|
|
1637
|
+
restored = {"path": path, "ok": False, "error": str(exc)}
|
|
1638
|
+
restored.setdefault("path", path)
|
|
1639
|
+
restored["mode"] = "snapshot"
|
|
1640
|
+
return restored
|
|
1641
|
+
return {
|
|
1642
|
+
"path": path, "ok": False, "mode": "none",
|
|
1643
|
+
"error": "no rollback available (git not applicable, no usable snapshot)",
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1165
1646
|
def rollback(self, ctx: AgentRunContext, current_user: str) -> None:
|
|
1166
|
-
"""ROLLBACK:
|
|
1647
|
+
"""ROLLBACK: recover written files (git → snapshot → none), then FAILED."""
|
|
1167
1648
|
d = self.deps
|
|
1168
1649
|
rolled: List[dict] = []
|
|
1650
|
+
seen_paths: set = set()
|
|
1169
1651
|
for step in ctx.transcript:
|
|
1170
1652
|
if step.get("state") != AgentState.EXECUTING.value:
|
|
1171
1653
|
continue
|
|
1172
|
-
|
|
1173
|
-
if gov.get("rollback") != "git":
|
|
1654
|
+
if not isinstance(step.get("result"), dict):
|
|
1174
1655
|
continue
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
path = result.get("path") or (step.get("args") or {}).get("path", "")
|
|
1179
|
-
if not path:
|
|
1656
|
+
gov = step.get("governance", {}) or {}
|
|
1657
|
+
path = step["result"].get("path") or (step.get("args") or {}).get("path", "")
|
|
1658
|
+
if not path or str(path) in seen_paths:
|
|
1180
1659
|
continue
|
|
1181
|
-
if
|
|
1182
|
-
rolled.append({"path": path, "ok": False, "error": "rollback_file port is not configured"})
|
|
1660
|
+
if gov.get("rollback") != "git" and step.get("action") not in d.file_create_actions:
|
|
1183
1661
|
continue
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
except Exception as exc:
|
|
1187
|
-
rolled.append({"path": path, "ok": False, "error": str(exc)})
|
|
1662
|
+
seen_paths.add(str(path))
|
|
1663
|
+
rolled.append(self._rollback_one(ctx, str(path), gov))
|
|
1188
1664
|
|
|
1189
1665
|
ctx.transcript.append({"state": AgentState.ROLLBACK.value, "rolled_back": rolled})
|
|
1190
1666
|
ctx.trace.decision(
|
|
1191
1667
|
"rollback", decision="rolled_back",
|
|
1192
1668
|
attempted=len(rolled), recovered=sum(1 for r in rolled if r.get("ok")),
|
|
1193
1669
|
)
|
|
1194
|
-
recovered = [r[
|
|
1670
|
+
recovered = [f"{r['path']} ({r.get('mode')})" for r in rolled if r.get("ok")]
|
|
1195
1671
|
ctx.final_message = (
|
|
1196
1672
|
f"실행 실패로 롤백했습니다. 복구 파일: {recovered}"
|
|
1197
1673
|
if recovered
|
|
1198
|
-
else "롤백을 시도했으나 복구할 파일이 없거나 git
|
|
1674
|
+
else "롤백을 시도했으나 복구할 파일이 없거나 git/스냅샷 복구 수단이 없습니다."
|
|
1199
1675
|
)
|
|
1200
1676
|
d.audit("agent_rollback", user_email=current_user, rolled_back=rolled)
|
|
1201
1677
|
self._emit_step(ctx, "rollback", "rolled_back", recovered=len(recovered))
|