ltcai 9.9.5 → 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 +41 -36
- package/docs/CHANGELOG.md +193 -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 +38 -8
- 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/retrieval_vector.py +10 -0
- package/lattice_brain/ingestion.py +9 -106
- package/lattice_brain/ingestion_jobs.py +133 -0
- 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 +119 -2
- package/latticeai/api/chat_contracts.py +9 -0
- 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 +381 -43
- 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 +5 -0
- package/latticeai/core/workspace_os.py +15 -80
- package/latticeai/core/workspace_review_items.py +107 -0
- package/latticeai/integrations/telegram_bot.py +188 -0
- 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 +73 -4
- package/latticeai/services/voice_capture.py +189 -0
- package/package.json +5 -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/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-VmP0kkeA.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-T9POo4qy.js → circle-pause--qxF9-EO.js} +1 -1
- package/static/app/assets/{circle-play-Bz5iTD0p.js → circle-play-89x3S6jm.js} +1 -1
- package/static/app/assets/{cpu-CJhjRtNq.js → cpu-DTUI63vo.js} +1 -1
- package/static/app/assets/download-EJS5_O0f.js +1 -0
- package/static/app/assets/{folder-open-B2K_22VI.js → folder-open-BL9zYTnc.js} +1 -1
- package/static/app/assets/{hard-drive-CjJtYqHf.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-Ck1nqc-v.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-CsnIhsDJ.js → useFocusTrap-58TNCONM.js} +1 -1
- package/static/app/assets/{navigation-BPQqEQwr.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-DmxfbqBp.js +0 -1
- package/static/app/assets/Brain-DK_zKCda.js +0 -321
- package/static/app/assets/BrainHome-CCE_0hni.js +0 -2
- package/static/app/assets/BrainSignals-r_J68_zh.js +0 -1
- package/static/app/assets/Capture-CvxwzpJZ.js +0 -1
- package/static/app/assets/CommandPalette-CVLiFIQC.js +0 -1
- package/static/app/assets/Library-4As9VUvy.js +0 -1
- package/static/app/assets/LivingBrain-BVWk58MW.js +0 -1
- package/static/app/assets/ProductFlow-B-Tz0Um4.js +0 -1
- package/static/app/assets/ReviewCard-Va3fPQuj.js +0 -3
- package/static/app/assets/System-DSR2zKWd.js +0 -1
- package/static/app/assets/index-7FAfYm4v.css +0 -2
- package/static/app/assets/index-CEu0Wqjl.js +0 -10
- package/static/app/assets/input-CSi6OBJ9.js +0 -1
- package/static/app/assets/primitives-B2f9N7Dh.js +0 -1
- package/static/app/assets/shield-alert-yA_Y8lOB.js +0 -1
- package/static/app/assets/textarea-C4wM0D-Z.js +0 -1
- package/static/app/assets/utils-BqoznzGP.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(
|
|
@@ -394,6 +417,75 @@ def artifact_checklist(
|
|
|
394
417
|
return checklist
|
|
395
418
|
|
|
396
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
|
+
|
|
397
489
|
def _format_artifact_checklist(checklist: List[Dict[str, Any]]) -> str:
|
|
398
490
|
lines = []
|
|
399
491
|
for item in checklist:
|
|
@@ -565,6 +657,19 @@ class AgentDeps:
|
|
|
565
657
|
# can also be attached on AgentRunContext.on_step. Both are advisory.
|
|
566
658
|
on_step: Optional[Callable[[Dict[str, Any]], None]] = None
|
|
567
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
|
+
|
|
568
673
|
|
|
569
674
|
class SingleAgentRuntime:
|
|
570
675
|
"""Drives the agent state machine over injected :class:`AgentDeps`."""
|
|
@@ -594,6 +699,44 @@ class SingleAgentRuntime:
|
|
|
594
699
|
self._env_transcript_budget = TranscriptBudget.from_env()
|
|
595
700
|
return self._env_transcript_budget
|
|
596
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
|
+
|
|
597
740
|
def _emit_step(self, ctx: AgentRunContext, phase: str, event: str, **details: Any) -> None:
|
|
598
741
|
"""Fire the per-run / deps step observers (review Wave 1.1).
|
|
599
742
|
|
|
@@ -613,6 +756,17 @@ class SingleAgentRuntime:
|
|
|
613
756
|
except Exception as exc: # noqa: BLE001 — observers are advisory
|
|
614
757
|
logging.warning("agent step observer failed: %s", exc)
|
|
615
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
|
+
|
|
616
770
|
def boundary(self) -> Dict[str, Any]:
|
|
617
771
|
return runtime_boundary_contract(
|
|
618
772
|
name="SingleAgentRuntime",
|
|
@@ -642,10 +796,11 @@ class SingleAgentRuntime:
|
|
|
642
796
|
) -> None:
|
|
643
797
|
"""PLAN: Planner role produces a structured plan JSON."""
|
|
644
798
|
d = self.deps
|
|
799
|
+
project_block = self._project_block(ctx)
|
|
645
800
|
context = (
|
|
646
801
|
f"{d.planner_prompt}\n\n"
|
|
647
802
|
f"[LANGUAGE HINT: {lang_hint}]\n"
|
|
648
|
-
f"Workspace root: {d.agent_root}\n\n"
|
|
803
|
+
f"Workspace root: {d.agent_root}{project_block}\n\n"
|
|
649
804
|
f"User request: {req.message}"
|
|
650
805
|
)
|
|
651
806
|
raw = await d.generate_as(
|
|
@@ -695,21 +850,20 @@ class SingleAgentRuntime:
|
|
|
695
850
|
weakening the gate itself.
|
|
696
851
|
"""
|
|
697
852
|
d = self.deps
|
|
698
|
-
|
|
853
|
+
mode = self.resolve_permission_mode(ctx)
|
|
699
854
|
# Governor-managed tools never hard-block the plan: each call is
|
|
700
855
|
# classified at execution time — additive creates run, mutations and
|
|
701
856
|
# deletions of existing content become review proposals.
|
|
702
|
-
governed_tools = (
|
|
703
|
-
frozenset(getattr(d.change_governor, "governed_tools", frozenset()))
|
|
704
|
-
if d.change_governor is not None else frozenset()
|
|
705
|
-
)
|
|
857
|
+
governed_tools = self._governed_tools()
|
|
706
858
|
steps = ctx.plan.get("steps", [])
|
|
707
|
-
non_auto =
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
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
|
+
)
|
|
713
867
|
lines = [
|
|
714
868
|
f"{index}. {step.get('description') or step.get('action') or '?'}"
|
|
715
869
|
for index, step in enumerate(steps, start=1)
|
|
@@ -720,6 +874,7 @@ class SingleAgentRuntime:
|
|
|
720
874
|
return {
|
|
721
875
|
"requires_approval": requires,
|
|
722
876
|
"non_auto_steps": non_auto,
|
|
877
|
+
"permission_mode": mode.value,
|
|
723
878
|
"plan_summary": summary,
|
|
724
879
|
}
|
|
725
880
|
|
|
@@ -762,13 +917,16 @@ class SingleAgentRuntime:
|
|
|
762
917
|
) -> None:
|
|
763
918
|
"""EXECUTE: Executor role calls tools one at a time until final or budget exhausted."""
|
|
764
919
|
d = self.deps
|
|
920
|
+
profile = self.profile_for(model_id)
|
|
765
921
|
exec_count = sum(1 for s in ctx.transcript if s.get("state") == AgentState.EXECUTING.value)
|
|
766
922
|
budget = max(1, max_steps - exec_count)
|
|
767
923
|
parse_failures = 0
|
|
768
924
|
|
|
769
925
|
for _ in range(budget):
|
|
770
926
|
request_workspace = getattr(req, "workspace_id", None)
|
|
771
|
-
context = self._executor_context(
|
|
927
|
+
context = self._executor_context(
|
|
928
|
+
ctx, req, lang_hint, current_user, request_workspace, profile=profile
|
|
929
|
+
)
|
|
772
930
|
raw = await d.generate_as(
|
|
773
931
|
model_id,
|
|
774
932
|
message="Execute the next step.",
|
|
@@ -781,7 +939,15 @@ class SingleAgentRuntime:
|
|
|
781
939
|
ctx.trace.repair("execute", repairs=exec_repairs)
|
|
782
940
|
except ValueError as exc:
|
|
783
941
|
parse_failures += 1
|
|
784
|
-
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
|
|
785
951
|
break
|
|
786
952
|
continue
|
|
787
953
|
|
|
@@ -848,6 +1014,7 @@ class SingleAgentRuntime:
|
|
|
848
1014
|
def _executor_context(
|
|
849
1015
|
self, ctx: AgentRunContext, req: Any, lang_hint: str,
|
|
850
1016
|
current_user: str, request_workspace: Optional[str],
|
|
1017
|
+
profile: Optional[AgentProfile] = None,
|
|
851
1018
|
) -> str:
|
|
852
1019
|
"""Assemble one executor turn's prompt (plan, corrections, recent chat)."""
|
|
853
1020
|
d = self.deps
|
|
@@ -867,9 +1034,12 @@ class SingleAgentRuntime:
|
|
|
867
1034
|
recent_kwargs["workspace_id"] = request_workspace
|
|
868
1035
|
recent_conversation = d.recent_chat_context(**recent_kwargs) or "(none)"
|
|
869
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
|
|
870
1040
|
bounded_transcript = compact_transcript(
|
|
871
1041
|
ctx.transcript,
|
|
872
|
-
window=
|
|
1042
|
+
window=window,
|
|
873
1043
|
result_chars=budget.result_chars,
|
|
874
1044
|
)
|
|
875
1045
|
# Mid-run workspace awareness (review L5): later steps must see what
|
|
@@ -882,7 +1052,7 @@ class SingleAgentRuntime:
|
|
|
882
1052
|
return (
|
|
883
1053
|
f"{d.executor_prompt}\n\n"
|
|
884
1054
|
f"[LANGUAGE HINT: {lang_hint}]\n"
|
|
885
|
-
f"Workspace root: {d.agent_root}\n\n"
|
|
1055
|
+
f"Workspace root: {d.agent_root}{self._project_block(ctx)}\n\n"
|
|
886
1056
|
f"PLAN:\n{json.dumps(ctx.plan, ensure_ascii=False)}{written_hint}\n\n"
|
|
887
1057
|
f"Recent conversation:\n{recent_conversation}\n\n"
|
|
888
1058
|
f"User request: {req.message}{corrections_hint}\n\n"
|
|
@@ -891,13 +1061,15 @@ class SingleAgentRuntime:
|
|
|
891
1061
|
|
|
892
1062
|
def _note_parse_failure(
|
|
893
1063
|
self, ctx: AgentRunContext, raw: Any, exc: ValueError, parse_failures: int,
|
|
1064
|
+
profile: Optional[AgentProfile] = None,
|
|
894
1065
|
) -> bool:
|
|
895
1066
|
"""Record one executor parse slip; True when the run should stop retrying."""
|
|
1067
|
+
profile = profile or self.profile_for(None)
|
|
896
1068
|
ctx.transcript.append({
|
|
897
1069
|
"state": AgentState.EXECUTING.value, "action": "parse_error",
|
|
898
1070
|
"raw": str(raw)[:400], "error": str(exc),
|
|
899
1071
|
})
|
|
900
|
-
if parse_failures >=
|
|
1072
|
+
if parse_failures >= profile.parse_failure_budget:
|
|
901
1073
|
ctx.trace.parse_error("execute", error=str(exc), recovered=False)
|
|
902
1074
|
self._emit_step(ctx, "execute", "parse_error", recovered=False)
|
|
903
1075
|
return True
|
|
@@ -911,9 +1083,10 @@ class SingleAgentRuntime:
|
|
|
911
1083
|
'EXACTLY one JSON object like {"thoughts": "...", "action": '
|
|
912
1084
|
'"tool_name", "args": {...}} and nothing else.'
|
|
913
1085
|
)
|
|
914
|
-
if parse_failures >=
|
|
1086
|
+
if parse_failures >= profile.escalate_after:
|
|
915
1087
|
# Escalate: name the valid tools so the model stops
|
|
916
|
-
# 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.
|
|
917
1090
|
valid = ", ".join(sorted(self.deps.tool_governance.keys()))
|
|
918
1091
|
hint = (
|
|
919
1092
|
f"{hint} Valid action values are: {valid}, final. "
|
|
@@ -924,6 +1097,84 @@ class SingleAgentRuntime:
|
|
|
924
1097
|
ctx.trace.correction("execute", hint=hint)
|
|
925
1098
|
return False
|
|
926
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
|
+
|
|
927
1178
|
def _is_repeated_create(self, ctx: AgentRunContext, name: Any, args: dict) -> bool:
|
|
928
1179
|
"""Loop guard: the same file-create action+args re-issued right after a result."""
|
|
929
1180
|
exec_steps = [s for s in ctx.transcript if s.get("state") == AgentState.EXECUTING.value]
|
|
@@ -946,10 +1197,37 @@ class SingleAgentRuntime:
|
|
|
946
1197
|
Returns ``(proposed, governor_allows_additive)``: ``proposed`` means the
|
|
947
1198
|
step was staged as a proposal (skip execution); ``allows_additive`` lets
|
|
948
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.
|
|
949
1206
|
"""
|
|
950
1207
|
d = self.deps
|
|
951
1208
|
if d.change_governor is None:
|
|
952
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
|
+
|
|
953
1231
|
verdict = d.change_governor.review(
|
|
954
1232
|
name, args, policy=dict(policy),
|
|
955
1233
|
user_email=current_user, workspace_id=request_workspace,
|
|
@@ -981,16 +1259,42 @@ class SingleAgentRuntime:
|
|
|
981
1259
|
self, ctx: AgentRunContext, req: Any, name: str, thoughts: str, args: dict,
|
|
982
1260
|
policy: dict, risk: str, current_user: str, governor_allows_additive: bool,
|
|
983
1261
|
) -> bool:
|
|
984
|
-
"""
|
|
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
|
+
"""
|
|
985
1268
|
d = self.deps
|
|
986
|
-
|
|
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
|
+
)
|
|
987
1290
|
ctx.trace.tool("execute", name=name, outcome="blocked_destructive", risk=risk)
|
|
988
1291
|
self._emit_step(ctx, "execute", "blocked", action=name, reason="destructive")
|
|
989
1292
|
ctx.transcript.append({
|
|
990
1293
|
"state": AgentState.EXECUTING.value, "action": name,
|
|
991
1294
|
"thoughts": thoughts, "args": args, "risk": risk,
|
|
992
1295
|
"governance": dict(policy),
|
|
993
|
-
"
|
|
1296
|
+
"permission_mode": mode.value,
|
|
1297
|
+
"error": error,
|
|
994
1298
|
})
|
|
995
1299
|
d.audit(
|
|
996
1300
|
"agent_blocked", user_email=current_user, source=getattr(req, "source", None) or "agent",
|
|
@@ -998,25 +1302,33 @@ class SingleAgentRuntime:
|
|
|
998
1302
|
)
|
|
999
1303
|
return True
|
|
1000
1304
|
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
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
|
|
1020
1332
|
|
|
1021
1333
|
def _dispatch_step(
|
|
1022
1334
|
self, ctx: AgentRunContext, name: str, thoughts: str, args: dict,
|
|
@@ -1135,11 +1447,19 @@ class SingleAgentRuntime:
|
|
|
1135
1447
|
checklist_hint = (
|
|
1136
1448
|
f"\n\n{_format_artifact_checklist(checklist)}" if checklist else ""
|
|
1137
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
|
+
)
|
|
1138
1457
|
context = (
|
|
1139
1458
|
f"{d.critic_prompt}\n\n"
|
|
1140
1459
|
f"[LANGUAGE HINT: {lang_hint}]\n\n"
|
|
1141
1460
|
f"Original request: {req.message}\n"
|
|
1142
|
-
f"Plan goal: {ctx.plan.get('goal', req.message)}{checklist_hint}
|
|
1461
|
+
f"Plan goal: {ctx.plan.get('goal', req.message)}{checklist_hint}"
|
|
1462
|
+
f"{_format_requirement_coverage(coverage)}\n\n"
|
|
1143
1463
|
f"Full transcript:\n{json.dumps(verify_transcript, ensure_ascii=False, indent=2)}"
|
|
1144
1464
|
)
|
|
1145
1465
|
raw = await d.generate_as(
|
|
@@ -1239,6 +1559,24 @@ class SingleAgentRuntime:
|
|
|
1239
1559
|
)
|
|
1240
1560
|
ctx.state = AgentState.NEEDS_REVIEW
|
|
1241
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
|
|
1242
1580
|
if not ctx.final_message:
|
|
1243
1581
|
ctx.final_message = verdict.get("reason", "작업이 완료되었습니다.")
|
|
1244
1582
|
ctx.state = AgentState.DONE
|