ltcai 11.2.0 → 11.4.0
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 +46 -53
- package/docs/CHANGELOG.md +61 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/MULTI_AGENT_RUNTIME.md +1 -1
- package/docs/ONBOARDING.md +1 -1
- package/docs/OPERATIONS.md +6 -2
- package/docs/PERMISSION_MODE.md +1 -1
- package/docs/TRUST_MODEL.md +1 -1
- package/docs/WHY_LATTICE.md +1 -1
- package/docs/kg-schema.md +2 -2
- package/docs/v11.3.0_PLAN.md +202 -0
- package/docs/v11.4.0_RUST_FOUNDATION_PLAN.md +176 -0
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/graph/_kg_common/__init__.py +287 -0
- package/lattice_brain/graph/_kg_common/extraction.py +516 -0
- package/lattice_brain/graph/_kg_common/relations.py +161 -0
- package/lattice_brain/graph/_kg_common/text.py +479 -0
- package/lattice_brain/graph/discovery_index/__init__.py +35 -0
- package/lattice_brain/graph/discovery_index/cleanup.py +182 -0
- package/lattice_brain/graph/discovery_index/extract.py +137 -0
- package/lattice_brain/graph/discovery_index/scan.py +411 -0
- package/lattice_brain/graph/discovery_index/upsert.py +495 -0
- package/lattice_brain/graph/projection/__init__.py +42 -0
- package/lattice_brain/graph/projection/curation.py +500 -0
- package/lattice_brain/graph/{projection.py → projection/v2_schema.py} +15 -477
- package/lattice_brain/graph/retrieval/__init__.py +54 -0
- package/lattice_brain/graph/retrieval/context.py +197 -0
- package/lattice_brain/graph/retrieval/graph_view.py +319 -0
- package/lattice_brain/graph/retrieval/hybrid.py +488 -0
- package/lattice_brain/graph/retrieval/maintenance.py +121 -0
- package/lattice_brain/graph/retrieval/signals.py +95 -0
- package/lattice_brain/graph/retrieval_vector/__init__.py +42 -0
- package/lattice_brain/graph/retrieval_vector/fingerprint.py +97 -0
- package/lattice_brain/graph/retrieval_vector/indexing.py +347 -0
- package/lattice_brain/graph/retrieval_vector/search.py +560 -0
- package/lattice_brain/graph/retrieval_vector/status.py +374 -0
- package/lattice_brain/ingestion/__init__.py +130 -0
- package/lattice_brain/ingestion/_contract.py +90 -0
- package/lattice_brain/ingestion/constants.py +127 -0
- package/lattice_brain/ingestion/folder_scan.py +57 -0
- package/lattice_brain/ingestion/folders.py +258 -0
- package/lattice_brain/ingestion/hashing.py +26 -0
- package/lattice_brain/ingestion/jobs_api.py +107 -0
- package/lattice_brain/ingestion/models.py +80 -0
- package/lattice_brain/ingestion/pipeline.py +486 -0
- package/lattice_brain/ingestion/quality.py +209 -0
- package/lattice_brain/ingestion/routing.py +295 -0
- package/lattice_brain/multimodal/__init__.py +164 -0
- package/lattice_brain/multimodal/audio.py +77 -0
- package/lattice_brain/multimodal/common.py +118 -0
- package/lattice_brain/multimodal/images.py +498 -0
- package/lattice_brain/multimodal/ports.py +169 -0
- package/lattice_brain/multimodal/video.py +410 -0
- package/lattice_brain/portability/__init__.py +90 -0
- package/lattice_brain/portability/_contract.py +42 -0
- package/lattice_brain/portability/backups.py +338 -0
- package/lattice_brain/portability/bundles.py +136 -0
- package/lattice_brain/portability/constants.py +93 -0
- package/lattice_brain/portability/fsops.py +138 -0
- package/lattice_brain/portability/service.py +41 -0
- package/lattice_brain/{portability.py → portability/sharing.py} +44 -677
- package/lattice_brain/runtime/__init__.py +1 -1
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/chronicle.py +63 -0
- package/latticeai/core/agent/__init__.py +93 -0
- package/latticeai/core/agent/_contract.py +79 -0
- package/latticeai/core/agent/context.py +57 -0
- package/latticeai/core/agent/deps.py +125 -0
- package/latticeai/core/agent/execution.py +622 -0
- package/latticeai/core/agent/planning.py +145 -0
- package/latticeai/core/agent/recovery.py +157 -0
- package/latticeai/core/agent/runtime.py +210 -0
- package/latticeai/core/agent/verification.py +231 -0
- package/latticeai/core/embedding_providers/__init__.py +151 -0
- package/latticeai/core/embedding_providers/base.py +199 -0
- package/latticeai/core/embedding_providers/captions.py +162 -0
- package/latticeai/core/embedding_providers/profiles.py +126 -0
- package/latticeai/core/embedding_providers/text.py +350 -0
- package/latticeai/core/embedding_providers/vision.py +352 -0
- package/latticeai/core/file_generation/__init__.py +115 -0
- package/latticeai/core/file_generation/bundles.py +76 -0
- package/latticeai/core/file_generation/extraction.py +154 -0
- package/latticeai/core/file_generation/inference.py +235 -0
- package/latticeai/core/file_generation/orchestration.py +152 -0
- package/latticeai/core/file_generation/prompting.py +117 -0
- package/latticeai/core/file_generation/repair.py +114 -0
- package/latticeai/core/file_generation/sanitize.py +61 -0
- package/latticeai/core/file_generation/validation.py +201 -0
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/messages.py +9 -0
- package/latticeai/core/workspace_os_constants.py +1 -1
- package/latticeai/integrations/telegram_bot/__init__.py +123 -0
- package/latticeai/integrations/telegram_bot/__main__.py +17 -0
- package/latticeai/integrations/telegram_bot/config.py +86 -0
- package/latticeai/integrations/telegram_bot/dispatch.py +311 -0
- package/latticeai/integrations/telegram_bot/flows.py +478 -0
- package/latticeai/integrations/telegram_bot/helpers.py +322 -0
- package/latticeai/integrations/telegram_bot/screens.py +394 -0
- package/latticeai/models/router/__init__.py +88 -0
- package/latticeai/models/router/_contract.py +66 -0
- package/latticeai/models/router/branding.py +56 -0
- package/latticeai/models/router/catalog.py +69 -0
- package/latticeai/models/router/documents.py +199 -0
- package/latticeai/models/router/errors.py +37 -0
- package/latticeai/models/router/generation.py +258 -0
- package/latticeai/models/router/loading.py +291 -0
- package/latticeai/models/router/local_models.py +85 -0
- package/latticeai/models/router/registry.py +147 -0
- package/latticeai/runtime/build_phases/__init__.py +82 -0
- package/latticeai/runtime/build_phases/features.py +407 -0
- package/latticeai/runtime/build_phases/foundation.py +555 -0
- package/latticeai/runtime/build_phases/web.py +492 -0
- package/latticeai/runtime/runtime_context.py +1 -0
- package/latticeai/services/architecture_readiness.py +48 -19
- package/latticeai/services/brain_intelligence/__init__.py +58 -0
- package/latticeai/services/brain_intelligence/_contract.py +71 -0
- package/latticeai/services/brain_intelligence/consistency.py +193 -0
- package/latticeai/services/brain_intelligence/constants.py +47 -0
- package/latticeai/services/brain_intelligence/digest.py +258 -0
- package/latticeai/services/brain_intelligence/health.py +331 -0
- package/latticeai/services/brain_intelligence/proposals.py +264 -0
- package/latticeai/services/brain_intelligence/sampling.py +84 -0
- package/latticeai/services/brain_intelligence/service.py +48 -0
- package/latticeai/services/chronicle.py +557 -0
- package/latticeai/services/memory_service/__init__.py +52 -0
- package/latticeai/services/memory_service/_contract.py +100 -0
- package/latticeai/services/memory_service/brief.py +431 -0
- package/latticeai/services/memory_service/constants.py +57 -0
- package/latticeai/services/memory_service/maintenance.py +138 -0
- package/latticeai/services/memory_service/manager.py +186 -0
- package/latticeai/services/memory_service/proof.py +136 -0
- package/latticeai/services/memory_service/recall.py +225 -0
- package/latticeai/services/memory_service/service.py +48 -0
- package/latticeai/services/memory_service/stores.py +110 -0
- package/latticeai/services/model_runtime/__init__.py +322 -0
- package/latticeai/services/model_runtime/cloud.py +87 -0
- package/latticeai/services/model_runtime/download.py +282 -0
- package/latticeai/services/model_runtime/engines.py +341 -0
- package/latticeai/services/model_runtime/loading.py +178 -0
- package/latticeai/services/model_runtime/service.py +129 -0
- package/latticeai/services/model_runtime/state.py +131 -0
- package/latticeai/services/model_runtime/status.py +255 -0
- package/latticeai/services/product_readiness.py +15 -7
- package/latticeai/setup/wizard/__init__.py +126 -0
- package/latticeai/setup/wizard/catalog.py +172 -0
- package/latticeai/setup/wizard/detect.py +323 -0
- package/latticeai/setup/wizard/install.py +348 -0
- package/latticeai/setup/wizard/paths.py +168 -0
- package/latticeai/setup/wizard/plans.py +74 -0
- package/latticeai/setup/wizard/recommend.py +320 -0
- package/package.json +6 -2
- package/scripts/bump_version.py +14 -0
- package/scripts/capture_release_evidence.mjs +33 -21
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/check_i18n_namespace_coverage.mjs +41 -4
- package/scripts/check_max_file_lines.mjs +102 -0
- package/scripts/check_release_evidence_bound.mjs +30 -15
- package/scripts/check_screenshot_pixel_delta.py +34 -4
- package/scripts/check_server_i18n.mjs +1 -0
- package/scripts/generate_rust_parity_fixtures.py +562 -0
- package/scripts/lib/mock_server_fingerprint.mjs +94 -0
- package/scripts/release_screen_claims.json +31 -2
- package/src-tauri/Cargo.lock +361 -3
- package/src-tauri/Cargo.toml +6 -1
- package/src-tauri/src/backend.rs +349 -0
- package/src-tauri/src/folder.rs +33 -0
- package/src-tauri/src/main.rs +97 -399
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +41 -37
- package/static/app/assets/Act-yYpYnn0v.js +1 -0
- package/static/app/assets/AdminConsole-DL3Cr5pL.js +1 -0
- package/static/app/assets/{Brain-tuhI4sOC.js → Brain-C1HBN0Wf.js} +2 -2
- package/static/app/assets/BrainHome-DoXRhUUC.js +2 -0
- package/static/app/assets/BrainSignals-6yR6ir5t.js +1 -0
- package/static/app/assets/Capture-CFIRsFNE.js +1 -0
- package/static/app/assets/Chronicle-BZbEgiwN.js +1 -0
- package/static/app/assets/CommandPalette-D2pMxC2I.js +1 -0
- package/static/app/assets/Library-DwO3yZST.js +1 -0
- package/static/app/assets/{LivingBrain-DBwhto14.js → LivingBrain-Jn1GK0-S.js} +1 -1
- package/static/app/assets/ProductFlow-B-w1R4Oo.js +1 -0
- package/static/app/assets/ReviewCard-6B27X8Vg.js +3 -0
- package/static/app/assets/System-DW8F-2xL.js +1 -0
- package/static/app/assets/arrow-left-DXvKg9U6.js +1 -0
- package/static/app/assets/{bot-Cia42c2h.js → bot-IM_E_Y12.js} +1 -1
- package/static/app/assets/brain-Ci1CkWjM.js +1 -0
- package/static/app/assets/{button-2j2Ijzgq.js → button-COwyqfHM.js} +1 -1
- package/static/app/assets/circle-check-DfInj-qD.js +1 -0
- package/static/app/assets/{circle-pause-BEFeWpVW.js → circle-pause-DEM4A1Y5.js} +1 -1
- package/static/app/assets/{circle-play-ujXMcHxl.js → circle-play-C9djDuLd.js} +1 -1
- package/static/app/assets/{cpu-k4awryFq.js → cpu-DFdo1gw-.js} +1 -1
- package/static/app/assets/{download-DFbLJ_ig.js → download-SnJL6oqk.js} +1 -1
- package/static/app/assets/{folder-open-7y_b6xkM.js → folder-open-CqZeDkjE.js} +1 -1
- package/static/app/assets/{hard-drive-Bidh02Kr.js → hard-drive-j1jJXYYf.js} +1 -1
- package/static/app/assets/{index-DwDl9-8Y.css → index-BLPb5lmE.css} +1 -1
- package/static/app/assets/index-_u5iUHDr.js +10 -0
- package/static/app/assets/input-B0lPdRQZ.js +1 -0
- package/static/app/assets/link-2-CoFbooHS.js +1 -0
- package/static/app/assets/{permissionCopy-Bpb83Hx9.js → permissionCopy-BsyLxtao.js} +1 -1
- package/static/app/assets/primitives-DEbN-d6p.js +1 -0
- package/static/app/assets/search-BybIWPNd.js +1 -0
- package/static/app/assets/{share-2-BH1M-WNi.js → share-2-CVtZ_ewX.js} +1 -1
- package/static/app/assets/{shield-alert-BlKdBXcG.js → shield-alert-CBi2GNWM.js} +1 -1
- package/static/app/assets/{textarea-CCWbUfFB.js → textarea-DNMpB5ih.js} +1 -1
- package/static/app/assets/{useFocusTrap-YdHQ7pJ1.js → useFocusTrap-C83t3GXF.js} +1 -1
- package/static/app/assets/useMutation-DtbJDoyz.js +1 -0
- package/static/app/assets/{useQuery-CXQiwbVT.js → useQuery-Dcp1OChy.js} +1 -1
- package/static/app/assets/utils-BlZr7Pd4.js +4 -0
- package/static/app/assets/workspace-jJY4RuAV.js +1 -0
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/lattice_brain/graph/_kg_common.py +0 -1331
- package/lattice_brain/graph/discovery_index.py +0 -1141
- package/lattice_brain/graph/retrieval.py +0 -1120
- package/lattice_brain/graph/retrieval_vector.py +0 -1293
- package/lattice_brain/ingestion.py +0 -1525
- package/lattice_brain/multimodal.py +0 -1258
- package/latticeai/core/agent.py +0 -1465
- package/latticeai/core/embedding_providers.py +0 -1196
- package/latticeai/core/file_generation.py +0 -1047
- package/latticeai/integrations/telegram_bot.py +0 -1390
- package/latticeai/models/router.py +0 -1007
- package/latticeai/runtime/build_phases.py +0 -1450
- package/latticeai/services/brain_intelligence.py +0 -1083
- package/latticeai/services/memory_service.py +0 -1177
- package/latticeai/services/model_runtime.py +0 -1281
- package/latticeai/setup/wizard.py +0 -1310
- package/static/app/assets/Act-AWf0SAKp.js +0 -1
- package/static/app/assets/AdminConsole-D0u8Tiyj.js +0 -1
- package/static/app/assets/BrainHome-Ts7G_Ila.js +0 -2
- package/static/app/assets/BrainSignals-jMYgQ2Ar.js +0 -1
- package/static/app/assets/Capture-CqOSzyPr.js +0 -1
- package/static/app/assets/CommandPalette-DC0Bzh-I.js +0 -1
- package/static/app/assets/Library-CX-bbhmK.js +0 -1
- package/static/app/assets/ProductFlow-BHA2cfKI.js +0 -1
- package/static/app/assets/ReviewCard-BUhCKRNM.js +0 -3
- package/static/app/assets/System-Bu2t5hn1.js +0 -1
- package/static/app/assets/arrow-left-Dzwa5zRb.js +0 -1
- package/static/app/assets/brain-DJMoqrwx.js +0 -1
- package/static/app/assets/index-BpYkzcVm.js +0 -10
- package/static/app/assets/input-DSlJJxRs.js +0 -1
- package/static/app/assets/primitives-BCx6TvfG.js +0 -1
- package/static/app/assets/search-Cgy8cCFJ.js +0 -1
- package/static/app/assets/utils-zqPZJxdx.js +0 -4
- package/static/app/assets/workspace-DXTihhfU.js +0 -1
|
@@ -0,0 +1,622 @@
|
|
|
1
|
+
"""EXECUTE — one tool call at a time, through every gate, on the record.
|
|
2
|
+
|
|
3
|
+
The longest phase and the only one that changes anything, so it is also where
|
|
4
|
+
all the governance lives: central change classification (additive creates run,
|
|
5
|
+
mutations become review proposals), the mode-invariant hard denials, the
|
|
6
|
+
fail-closed overwrite guard, and the shared pre_tool/post_tool lifecycle. The
|
|
7
|
+
direct-path fallback at the bottom is the escape hatch for a model too small to
|
|
8
|
+
hold the tool-call protocol — it writes the planner's own files through the
|
|
9
|
+
same validated pipeline the chat path uses, and never fabricates evidence.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
from typing import Any, Dict, List, Mapping, Optional, Tuple
|
|
17
|
+
|
|
18
|
+
from lattice_brain.runtime.hooks import dispatch_tool
|
|
19
|
+
from latticeai.core.agent_helpers import (
|
|
20
|
+
compact_transcript,
|
|
21
|
+
extract_action_details,
|
|
22
|
+
files_written,
|
|
23
|
+
)
|
|
24
|
+
from latticeai.core.agent_permission import block_reason_for_tool
|
|
25
|
+
from latticeai.core.agent_profiles import AgentProfile
|
|
26
|
+
from latticeai.core.agent_prompts import executor_prompt_for
|
|
27
|
+
from latticeai.core.agent_state import AgentState
|
|
28
|
+
from latticeai.core.file_generation import (
|
|
29
|
+
generate_file_content,
|
|
30
|
+
infer_file_target,
|
|
31
|
+
sanitize_write_content,
|
|
32
|
+
)
|
|
33
|
+
from latticeai.core.permission_mode import is_circuit_breaker, should_stage_proposal
|
|
34
|
+
from latticeai.core.tool_governor import classify_tool_call
|
|
35
|
+
from latticeai.core.tool_registry import SCOPED_KNOWLEDGE_TOOLS
|
|
36
|
+
from latticeai.tools import ToolError
|
|
37
|
+
|
|
38
|
+
from ._contract import AgentCore as _Core
|
|
39
|
+
from .context import AgentRunContext
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class _ExecutionMixin(_Core):
|
|
43
|
+
"""The EXECUTE phase of :class:`SingleAgentRuntime`."""
|
|
44
|
+
|
|
45
|
+
# ── EXECUTE ──────────────────────────────────────────────────────
|
|
46
|
+
async def execute(
|
|
47
|
+
self, ctx: AgentRunContext, req: Any, lang_hint: str,
|
|
48
|
+
current_user: str, max_steps: int, model_id: Optional[str] = None,
|
|
49
|
+
) -> None:
|
|
50
|
+
"""EXECUTE: Executor role calls tools one at a time until final or budget exhausted."""
|
|
51
|
+
d = self.deps
|
|
52
|
+
profile = self.profile_for(model_id)
|
|
53
|
+
exec_count = sum(1 for s in ctx.transcript if s.get("state") == AgentState.EXECUTING.value)
|
|
54
|
+
budget = max(1, max_steps - exec_count)
|
|
55
|
+
parse_failures = 0
|
|
56
|
+
|
|
57
|
+
for _ in range(budget):
|
|
58
|
+
request_workspace = getattr(req, "workspace_id", None)
|
|
59
|
+
context = self._executor_context(
|
|
60
|
+
ctx, req, lang_hint, current_user, request_workspace, profile=profile
|
|
61
|
+
)
|
|
62
|
+
raw = await d.generate_as(
|
|
63
|
+
model_id,
|
|
64
|
+
message="Execute the next step.",
|
|
65
|
+
context=context, max_tokens=self.phase_budgets.execute_tokens,
|
|
66
|
+
temperature=req.temperature,
|
|
67
|
+
)
|
|
68
|
+
ctx.trace.llm_call("execute", model=model_id)
|
|
69
|
+
try:
|
|
70
|
+
action, exec_repairs = extract_action_details(str(raw))
|
|
71
|
+
ctx.trace.repair("execute", repairs=exec_repairs)
|
|
72
|
+
except ValueError as exc:
|
|
73
|
+
parse_failures += 1
|
|
74
|
+
if self._note_parse_failure(ctx, raw, exc, parse_failures, profile):
|
|
75
|
+
# Direct-path fallback (v9.9.7): a small model that cannot
|
|
76
|
+
# hold the tool-call protocol can still write a file. Run
|
|
77
|
+
# the plan's own file steps without asking for any JSON.
|
|
78
|
+
if profile.direct_path_fallback and await self._direct_file_path(
|
|
79
|
+
ctx, req, current_user, model_id
|
|
80
|
+
):
|
|
81
|
+
ctx.state = AgentState.VERIFYING
|
|
82
|
+
return
|
|
83
|
+
break
|
|
84
|
+
continue
|
|
85
|
+
|
|
86
|
+
name = str(action.get("action") or "")
|
|
87
|
+
thoughts = str(action.get("thoughts") or "")[:600]
|
|
88
|
+
args = action.get("args") or {}
|
|
89
|
+
|
|
90
|
+
if name in SCOPED_KNOWLEDGE_TOOLS:
|
|
91
|
+
# Scope is server-owned, never model-owned. Overwrite any
|
|
92
|
+
# claimed values before policy evaluation, audit, and dispatch.
|
|
93
|
+
args = dict(args)
|
|
94
|
+
args["workspace_id"] = request_workspace or "personal"
|
|
95
|
+
args["user_email"] = current_user or "local"
|
|
96
|
+
|
|
97
|
+
if name == "final":
|
|
98
|
+
ctx.final_message = action.get("message", "작업을 완료했습니다.")
|
|
99
|
+
ctx.transcript.append({
|
|
100
|
+
"state": AgentState.EXECUTING.value, "action": "final", "thoughts": thoughts,
|
|
101
|
+
})
|
|
102
|
+
ctx.trace.decision("execute", decision="final")
|
|
103
|
+
self._emit_step(ctx, "execute", "final")
|
|
104
|
+
ctx.state = AgentState.VERIFYING
|
|
105
|
+
return
|
|
106
|
+
|
|
107
|
+
# Loop guard
|
|
108
|
+
if self._is_repeated_create(ctx, name, args):
|
|
109
|
+
ctx.transcript.append({
|
|
110
|
+
"state": AgentState.EXECUTING.value, "action": name,
|
|
111
|
+
"error": "LOOP_DETECTED: identical action+args repeated — halted.",
|
|
112
|
+
})
|
|
113
|
+
ctx.trace.decision("execute", decision="loop_detected", tool=name)
|
|
114
|
+
self._emit_step(ctx, "execute", "blocked", action=name, reason="loop_detected")
|
|
115
|
+
break
|
|
116
|
+
|
|
117
|
+
if name == "clear_history":
|
|
118
|
+
result = d.clear_history(args.get("keep_last", 0))
|
|
119
|
+
ctx.transcript.append({
|
|
120
|
+
"state": AgentState.EXECUTING.value, "action": name,
|
|
121
|
+
"thoughts": thoughts, "args": args, "result": result,
|
|
122
|
+
})
|
|
123
|
+
self._emit_step(ctx, "execute", "tool", action=name, ok=True)
|
|
124
|
+
continue
|
|
125
|
+
|
|
126
|
+
policy = d.policy_for(name, args)
|
|
127
|
+
risk = d.risk_level(policy)
|
|
128
|
+
|
|
129
|
+
proposed, governor_allows_additive = self._governor_review(
|
|
130
|
+
ctx, name, thoughts, args, policy, risk, current_user, request_workspace,
|
|
131
|
+
conversation_id=getattr(req, "conversation_id", None),
|
|
132
|
+
)
|
|
133
|
+
if proposed:
|
|
134
|
+
continue
|
|
135
|
+
|
|
136
|
+
if self._blocked_by_gates(
|
|
137
|
+
ctx, req, name, thoughts, args, policy, risk,
|
|
138
|
+
current_user, governor_allows_additive,
|
|
139
|
+
):
|
|
140
|
+
continue
|
|
141
|
+
|
|
142
|
+
self._dispatch_step(ctx, name, thoughts, args, policy, risk, current_user)
|
|
143
|
+
|
|
144
|
+
ctx.state = AgentState.VERIFYING
|
|
145
|
+
|
|
146
|
+
def _self_model_summary(
|
|
147
|
+
self, ctx: AgentRunContext, current_user: str, request_workspace: Optional[str]
|
|
148
|
+
) -> str:
|
|
149
|
+
"""What this Brain knows about its owner, resolved once per run.
|
|
150
|
+
|
|
151
|
+
The port may be a plain string or a resolver taking scope kwargs — the
|
|
152
|
+
same two shapes ``permission_mode`` accepts, so there is one convention
|
|
153
|
+
for "a value or a way to get one". Anything that goes wrong yields an
|
|
154
|
+
empty summary: prompt assembly must not fail because a profile could
|
|
155
|
+
not be read, and an empty summary is byte-identical to having no port
|
|
156
|
+
at all.
|
|
157
|
+
"""
|
|
158
|
+
if ctx.self_model_summary is not None:
|
|
159
|
+
return ctx.self_model_summary
|
|
160
|
+
source = self.deps.self_model_summary
|
|
161
|
+
summary = ""
|
|
162
|
+
if source is not None:
|
|
163
|
+
try:
|
|
164
|
+
if callable(source):
|
|
165
|
+
try:
|
|
166
|
+
summary = source(
|
|
167
|
+
user_email=current_user or None,
|
|
168
|
+
workspace_id=request_workspace,
|
|
169
|
+
)
|
|
170
|
+
except TypeError:
|
|
171
|
+
# A resolver that takes no scope arguments is allowed.
|
|
172
|
+
summary = source()
|
|
173
|
+
else:
|
|
174
|
+
summary = source
|
|
175
|
+
except Exception: # noqa: BLE001 — a profile is never worth a failed run
|
|
176
|
+
logging.debug("agent: self-model summary unavailable", exc_info=True)
|
|
177
|
+
summary = ""
|
|
178
|
+
ctx.self_model_summary = str(summary or "").strip()
|
|
179
|
+
return ctx.self_model_summary
|
|
180
|
+
|
|
181
|
+
def _executor_context(
|
|
182
|
+
self, ctx: AgentRunContext, req: Any, lang_hint: str,
|
|
183
|
+
current_user: str, request_workspace: Optional[str],
|
|
184
|
+
profile: Optional[AgentProfile] = None,
|
|
185
|
+
) -> str:
|
|
186
|
+
"""Assemble one executor turn's prompt (plan, corrections, recent chat)."""
|
|
187
|
+
d = self.deps
|
|
188
|
+
# Only the latest corrections steer the next attempt — stale hints
|
|
189
|
+
# from earlier retries dilute weak models (review Wave 0.3).
|
|
190
|
+
active_corrections = ctx.corrections[-3:]
|
|
191
|
+
corrections_hint = (
|
|
192
|
+
"\n\nCritic corrections from previous attempt:\n"
|
|
193
|
+
+ "\n".join(f"- {c}" for c in active_corrections)
|
|
194
|
+
) if active_corrections else ""
|
|
195
|
+
|
|
196
|
+
recent_kwargs = {
|
|
197
|
+
"conversation_id": req.conversation_id,
|
|
198
|
+
"user_email": current_user or None,
|
|
199
|
+
}
|
|
200
|
+
if request_workspace is not None:
|
|
201
|
+
recent_kwargs["workspace_id"] = request_workspace
|
|
202
|
+
recent_conversation = d.recent_chat_context(**recent_kwargs) or "(none)"
|
|
203
|
+
budget = self.transcript_budget
|
|
204
|
+
# A small model drowns in a long transcript far sooner than a large
|
|
205
|
+
# one, so the profile may narrow the window (v9.9.7).
|
|
206
|
+
window = min(budget.window, profile.transcript_window) if profile else budget.window
|
|
207
|
+
bounded_transcript = compact_transcript(
|
|
208
|
+
ctx.transcript,
|
|
209
|
+
window=window,
|
|
210
|
+
result_chars=budget.result_chars,
|
|
211
|
+
)
|
|
212
|
+
# Mid-run workspace awareness (review L5): later steps must see what
|
|
213
|
+
# this run already produced instead of a stale workspace picture.
|
|
214
|
+
written = files_written(ctx.transcript, d.file_create_actions)
|
|
215
|
+
written_hint = (
|
|
216
|
+
"\n\nFiles written by this run so far (they exist in the workspace now):\n"
|
|
217
|
+
+ "\n".join(f"- {path}" for path in written)
|
|
218
|
+
) if written else ""
|
|
219
|
+
return (
|
|
220
|
+
# v11.1.0: the executor prompt carries profile-aware file-writing
|
|
221
|
+
# hints, because "wrote nothing at all" was the weak-model failure
|
|
222
|
+
# mode the loop could not repair after the fact.
|
|
223
|
+
f"{executor_prompt_for(d.executor_prompt, profile=profile, self_model_summary=self._self_model_summary(ctx, current_user, request_workspace))}\n\n"
|
|
224
|
+
f"[LANGUAGE HINT: {lang_hint}]\n"
|
|
225
|
+
f"Workspace root: {d.agent_root}{self._project_block(ctx)}\n\n"
|
|
226
|
+
f"PLAN:\n{json.dumps(ctx.plan, ensure_ascii=False)}{written_hint}\n\n"
|
|
227
|
+
f"Recent conversation:\n{recent_conversation}\n\n"
|
|
228
|
+
f"User request: {req.message}{corrections_hint}\n\n"
|
|
229
|
+
f"Execution transcript:\n{json.dumps(bounded_transcript, ensure_ascii=False, indent=2)}"
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
def _note_parse_failure(
|
|
233
|
+
self, ctx: AgentRunContext, raw: Any, exc: ValueError, parse_failures: int,
|
|
234
|
+
profile: Optional[AgentProfile] = None,
|
|
235
|
+
) -> bool:
|
|
236
|
+
"""Record one executor parse slip; True when the run should stop retrying."""
|
|
237
|
+
profile = profile or self.profile_for(None)
|
|
238
|
+
ctx.transcript.append({
|
|
239
|
+
"state": AgentState.EXECUTING.value, "action": "parse_error",
|
|
240
|
+
"raw": str(raw)[:400], "error": str(exc),
|
|
241
|
+
})
|
|
242
|
+
if parse_failures >= profile.parse_failure_budget:
|
|
243
|
+
ctx.trace.parse_error("execute", error=str(exc), recovered=False)
|
|
244
|
+
self._emit_step(ctx, "execute", "parse_error", recovered=False)
|
|
245
|
+
return True
|
|
246
|
+
ctx.trace.parse_error("execute", error=str(exc), recovered=True)
|
|
247
|
+
self._emit_step(ctx, "execute", "parse_error", recovered=True)
|
|
248
|
+
# Weak models often need one concrete reminder of the wire
|
|
249
|
+
# format; feed it through the corrections channel and retry
|
|
250
|
+
# instead of aborting the whole run on the first slip.
|
|
251
|
+
hint = (
|
|
252
|
+
'Your last reply was not a single JSON action object. Reply with '
|
|
253
|
+
'EXACTLY one JSON object like {"thoughts": "...", "action": '
|
|
254
|
+
'"tool_name", "args": {...}} and nothing else.'
|
|
255
|
+
)
|
|
256
|
+
if parse_failures >= profile.escalate_after:
|
|
257
|
+
# Escalate: name the valid tools so the model stops
|
|
258
|
+
# inventing action names or prose. The compact profile escalates
|
|
259
|
+
# a slip earlier — a small model needs the list sooner.
|
|
260
|
+
valid = ", ".join(sorted(self.deps.tool_governance.keys()))
|
|
261
|
+
hint = (
|
|
262
|
+
f"{hint} Valid action values are: {valid}, final. "
|
|
263
|
+
'Use {"action": "final", "message": "..."} to finish.'
|
|
264
|
+
)
|
|
265
|
+
if hint not in ctx.corrections:
|
|
266
|
+
ctx.corrections.append(hint)
|
|
267
|
+
ctx.trace.correction("execute", hint=hint)
|
|
268
|
+
return False
|
|
269
|
+
|
|
270
|
+
async def _direct_file_path(
|
|
271
|
+
self, ctx: AgentRunContext, req: Any, current_user: str,
|
|
272
|
+
model_id: Optional[str],
|
|
273
|
+
) -> bool:
|
|
274
|
+
"""Write the plan's file steps without asking the model for JSON (v9.9.7).
|
|
275
|
+
|
|
276
|
+
The compact profile's escape hatch. A 1–4B local model that cannot hold
|
|
277
|
+
the tool-call protocol can still write a file, so when JSON tool calls
|
|
278
|
+
are exhausted the loop drops the protocol entirely: it takes the paths
|
|
279
|
+
the *planner* already chose and asks only for file content in plain
|
|
280
|
+
text, through the same validated
|
|
281
|
+
:func:`~latticeai.core.file_generation.generate_file_content` pipeline
|
|
282
|
+
the direct chat path uses.
|
|
283
|
+
|
|
284
|
+
Returns True when at least one file was actually written. Honest
|
|
285
|
+
failure modes: no planned paths, a governor that stages the write as a
|
|
286
|
+
proposal, or a tool error all return False and leave the run to end as
|
|
287
|
+
it would have — this never fabricates evidence.
|
|
288
|
+
"""
|
|
289
|
+
d = self.deps
|
|
290
|
+
planned: List[str] = []
|
|
291
|
+
for step in ctx.plan.get("steps") or []:
|
|
292
|
+
if not isinstance(step, dict) or step.get("action") not in d.file_create_actions:
|
|
293
|
+
continue
|
|
294
|
+
path = str((step.get("args") or {}).get("path") or "").strip()
|
|
295
|
+
if path and path not in planned:
|
|
296
|
+
planned.append(path)
|
|
297
|
+
if not planned:
|
|
298
|
+
inferred = infer_file_target(getattr(req, "message", "") or "")
|
|
299
|
+
if inferred:
|
|
300
|
+
planned = [inferred]
|
|
301
|
+
if not planned:
|
|
302
|
+
return False
|
|
303
|
+
|
|
304
|
+
goal = str(ctx.plan.get("goal") or getattr(req, "message", "") or "")
|
|
305
|
+
|
|
306
|
+
async def _generate(context: str) -> Any:
|
|
307
|
+
return await d.generate_as(
|
|
308
|
+
model_id,
|
|
309
|
+
message="Write the file content.",
|
|
310
|
+
context=context,
|
|
311
|
+
max_tokens=self.phase_budgets.execute_tokens,
|
|
312
|
+
temperature=0.2,
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
wrote = False
|
|
316
|
+
for path in planned[:6]:
|
|
317
|
+
try:
|
|
318
|
+
content, meta = await generate_file_content(
|
|
319
|
+
_generate,
|
|
320
|
+
target_path=path,
|
|
321
|
+
user_request=goal,
|
|
322
|
+
bundle_files=planned if len(planned) > 1 else None,
|
|
323
|
+
)
|
|
324
|
+
except Exception as exc: # noqa: BLE001 — fallback must not raise
|
|
325
|
+
logging.warning("direct file path generation failed for %s: %s", path, exc)
|
|
326
|
+
continue
|
|
327
|
+
ctx.trace.llm_call("execute", model=model_id)
|
|
328
|
+
ctx.trace.repair("execute", repairs=["direct_path_fallback"])
|
|
329
|
+
args = {"path": path, "content": content}
|
|
330
|
+
policy = d.policy_for("write_file", args)
|
|
331
|
+
risk = d.risk_level(policy)
|
|
332
|
+
before = len(ctx.transcript)
|
|
333
|
+
self._dispatch_step(ctx, "write_file", "direct path fallback", args, policy, risk, current_user)
|
|
334
|
+
last = ctx.transcript[-1] if len(ctx.transcript) > before else {}
|
|
335
|
+
if isinstance(last.get("result"), dict) and not last["result"].get("proposed"):
|
|
336
|
+
wrote = True
|
|
337
|
+
last["direct_path"] = True
|
|
338
|
+
last["generation"] = {"repaired": bool(meta.get("repaired"))}
|
|
339
|
+
if wrote:
|
|
340
|
+
ctx.trace.decision("execute", decision="direct_path_fallback", files=len(planned))
|
|
341
|
+
self._emit_step(ctx, "execute", "direct_path", files=len(planned))
|
|
342
|
+
ctx.final_message = (
|
|
343
|
+
"도구 호출 형식을 계속 벗어나서, 계획에 있던 파일을 직접 생성했습니다. "
|
|
344
|
+
"내용을 확인해 주세요."
|
|
345
|
+
)
|
|
346
|
+
return wrote
|
|
347
|
+
|
|
348
|
+
def _is_repeated_create(self, ctx: AgentRunContext, name: Any, args: dict) -> bool:
|
|
349
|
+
"""Loop guard: the same file-create action+args re-issued right after a result."""
|
|
350
|
+
exec_steps = [s for s in ctx.transcript if s.get("state") == AgentState.EXECUTING.value]
|
|
351
|
+
last = exec_steps[-1] if exec_steps else None
|
|
352
|
+
return bool(
|
|
353
|
+
name in self.deps.file_create_actions and last
|
|
354
|
+
and last.get("action") == name
|
|
355
|
+
and (last.get("args") or {}) == args
|
|
356
|
+
and "result" in last
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
def _governor_review(
|
|
360
|
+
self, ctx: AgentRunContext, name: str, thoughts: str, args: dict,
|
|
361
|
+
policy: Mapping[str, Any], risk: str, current_user: str, request_workspace: Optional[str],
|
|
362
|
+
conversation_id: Optional[str] = None,
|
|
363
|
+
) -> Tuple[bool, bool]:
|
|
364
|
+
"""Central change-class governance: create-new runs with minimal
|
|
365
|
+
friction, change/delete-existing becomes a review proposal.
|
|
366
|
+
|
|
367
|
+
Returns ``(proposed, governor_allows_additive)``: ``proposed`` means the
|
|
368
|
+
step was staged as a proposal (skip execution); ``allows_additive`` lets
|
|
369
|
+
an additive create pass the classic approval gate.
|
|
370
|
+
|
|
371
|
+
Under a mode that does not stage proposals (``trusted`` / ``bypass``)
|
|
372
|
+
the decision is made *before* the governor is consulted, because
|
|
373
|
+
``review`` persists a proposal as a side effect — reviewing first and
|
|
374
|
+
discarding the verdict afterwards would apply the change *and* leave an
|
|
375
|
+
orphan proposal pending in the Review Center.
|
|
376
|
+
"""
|
|
377
|
+
d = self.deps
|
|
378
|
+
if d.change_governor is None:
|
|
379
|
+
return False, False
|
|
380
|
+
|
|
381
|
+
mode = self.resolve_permission_mode(
|
|
382
|
+
ctx, user_email=current_user, workspace_id=request_workspace,
|
|
383
|
+
)
|
|
384
|
+
if not should_stage_proposal(mode, proposal_required=True):
|
|
385
|
+
if name not in self._governed_tools():
|
|
386
|
+
return False, False
|
|
387
|
+
if policy.get("destructive") or policy.get("risk") == "destructive":
|
|
388
|
+
# Let the destructive gate downstream own the block + transcript.
|
|
389
|
+
return False, False
|
|
390
|
+
d.audit(
|
|
391
|
+
"agent_change_auto_applied",
|
|
392
|
+
user_email=current_user,
|
|
393
|
+
workspace_id=request_workspace,
|
|
394
|
+
action=name,
|
|
395
|
+
path=str(args.get("path") or "") or None,
|
|
396
|
+
permission_mode=mode.value,
|
|
397
|
+
note="permission mode auto-applies mutation with audit",
|
|
398
|
+
)
|
|
399
|
+
return False, True
|
|
400
|
+
|
|
401
|
+
verdict = d.change_governor.review(
|
|
402
|
+
name, args, policy=dict(policy),
|
|
403
|
+
user_email=current_user, workspace_id=request_workspace,
|
|
404
|
+
conversation_id=conversation_id,
|
|
405
|
+
)
|
|
406
|
+
if verdict is not None and verdict.get("decision") == "proposed":
|
|
407
|
+
proposal = verdict.get("proposal") or {}
|
|
408
|
+
ctx.trace.tool("execute", name=name, outcome="proposed", risk=risk)
|
|
409
|
+
self._emit_step(ctx, "execute", "proposed", action=name)
|
|
410
|
+
ctx.transcript.append({
|
|
411
|
+
"state": AgentState.EXECUTING.value, "action": name,
|
|
412
|
+
"thoughts": thoughts, "args": {k: v for k, v in args.items() if k != "content"},
|
|
413
|
+
"risk": risk, "governance": dict(policy),
|
|
414
|
+
"result": {
|
|
415
|
+
"proposed": True,
|
|
416
|
+
"proposal_id": proposal.get("id"),
|
|
417
|
+
"note": "기존 내용을 바꾸는 작업이라 변경 제안으로 저장했습니다. 검토함에서 승인하면 적용됩니다.",
|
|
418
|
+
},
|
|
419
|
+
})
|
|
420
|
+
d.audit(
|
|
421
|
+
"agent_change_proposed", user_email=current_user,
|
|
422
|
+
action=name, proposal_id=proposal.get("id"),
|
|
423
|
+
change_class=(verdict.get("classification") or {}).get("change_class"),
|
|
424
|
+
)
|
|
425
|
+
return True, False
|
|
426
|
+
return False, (verdict is not None and verdict.get("decision") == "allow_additive")
|
|
427
|
+
|
|
428
|
+
def _blocked_by_gates(
|
|
429
|
+
self, ctx: AgentRunContext, req: Any, name: str, thoughts: str, args: dict,
|
|
430
|
+
policy: Mapping[str, Any], risk: str, current_user: str, governor_allows_additive: bool,
|
|
431
|
+
) -> bool:
|
|
432
|
+
"""Destructive / circuit-breaker / fail-closed-overwrite / approval gates.
|
|
433
|
+
|
|
434
|
+
Returns True when the step was blocked. The active permission mode can
|
|
435
|
+
widen what runs without an extra approval prompt, but never widens a
|
|
436
|
+
circuit breaker, the destructive gate, or the overwrite check.
|
|
437
|
+
"""
|
|
438
|
+
d = self.deps
|
|
439
|
+
mode = self.resolve_permission_mode(
|
|
440
|
+
ctx,
|
|
441
|
+
user_email=current_user,
|
|
442
|
+
workspace_id=getattr(req, "workspace_id", None),
|
|
443
|
+
)
|
|
444
|
+
# Hard denials first — mode-invariant. A circuit breaker (root/home
|
|
445
|
+
# paths, `rm -rf /` style commands) and a destructive policy are both
|
|
446
|
+
# audited as ``blocked`` with the reason that actually fired, rather
|
|
447
|
+
# than being flattened into the approval path.
|
|
448
|
+
breaker = is_circuit_breaker(name, policy, args)
|
|
449
|
+
hard_deny = breaker or (
|
|
450
|
+
"destructive policy"
|
|
451
|
+
if policy["risk"] == "destructive" or policy.get("destructive")
|
|
452
|
+
else None
|
|
453
|
+
)
|
|
454
|
+
if hard_deny:
|
|
455
|
+
error = (
|
|
456
|
+
f"BLOCKED: destructive action '{name}' not permitted in agent mode."
|
|
457
|
+
if hard_deny == "destructive policy"
|
|
458
|
+
else f"BLOCKED: {hard_deny}"
|
|
459
|
+
)
|
|
460
|
+
ctx.trace.tool("execute", name=name, outcome="blocked_destructive", risk=risk)
|
|
461
|
+
self._emit_step(ctx, "execute", "blocked", action=name, reason="destructive")
|
|
462
|
+
ctx.transcript.append({
|
|
463
|
+
"state": AgentState.EXECUTING.value, "action": name,
|
|
464
|
+
"thoughts": thoughts, "args": args, "risk": risk,
|
|
465
|
+
"governance": dict(policy),
|
|
466
|
+
"permission_mode": mode.value,
|
|
467
|
+
"error": error,
|
|
468
|
+
})
|
|
469
|
+
d.audit(
|
|
470
|
+
"agent_blocked", user_email=current_user, source=getattr(req, "source", None) or "agent",
|
|
471
|
+
action=name, reason="destructive", governance=dict(policy),
|
|
472
|
+
)
|
|
473
|
+
return True
|
|
474
|
+
|
|
475
|
+
# Fail-closed overwrite guard — mode-invariant, like the two above.
|
|
476
|
+
# A call that rewrites existing content but cannot be staged as a
|
|
477
|
+
# reviewable proposal (binary document creators, home-sandbox writes)
|
|
478
|
+
# has no safe apply path in ANY mode: trusted/bypass skip the approval
|
|
479
|
+
# *prompt*, they never remove the existence check. Without this the
|
|
480
|
+
# loop silently overwrote files that the HTTP surface refuses with 409
|
|
481
|
+
# (``ToolDispatchService.enforce_policy``).
|
|
482
|
+
overwrite = classify_tool_call(
|
|
483
|
+
name, args, policy=dict(policy),
|
|
484
|
+
path_exists=lambda candidate: self._governed_path_exists(name, candidate),
|
|
485
|
+
)
|
|
486
|
+
if overwrite.get("fail_closed"):
|
|
487
|
+
target = str(args.get("path") or args.get("filename") or "")
|
|
488
|
+
error = (
|
|
489
|
+
f"NEEDS_REVIEW: '{name}' 은(는) 이미 있는 파일 '{target}' 을(를) 덮어씁니다. "
|
|
490
|
+
"이 도구의 변경은 검토 가능한 제안으로 만들 수 없어 실행하지 않았습니다. "
|
|
491
|
+
"새 파일 이름으로 만들거나 write_file/edit_file 로 수정하세요."
|
|
492
|
+
)
|
|
493
|
+
ctx.trace.tool("execute", name=name, outcome="blocked_overwrite", risk=risk)
|
|
494
|
+
self._emit_step(ctx, "execute", "blocked", action=name, reason="overwrite")
|
|
495
|
+
ctx.transcript.append({
|
|
496
|
+
"state": AgentState.EXECUTING.value, "action": name,
|
|
497
|
+
"thoughts": thoughts,
|
|
498
|
+
# Same shape as a staged proposal: the payload is never worth
|
|
499
|
+
# replaying into the transcript, only the decision is.
|
|
500
|
+
"args": {k: v for k, v in args.items() if k != "content"},
|
|
501
|
+
"risk": risk,
|
|
502
|
+
"governance": dict(policy),
|
|
503
|
+
"permission_mode": mode.value,
|
|
504
|
+
"change_class": overwrite.get("change_class"),
|
|
505
|
+
"error": error,
|
|
506
|
+
})
|
|
507
|
+
d.audit(
|
|
508
|
+
"agent_blocked", user_email=current_user,
|
|
509
|
+
source=getattr(req, "source", None) or "agent",
|
|
510
|
+
action=name, reason="overwrite_fail_closed",
|
|
511
|
+
path=target or None,
|
|
512
|
+
change_class=overwrite.get("change_class"),
|
|
513
|
+
permission_mode=mode.value,
|
|
514
|
+
governance=dict(policy),
|
|
515
|
+
)
|
|
516
|
+
return True
|
|
517
|
+
|
|
518
|
+
reason = block_reason_for_tool(
|
|
519
|
+
mode, name, policy, args,
|
|
520
|
+
approved_by_human=bool(ctx.approved_by_human),
|
|
521
|
+
governor_allows_additive=governor_allows_additive,
|
|
522
|
+
)
|
|
523
|
+
if reason is None:
|
|
524
|
+
return False
|
|
525
|
+
|
|
526
|
+
d.audit(
|
|
527
|
+
"agent_exec", user_email=current_user, source=getattr(req, "source", None) or "agent",
|
|
528
|
+
state=AgentState.EXECUTING.value, action=name, risk=risk,
|
|
529
|
+
shell=policy["shell"], network=policy["network"],
|
|
530
|
+
destructive=policy["destructive"], sandbox=policy["sandbox"],
|
|
531
|
+
rollback=policy["rollback"],
|
|
532
|
+
permission_mode=mode.value,
|
|
533
|
+
args={k: v for k, v in args.items() if k != "content"},
|
|
534
|
+
)
|
|
535
|
+
ctx.trace.tool("execute", name=name, outcome="blocked_approval", risk=risk)
|
|
536
|
+
self._emit_step(ctx, "execute", "blocked", action=name, reason="approval")
|
|
537
|
+
ctx.transcript.append({
|
|
538
|
+
"state": AgentState.EXECUTING.value, "action": name,
|
|
539
|
+
"thoughts": thoughts, "args": args, "risk": risk,
|
|
540
|
+
"governance": dict(policy),
|
|
541
|
+
"permission_mode": mode.value,
|
|
542
|
+
"error": reason,
|
|
543
|
+
})
|
|
544
|
+
return True
|
|
545
|
+
|
|
546
|
+
def _dispatch_step(
|
|
547
|
+
self, ctx: AgentRunContext, name: str, thoughts: str, args: dict,
|
|
548
|
+
policy: Mapping[str, Any], risk: str, current_user: str,
|
|
549
|
+
) -> None:
|
|
550
|
+
"""Role check + shared tool lifecycle, recorded on the transcript either way."""
|
|
551
|
+
d = self.deps
|
|
552
|
+
sanitize_meta: Optional[Dict[str, Any]] = None
|
|
553
|
+
if name == "write_file" and isinstance(args.get("content"), str):
|
|
554
|
+
# ArtifactWritePipeline: the executor's args.content is untrusted
|
|
555
|
+
# model output. The same extract→validate→repair guarantee as the
|
|
556
|
+
# direct chat path applies here, so a weak model driving the JSON
|
|
557
|
+
# loop can never persist fenced/chatty/truncated payloads.
|
|
558
|
+
cleaned, meta = sanitize_write_content(
|
|
559
|
+
str(args.get("path") or ""), args["content"],
|
|
560
|
+
user_request=str(ctx.plan.get("goal") or thoughts or name),
|
|
561
|
+
)
|
|
562
|
+
if meta.get("sanitized"):
|
|
563
|
+
args = dict(args)
|
|
564
|
+
args["content"] = cleaned
|
|
565
|
+
sanitize_meta = meta
|
|
566
|
+
ctx.trace.repair(
|
|
567
|
+
"execute",
|
|
568
|
+
repairs=[
|
|
569
|
+
"artifact_repair" if meta.get("repaired") else "artifact_sanitize"
|
|
570
|
+
],
|
|
571
|
+
)
|
|
572
|
+
step_index = 1 + sum(
|
|
573
|
+
1 for s in ctx.transcript
|
|
574
|
+
if s.get("state") == AgentState.EXECUTING.value
|
|
575
|
+
and s.get("action") not in (None, "final", "parse_error")
|
|
576
|
+
)
|
|
577
|
+
if (
|
|
578
|
+
name in d.file_create_actions
|
|
579
|
+
and d.snapshot_file is not None
|
|
580
|
+
and args.get("path")
|
|
581
|
+
):
|
|
582
|
+
# Pre-write snapshot (review L7): the first capture per path is
|
|
583
|
+
# the true pre-run state — later writes to the same path must
|
|
584
|
+
# not overwrite it. Best-effort: a snapshot failure never
|
|
585
|
+
# blocks the write, it only narrows rollback options.
|
|
586
|
+
path_str = str(args["path"])
|
|
587
|
+
if not any(entry.get("path") == path_str for entry in ctx.rollback_log):
|
|
588
|
+
try:
|
|
589
|
+
pre = d.snapshot_file(path_str)
|
|
590
|
+
ctx.rollback_log.append({"path": path_str, **(pre or {})})
|
|
591
|
+
except Exception as exc: # noqa: BLE001
|
|
592
|
+
logging.warning("pre-write snapshot failed for %s: %s", path_str, exc)
|
|
593
|
+
try:
|
|
594
|
+
d.check_role(name, current_user)
|
|
595
|
+
# Shared tool lifecycle: pre_tool (may block) → execute → post_tool.
|
|
596
|
+
result = dispatch_tool(
|
|
597
|
+
d.hooks, name, args,
|
|
598
|
+
lambda: d.execute_tool(name, args),
|
|
599
|
+
user_email=current_user, source="agent",
|
|
600
|
+
)
|
|
601
|
+
ctx.trace.tool("execute", name=name, outcome="ok", risk=risk)
|
|
602
|
+
ctx.transcript.append({
|
|
603
|
+
"state": AgentState.EXECUTING.value, "action": name,
|
|
604
|
+
"thoughts": thoughts, "args": args,
|
|
605
|
+
"risk": risk, "governance": dict(policy), "result": result,
|
|
606
|
+
**({"content_sanitize": sanitize_meta} if sanitize_meta else {}),
|
|
607
|
+
})
|
|
608
|
+
self._emit_step(
|
|
609
|
+
ctx, "execute", "tool", action=name, ok=True, step=step_index,
|
|
610
|
+
path=str(args.get("path")) if args.get("path") else None,
|
|
611
|
+
)
|
|
612
|
+
except (ToolError, KeyError, TypeError, PermissionError) as exc:
|
|
613
|
+
ctx.trace.tool("execute", name=name, outcome="error", risk=risk)
|
|
614
|
+
ctx.transcript.append({
|
|
615
|
+
"state": AgentState.EXECUTING.value, "action": name,
|
|
616
|
+
"thoughts": thoughts, "args": args,
|
|
617
|
+
"risk": risk, "governance": dict(policy), "error": str(exc),
|
|
618
|
+
})
|
|
619
|
+
self._emit_step(
|
|
620
|
+
ctx, "execute", "tool", action=name, ok=False, step=step_index,
|
|
621
|
+
path=str(args.get("path")) if args.get("path") else None,
|
|
622
|
+
)
|