ltcai 10.0.0 → 10.3.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 +43 -32
- package/docs/CHANGELOG.md +204 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/HYBRID_CLOUD_KG_STREAMING.md +101 -0
- package/docs/MYPY_BACKLOG.md +98 -0
- package/docs/ONBOARDING.md +1 -1
- package/docs/OPERATIONS.md +1 -1
- 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/archive.py +5 -4
- package/lattice_brain/conversations.py +14 -3
- package/lattice_brain/embeddings.py +12 -2
- package/lattice_brain/graph/_kg_common.py +5 -5
- package/lattice_brain/graph/_kg_fsutil.py +5 -4
- package/lattice_brain/graph/discovery.py +4 -3
- package/lattice_brain/graph/discovery_index.py +0 -1
- package/lattice_brain/graph/documents.py +3 -2
- package/lattice_brain/graph/fusion.py +4 -0
- package/lattice_brain/graph/ingest.py +12 -2
- package/lattice_brain/graph/projection.py +3 -2
- package/lattice_brain/graph/provenance.py +5 -3
- package/lattice_brain/graph/rerank.py +4 -1
- package/lattice_brain/graph/retrieval.py +0 -1
- package/lattice_brain/graph/retrieval_docgen.py +0 -1
- package/lattice_brain/graph/retrieval_reads.py +0 -1
- package/lattice_brain/graph/retrieval_vector.py +0 -1
- package/lattice_brain/graph/schema.py +4 -3
- package/lattice_brain/graph/store.py +18 -4
- package/lattice_brain/graph/write_master.py +46 -1
- package/lattice_brain/ingestion.py +4 -1
- package/lattice_brain/portability.py +5 -2
- package/lattice_brain/quality.py +12 -5
- package/lattice_brain/quiet.py +43 -0
- package/lattice_brain/runtime/agent_runtime.py +19 -10
- package/lattice_brain/runtime/hooks.py +3 -2
- package/lattice_brain/runtime/multi_agent.py +2 -3
- package/lattice_brain/sensitivity.py +94 -0
- package/lattice_brain/storage/base.py +30 -2
- package/lattice_brain/storage/migration.py +3 -2
- package/lattice_brain/storage/postgres.py +2 -2
- package/lattice_brain/storage/sqlite.py +6 -4
- package/lattice_brain/workflow.py +4 -2
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/admin.py +1 -1
- package/latticeai/api/agents.py +4 -2
- package/latticeai/api/auth.py +5 -1
- package/latticeai/api/automation_intelligence.py +2 -1
- package/latticeai/api/browser.py +3 -2
- package/latticeai/api/chat.py +28 -17
- package/latticeai/api/chat_agent_http.py +22 -8
- package/latticeai/api/chat_contracts.py +4 -0
- package/latticeai/api/chat_documents.py +6 -2
- package/latticeai/api/chat_hybrid.py +82 -0
- package/latticeai/api/computer_use.py +8 -3
- package/latticeai/api/knowledge_graph.py +1 -1
- package/latticeai/api/mcp.py +4 -4
- package/latticeai/api/models.py +5 -2
- package/latticeai/api/network_boundary.py +220 -0
- package/latticeai/api/permissions.py +0 -1
- package/latticeai/api/realtime.py +1 -1
- package/latticeai/api/security_dashboard.py +1 -1
- package/latticeai/api/setup.py +16 -3
- package/latticeai/api/static_routes.py +2 -1
- package/latticeai/api/tools.py +12 -8
- package/latticeai/api/voice_capture.py +3 -1
- package/latticeai/api/workflow_designer.py +1 -1
- package/latticeai/api/workspace.py +1 -2
- package/latticeai/app_factory.py +174 -145
- package/latticeai/cli/entrypoint.py +6 -4
- package/latticeai/core/agent.py +55 -495
- package/latticeai/core/agent_eval.py +2 -2
- package/latticeai/core/agent_helpers.py +493 -0
- package/latticeai/core/agent_prompts.py +0 -1
- package/latticeai/core/agent_registry.py +3 -1
- package/latticeai/core/agent_state.py +41 -0
- package/latticeai/core/audit.py +1 -1
- package/latticeai/core/builtin_hooks.py +2 -1
- package/latticeai/core/config.py +0 -1
- package/latticeai/core/embedding_providers.py +12 -1
- package/latticeai/core/file_generation.py +3 -0
- package/latticeai/core/invitations.py +4 -1
- package/latticeai/core/io_utils.py +3 -1
- package/latticeai/core/legacy_compatibility.py +1 -2
- package/latticeai/core/local_embeddings.py +12 -1
- package/latticeai/core/marketplace.py +1 -2
- package/latticeai/core/mcp_registry.py +0 -1
- package/latticeai/core/model_compat.py +1 -1
- package/latticeai/core/model_resolution.py +1 -1
- package/latticeai/core/network_boundary.py +168 -0
- package/latticeai/core/oidc.py +3 -0
- package/latticeai/core/permission_mode.py +6 -6
- package/latticeai/core/plugins.py +3 -2
- package/latticeai/core/policy.py +0 -1
- package/latticeai/core/quiet.py +84 -0
- package/latticeai/core/realtime.py +3 -2
- package/latticeai/core/run_store.py +4 -2
- package/latticeai/core/security.py +4 -0
- package/latticeai/core/users.py +5 -3
- package/latticeai/core/workspace_graph_trace.py +3 -0
- package/latticeai/core/workspace_os.py +65 -273
- package/latticeai/core/workspace_os_constants.py +126 -0
- package/latticeai/core/workspace_os_state.py +180 -0
- package/latticeai/core/workspace_os_utils.py +3 -1
- package/latticeai/core/workspace_permissions.py +1 -0
- package/latticeai/core/workspace_snapshots.py +3 -1
- package/latticeai/core/workspace_timeline.py +3 -1
- package/latticeai/integrations/telegram_bot.py +25 -16
- package/latticeai/models/router.py +6 -3
- package/latticeai/runtime/access_runtime.py +3 -1
- package/latticeai/runtime/audit_runtime.py +3 -2
- package/latticeai/runtime/chat_wiring.py +4 -1
- package/latticeai/runtime/history_runtime.py +1 -1
- package/latticeai/runtime/history_writer.py +138 -0
- package/latticeai/runtime/lifespan_runtime.py +3 -1
- package/latticeai/runtime/network_boundary_wiring.py +124 -0
- package/latticeai/runtime/persistence_runtime.py +3 -1
- package/latticeai/runtime/router_registration.py +11 -1
- package/latticeai/services/architecture_readiness.py +1 -2
- package/latticeai/services/change_proposals.py +2 -1
- package/latticeai/services/cloud_egress_audit.py +85 -0
- package/latticeai/services/cloud_extraction.py +129 -0
- package/latticeai/services/cloud_streaming.py +266 -0
- package/latticeai/services/cloud_token_guard.py +84 -0
- package/latticeai/services/command_center.py +2 -1
- package/latticeai/services/folder_watch.py +3 -0
- package/latticeai/services/funnel_metrics.py +3 -1
- package/latticeai/services/hybrid_chat.py +265 -0
- package/latticeai/services/hybrid_context.py +228 -0
- package/latticeai/services/hybrid_policy.py +178 -0
- package/latticeai/services/memory_service.py +1 -1
- package/latticeai/services/model_catalog.py +10 -1
- package/latticeai/services/model_engines.py +35 -14
- package/latticeai/services/model_loading.py +3 -2
- package/latticeai/services/model_runtime.py +68 -17
- package/latticeai/services/multimodal_streaming.py +123 -0
- package/latticeai/services/network_boundary_service.py +154 -0
- package/latticeai/services/openai_compatible_adapter.py +100 -0
- package/latticeai/services/p_reinforce.py +4 -0
- package/latticeai/services/platform_runtime.py +9 -4
- package/latticeai/services/process_audit.py +0 -1
- package/latticeai/services/product_readiness.py +1 -1
- package/latticeai/services/run_executor.py +3 -2
- package/latticeai/services/search_service.py +1 -4
- package/latticeai/services/setup_detection.py +2 -1
- package/latticeai/services/tool_dispatch.py +7 -2
- package/latticeai/services/upload_service.py +2 -1
- package/latticeai/setup/auto_setup.py +10 -7
- package/latticeai/setup/wizard.py +15 -11
- package/latticeai/tools/__init__.py +3 -3
- package/latticeai/tools/commands.py +7 -7
- package/latticeai/tools/computer.py +3 -2
- package/latticeai/tools/documents.py +10 -9
- package/latticeai/tools/filesystem.py +7 -4
- package/latticeai/tools/knowledge.py +2 -0
- package/latticeai/tools/local_files.py +1 -1
- package/latticeai/tools/network.py +2 -1
- package/package.json +7 -3
- package/scripts/bench_agent_smoke.py +0 -1
- package/scripts/bench_models.py +0 -1
- package/scripts/brain_quality_eval.py +7 -2
- package/scripts/bump_version.py +2 -1
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/migrate_brain_storage.py +5 -1
- package/scripts/profile_kg.py +2 -7
- package/scripts/verify_hf_model_registry.py +2 -2
- 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 -37
- package/static/app/assets/{Act-BtCREeN1.js → Act-DrKIGZeJ.js} +1 -1
- package/static/app/assets/{AdminConsole-TPeeN18T.js → AdminConsole-CEV1VefA.js} +1 -1
- package/static/app/assets/{Brain-BKs6JAp0.js → Brain-BZSXnwWU.js} +1 -1
- package/static/app/assets/{BrainHome-BPGOvSd6.js → BrainHome-CEBd86k6.js} +1 -1
- package/static/app/assets/{BrainSignals-CtzQZ15J.js → BrainSignals-O4s6R7av.js} +1 -1
- package/static/app/assets/{Capture-1_NaHWqB.js → Capture-AGbOxsg-.js} +1 -1
- package/static/app/assets/{CommandPalette-pqvQOXe4.js → CommandPalette-CeqBjCn2.js} +1 -1
- package/static/app/assets/Library-CXnbNiQW.js +1 -0
- package/static/app/assets/{LivingBrain-DlQ20Q75.js → LivingBrain-D9Iei2ZJ.js} +1 -1
- package/static/app/assets/{ProductFlow-BZvGDRi_.js → ProductFlow-JlXiDfMM.js} +1 -1
- package/static/app/assets/{ReviewCard-BWgI0D2s.js → ReviewCard-QQly2bTV.js} +1 -1
- package/static/app/assets/System-Bp9ZY4ER.js +1 -0
- package/static/app/assets/{activity-Dlfk8YC7.js → activity-CxRPOQ2F.js} +1 -1
- package/static/app/assets/{bot-CDvUB76P.js → bot-1bcsWB2s.js} +1 -1
- package/static/app/assets/{brain-xczrohrt.js → brain-CxaeKCRM.js} +1 -1
- package/static/app/assets/{button-SOdH3Oyf.js → button-ZYFqGan7.js} +1 -1
- package/static/app/assets/{circle-pause-CG1ythH4.js → circle-pause-B5NOIqvk.js} +1 -1
- package/static/app/assets/{circle-play-HXwvjS6W.js → circle-play-BiJVFXf7.js} +1 -1
- package/static/app/assets/{cpu-B0d-rGyk.js → cpu-Vvq0OKTr.js} +1 -1
- package/static/app/assets/{download-BGIkTQL6.js → download-SEcXzkx1.js} +1 -1
- package/static/app/assets/{folder-open-Dst_Z0_K.js → folder-open-Dw1ff_Qa.js} +1 -1
- package/static/app/assets/{hard-drive-D53MsWkV.js → hard-drive-B00fR7_r.js} +1 -1
- package/static/app/assets/index-CEkL4_M-.css +2 -0
- package/static/app/assets/{index-C_IrlQMV.js → index-Dm9VXl1o.js} +3 -3
- package/static/app/assets/{input-C5m0riF6.js → input-DtdWYQEa.js} +1 -1
- package/static/app/assets/{network-C5a-E5iS.js → network-CpjBu0kI.js} +1 -1
- package/static/app/assets/{primitives-vNXYf58F.js → primitives-CwsudBwc.js} +1 -1
- package/static/app/assets/search-CwEJWBEA.js +1 -0
- package/static/app/assets/{shield-alert-Cc-WVXqN.js → shield-alert-CpjBc-gQ.js} +1 -1
- package/static/app/assets/{textarea-BkZ0EqVO.js → textarea-DUPyjHQs.js} +1 -1
- package/static/app/assets/{useFocusTrap-CTtKbAOU.js → useFocusTrap-BWcMtCWH.js} +1 -1
- package/static/app/assets/{useQuery-Dx1fi4Wu.js → useQuery-Dg5fPqTS.js} +1 -1
- package/static/app/assets/{users-BFpQXtEF.js → users-BgcdTQGM.js} +1 -1
- package/static/app/assets/{utils-BA_lmW3J.js → utils-a3YXFtEO.js} +2 -2
- package/static/app/assets/workspace-BMUEiXUm.js +1 -0
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/static/app/assets/Library-DhvoPvC7.js +0 -1
- package/static/app/assets/System-CSMdYLMy.js +0 -1
- package/static/app/assets/index-FxDusbr0.css +0 -2
- package/static/app/assets/search-DhbSgW6m.js +0 -1
- package/static/app/assets/workspace-DBPB0jkX.js +0 -1
package/latticeai/core/agent.py
CHANGED
|
@@ -20,59 +20,81 @@ only owns the state machine.
|
|
|
20
20
|
|
|
21
21
|
from __future__ import annotations
|
|
22
22
|
|
|
23
|
-
import ast
|
|
24
23
|
import json
|
|
25
24
|
import logging
|
|
26
|
-
import re
|
|
27
25
|
from dataclasses import dataclass
|
|
28
|
-
from enum import Enum
|
|
29
26
|
from pathlib import Path
|
|
30
|
-
from typing import Any, Awaitable, Callable, Dict, FrozenSet, List,
|
|
27
|
+
from typing import Any, Awaitable, Callable, Dict, FrozenSet, List, Optional, Tuple
|
|
31
28
|
|
|
29
|
+
from lattice_brain.runtime.contracts import (
|
|
30
|
+
runtime_boundary_contract,
|
|
31
|
+
single_agent_contract,
|
|
32
|
+
)
|
|
32
33
|
from lattice_brain.runtime.hooks import dispatch_tool
|
|
33
|
-
from
|
|
34
|
+
from latticeai.core.agent_helpers import (
|
|
35
|
+
PhaseBudgets,
|
|
36
|
+
TranscriptBudget,
|
|
37
|
+
_truncate_strings,
|
|
38
|
+
artifact_checklist,
|
|
39
|
+
compact_transcript,
|
|
40
|
+
extract_action,
|
|
41
|
+
extract_action_details,
|
|
42
|
+
files_written,
|
|
43
|
+
filter_learnings,
|
|
44
|
+
format_artifact_checklist,
|
|
45
|
+
format_requirement_coverage,
|
|
46
|
+
normalize_plan,
|
|
47
|
+
requirement_coverage,
|
|
48
|
+
)
|
|
34
49
|
from latticeai.core.agent_permission import (
|
|
35
50
|
block_reason_for_tool,
|
|
36
51
|
non_auto_plan_steps,
|
|
37
52
|
resolve_deps_mode,
|
|
38
53
|
)
|
|
39
54
|
from latticeai.core.agent_profiles import AgentProfile, profile_for_model
|
|
55
|
+
|
|
56
|
+
# The state vocabulary and the pure helpers live in sibling modules so this one
|
|
57
|
+
# holds only the loop. They are re-exported (see ``__all__``) because callers —
|
|
58
|
+
# the HTTP layer, run_store, the eval harness, and the tests — have always
|
|
59
|
+
# imported them from here, and that contract does not change.
|
|
60
|
+
from latticeai.core.agent_state import AGENT_TERMINAL_STATES, AgentState
|
|
40
61
|
from latticeai.core.agent_trace import LoopTrace
|
|
62
|
+
from latticeai.core.file_generation import (
|
|
63
|
+
generate_file_content,
|
|
64
|
+
infer_file_target,
|
|
65
|
+
sanitize_write_content,
|
|
66
|
+
)
|
|
41
67
|
from latticeai.core.permission_mode import (
|
|
42
68
|
PermissionMode,
|
|
43
69
|
is_circuit_breaker,
|
|
44
70
|
plan_requires_approval,
|
|
45
71
|
should_stage_proposal,
|
|
46
72
|
)
|
|
47
|
-
from latticeai.core.file_generation import (
|
|
48
|
-
generate_file_content,
|
|
49
|
-
infer_file_target,
|
|
50
|
-
infer_project_manifest,
|
|
51
|
-
sanitize_write_content,
|
|
52
|
-
)
|
|
53
73
|
from latticeai.core.tool_registry import SCOPED_KNOWLEDGE_TOOLS
|
|
54
74
|
from latticeai.tools import ToolError
|
|
55
75
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
+
__all__ = [
|
|
77
|
+
# this module
|
|
78
|
+
"AgentDeps",
|
|
79
|
+
"AgentRunContext",
|
|
80
|
+
"SingleAgentRuntime",
|
|
81
|
+
# re-exported from agent_state
|
|
82
|
+
"AGENT_TERMINAL_STATES",
|
|
83
|
+
"AgentState",
|
|
84
|
+
# re-exported from agent_helpers
|
|
85
|
+
"PhaseBudgets",
|
|
86
|
+
"TranscriptBudget",
|
|
87
|
+
"artifact_checklist",
|
|
88
|
+
"compact_transcript",
|
|
89
|
+
"extract_action",
|
|
90
|
+
"extract_action_details",
|
|
91
|
+
"files_written",
|
|
92
|
+
"filter_learnings",
|
|
93
|
+
"format_artifact_checklist",
|
|
94
|
+
"format_requirement_coverage",
|
|
95
|
+
"normalize_plan",
|
|
96
|
+
"requirement_coverage",
|
|
97
|
+
]
|
|
76
98
|
|
|
77
99
|
|
|
78
100
|
class AgentRunContext:
|
|
@@ -111,468 +133,6 @@ class AgentRunContext:
|
|
|
111
133
|
self.permission_mode: Optional[str] = None
|
|
112
134
|
|
|
113
135
|
|
|
114
|
-
_THINK_BLOCK_RE = re.compile(
|
|
115
|
-
r"<(think|thinking|reasoning)>.*?</\1>", flags=re.DOTALL | re.IGNORECASE
|
|
116
|
-
)
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
def extract_action_details(raw: str) -> Tuple[Dict, List[str]]:
|
|
120
|
-
"""Parse one JSON action object out of an LLM response (tolerant of fences/prose).
|
|
121
|
-
|
|
122
|
-
Returns ``(action, repairs)`` where ``repairs`` names every tolerance that
|
|
123
|
-
was needed — the loop trace and the weak-model robustness harness consume
|
|
124
|
-
it to measure how much help a given model needs.
|
|
125
|
-
"""
|
|
126
|
-
repairs: List[str] = []
|
|
127
|
-
# Small local models often prepend <think>...</think> reasoning that can
|
|
128
|
-
# itself contain braces — drop it before locating the action object.
|
|
129
|
-
text = _THINK_BLOCK_RE.sub("", raw).strip()
|
|
130
|
-
if text != str(raw).strip():
|
|
131
|
-
repairs.append("think_strip")
|
|
132
|
-
fenced = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, flags=re.DOTALL)
|
|
133
|
-
if fenced:
|
|
134
|
-
text = fenced.group(1).strip()
|
|
135
|
-
repairs.append("fence")
|
|
136
|
-
elif not text.startswith("{"):
|
|
137
|
-
start = text.find("{")
|
|
138
|
-
end = text.rfind("}")
|
|
139
|
-
if start >= 0 and end > start:
|
|
140
|
-
text = text[start : end + 1]
|
|
141
|
-
repairs.append("slice")
|
|
142
|
-
|
|
143
|
-
action: Any = None
|
|
144
|
-
try:
|
|
145
|
-
action = json.loads(text)
|
|
146
|
-
except json.JSONDecodeError:
|
|
147
|
-
# Second chance for the most common small-model JSON slips: trailing
|
|
148
|
-
# commas before a closing brace/bracket.
|
|
149
|
-
repaired = re.sub(r",\s*([}\]])", r"\1", text)
|
|
150
|
-
try:
|
|
151
|
-
action = json.loads(repaired)
|
|
152
|
-
repairs.append("trailing_comma")
|
|
153
|
-
except json.JSONDecodeError as exc:
|
|
154
|
-
# Last chance: weak models sometimes emit a Python dict literal
|
|
155
|
-
# (single quotes, True/False/None). ast.literal_eval parses that
|
|
156
|
-
# deterministically without evaluating code.
|
|
157
|
-
try:
|
|
158
|
-
literal = ast.literal_eval(text)
|
|
159
|
-
except (ValueError, SyntaxError):
|
|
160
|
-
raise ValueError(f"Agent did not return valid JSON: {exc}") from exc
|
|
161
|
-
if not isinstance(literal, dict):
|
|
162
|
-
raise ValueError(f"Agent did not return valid JSON: {exc}") from exc
|
|
163
|
-
action = literal
|
|
164
|
-
repairs.append("python_literal")
|
|
165
|
-
|
|
166
|
-
if not isinstance(action, dict) or "action" not in action:
|
|
167
|
-
raise ValueError("Agent JSON must include an action field.")
|
|
168
|
-
return action, repairs
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
def extract_action(raw: str) -> Dict:
|
|
172
|
-
"""Back-compat wrapper over :func:`extract_action_details`."""
|
|
173
|
-
action, _ = extract_action_details(raw)
|
|
174
|
-
return action
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
_FILE_CREATE_PLAN_ACTIONS = frozenset({"write_file", "generate_file"})
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
def _plan_misses_manifest(steps: List[Dict[str, Any]], manifest: Dict[str, Any]) -> bool:
|
|
181
|
-
"""True when a pure file-writing plan fails to cover the manifest's file types.
|
|
182
|
-
|
|
183
|
-
Only pure file-creation plans are candidates for rewriting — a plan with
|
|
184
|
-
read/search steps reflects real planner intent and stays untouched.
|
|
185
|
-
"""
|
|
186
|
-
if any(s.get("action") not in _FILE_CREATE_PLAN_ACTIONS for s in steps):
|
|
187
|
-
return False
|
|
188
|
-
|
|
189
|
-
def _ext(path: Any) -> str:
|
|
190
|
-
text = str(path or "")
|
|
191
|
-
dot = text.rfind(".")
|
|
192
|
-
return text[dot:].lower() if dot >= 0 else ""
|
|
193
|
-
|
|
194
|
-
planned_exts = {
|
|
195
|
-
_ext((s.get("args") or {}).get("path")) for s in steps
|
|
196
|
-
}
|
|
197
|
-
manifest_exts = {_ext(spec.get("path")) for spec in manifest.get("files", [])}
|
|
198
|
-
return not manifest_exts.issubset(planned_exts)
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
def normalize_plan(plan: Any, user_message: str) -> Tuple[Dict[str, Any], List[str]]:
|
|
202
|
-
"""Enforce the minimal plan schema so execution never starts adrift.
|
|
203
|
-
|
|
204
|
-
A weak planner that returns junk steps / a missing goal previously flowed
|
|
205
|
-
straight into the executor, which then had to reconstruct intent from the
|
|
206
|
-
raw request. Normalization keeps the loop honest: ``goal`` is always a
|
|
207
|
-
non-empty string, ``steps`` only contains dicts with an ``action``, and an
|
|
208
|
-
empty plan for an obvious file-creation request gets a deterministic
|
|
209
|
-
single ``write_file`` step instead of leaving the executor to improvise.
|
|
210
|
-
|
|
211
|
-
Returns ``(plan, fixes)`` where ``fixes`` names every applied repair —
|
|
212
|
-
the loop trace records them so plan quality is observable per model.
|
|
213
|
-
"""
|
|
214
|
-
fixes: List[str] = []
|
|
215
|
-
if not isinstance(plan, dict):
|
|
216
|
-
plan = {}
|
|
217
|
-
fixes.append("plan_not_object")
|
|
218
|
-
plan = dict(plan)
|
|
219
|
-
|
|
220
|
-
goal = str(plan.get("goal") or "").strip()
|
|
221
|
-
if not goal:
|
|
222
|
-
plan["goal"] = user_message
|
|
223
|
-
fixes.append("goal_defaulted")
|
|
224
|
-
|
|
225
|
-
raw_steps = plan.get("steps")
|
|
226
|
-
steps = [
|
|
227
|
-
s for s in (raw_steps if isinstance(raw_steps, list) else [])
|
|
228
|
-
if isinstance(s, dict) and s.get("action")
|
|
229
|
-
]
|
|
230
|
-
if raw_steps and steps != raw_steps:
|
|
231
|
-
fixes.append("steps_filtered")
|
|
232
|
-
|
|
233
|
-
# Manifest-aware planning (review Wave 0.4): when the request is a
|
|
234
|
-
# recognized multi-file project, the deterministic manifest — not the
|
|
235
|
-
# planner's improvisation — decides the file set, exactly like the direct
|
|
236
|
-
# chat path. Rewrites apply only when the plan is empty or is a pure
|
|
237
|
-
# file-writing plan that misses part of the manifest, so a planner that
|
|
238
|
-
# already covered every requested file type is left untouched.
|
|
239
|
-
manifest = infer_project_manifest(user_message)
|
|
240
|
-
if manifest:
|
|
241
|
-
manifest_steps = [{
|
|
242
|
-
"action": "write_file",
|
|
243
|
-
"args": {"path": spec["path"]},
|
|
244
|
-
"description": spec["brief"],
|
|
245
|
-
} for spec in manifest["files"]]
|
|
246
|
-
if not steps:
|
|
247
|
-
steps = manifest_steps
|
|
248
|
-
fixes.append("manifest_steps")
|
|
249
|
-
elif _plan_misses_manifest(steps, manifest):
|
|
250
|
-
steps = manifest_steps
|
|
251
|
-
fixes.append("manifest_rewrite")
|
|
252
|
-
|
|
253
|
-
if not steps:
|
|
254
|
-
inferred = infer_file_target(user_message)
|
|
255
|
-
if inferred:
|
|
256
|
-
steps = [{
|
|
257
|
-
"action": "write_file",
|
|
258
|
-
"args": {"path": inferred},
|
|
259
|
-
"description": f"Create {inferred} for: {user_message[:120]}",
|
|
260
|
-
}]
|
|
261
|
-
fixes.append("heuristic_file_step")
|
|
262
|
-
plan["steps"] = steps
|
|
263
|
-
|
|
264
|
-
try:
|
|
265
|
-
estimated = int(plan.get("estimated_steps") or 0)
|
|
266
|
-
except (TypeError, ValueError):
|
|
267
|
-
estimated = 0
|
|
268
|
-
fixes.append("estimated_steps_invalid")
|
|
269
|
-
plan["estimated_steps"] = max(1, estimated, len(steps))
|
|
270
|
-
plan["requires_approval"] = bool(plan.get("requires_approval", False))
|
|
271
|
-
if not isinstance(plan.get("rollback_strategy"), str):
|
|
272
|
-
plan["rollback_strategy"] = "none"
|
|
273
|
-
return plan, fixes
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
_TRIVIAL_LEARNING_RE = re.compile(
|
|
277
|
-
r"^(파일(을|이)?\s*(만들|생성|작성|저장)|작업(을|이)?\s*(완료|성공)|성공적으로"
|
|
278
|
-
r"|task\s+(was\s+)?complet|file\s+(was\s+)?(creat|written|saved)"
|
|
279
|
-
r"|(successfully\s+)?(created|completed|finished|done)\b)",
|
|
280
|
-
re.IGNORECASE,
|
|
281
|
-
)
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
def filter_learnings(learnings: List[Any]) -> List[str]:
|
|
285
|
-
"""Drop trivial/duplicate learnings before they enter the brain.
|
|
286
|
-
|
|
287
|
-
"파일을 만들었다"-class statements restate what the transcript already
|
|
288
|
-
records and pollute recall. A learning survives when it is long enough to
|
|
289
|
-
carry information and is not a bare completion announcement.
|
|
290
|
-
"""
|
|
291
|
-
kept: List[str] = []
|
|
292
|
-
seen: set = set()
|
|
293
|
-
for raw in learnings or []:
|
|
294
|
-
text = str(raw or "").strip()
|
|
295
|
-
if len(text) < 12:
|
|
296
|
-
continue
|
|
297
|
-
if _TRIVIAL_LEARNING_RE.match(text) and len(text) < 48:
|
|
298
|
-
continue
|
|
299
|
-
key = text.lower()
|
|
300
|
-
if key in seen:
|
|
301
|
-
continue
|
|
302
|
-
seen.add(key)
|
|
303
|
-
kept.append(text)
|
|
304
|
-
return kept
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
def _truncate_strings(value: Any, limit: int) -> Any:
|
|
308
|
-
"""Deep-copy ``value`` with every string capped at ``limit`` chars.
|
|
309
|
-
|
|
310
|
-
Long tool outputs (file bodies, command output) dominate executor prompt
|
|
311
|
-
size without adding decision-relevant signal. The cap keeps the head of
|
|
312
|
-
each string and names how much was dropped, so the model still sees what
|
|
313
|
-
the value was — never a silent hole.
|
|
314
|
-
"""
|
|
315
|
-
if isinstance(value, str):
|
|
316
|
-
if len(value) <= limit:
|
|
317
|
-
return value
|
|
318
|
-
return value[:limit] + f"…[+{len(value) - limit} chars]"
|
|
319
|
-
if isinstance(value, dict):
|
|
320
|
-
return {k: _truncate_strings(v, limit) for k, v in value.items()}
|
|
321
|
-
if isinstance(value, list):
|
|
322
|
-
return [_truncate_strings(v, limit) for v in value]
|
|
323
|
-
return value
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
def compact_transcript(
|
|
327
|
-
transcript: List[Dict[str, Any]],
|
|
328
|
-
*,
|
|
329
|
-
window: int = 8,
|
|
330
|
-
result_chars: int = 700,
|
|
331
|
-
) -> List[Dict[str, Any]]:
|
|
332
|
-
"""Bounded executor view of a transcript (review Wave 0.3).
|
|
333
|
-
|
|
334
|
-
The executor prompt previously embedded the *entire* transcript JSON every
|
|
335
|
-
step — O(steps²) token growth that starved :class:`PhaseBudgets` on long
|
|
336
|
-
runs and buried weak models in stale detail. This view keeps the most
|
|
337
|
-
recent ``window`` steps in full (with string values capped at
|
|
338
|
-
``result_chars``) and reduces every older step to a one-line summary, so
|
|
339
|
-
the prompt stays bounded while no step disappears entirely.
|
|
340
|
-
"""
|
|
341
|
-
steps = list(transcript or [])
|
|
342
|
-
if len(steps) <= window:
|
|
343
|
-
return [_truncate_strings(step, result_chars) for step in steps]
|
|
344
|
-
older, recent = steps[:-window], steps[-window:]
|
|
345
|
-
summarized: List[Dict[str, Any]] = [{
|
|
346
|
-
"summarized_older_steps": len(older),
|
|
347
|
-
"note": "older steps compacted — full detail retained in the run record",
|
|
348
|
-
}]
|
|
349
|
-
for step in older:
|
|
350
|
-
entry: Dict[str, Any] = {"state": step.get("state")}
|
|
351
|
-
for key in ("action", "verdict", "retry_attempt"):
|
|
352
|
-
if step.get(key) is not None:
|
|
353
|
-
entry[key] = step.get(key)
|
|
354
|
-
if step.get("error"):
|
|
355
|
-
entry["error"] = str(step["error"])[:160]
|
|
356
|
-
elif isinstance(step.get("result"), dict):
|
|
357
|
-
entry["ok"] = True
|
|
358
|
-
path = step["result"].get("path") or (step.get("args") or {}).get("path")
|
|
359
|
-
if path:
|
|
360
|
-
entry["path"] = str(path)
|
|
361
|
-
summarized.append(entry)
|
|
362
|
-
summarized.extend(_truncate_strings(step, result_chars) for step in recent)
|
|
363
|
-
return summarized
|
|
364
|
-
|
|
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
|
-
|
|
506
|
-
@dataclass(frozen=True)
|
|
507
|
-
class TranscriptBudget:
|
|
508
|
-
"""Executor/critic prompt shaping caps (review Wave 0.3).
|
|
509
|
-
|
|
510
|
-
``window`` full recent steps for the executor; per-string caps keep tool
|
|
511
|
-
output bodies from dominating either prompt. Overridable through the same
|
|
512
|
-
``Config.from_env`` pattern as :class:`PhaseBudgets`.
|
|
513
|
-
"""
|
|
514
|
-
|
|
515
|
-
window: int = 8
|
|
516
|
-
result_chars: int = 700
|
|
517
|
-
verify_chars: int = 1200
|
|
518
|
-
|
|
519
|
-
@classmethod
|
|
520
|
-
def from_env(cls, env: Optional[Mapping[str, str]] = None) -> "TranscriptBudget":
|
|
521
|
-
from latticeai.core.config import _int
|
|
522
|
-
|
|
523
|
-
if env is None:
|
|
524
|
-
import os
|
|
525
|
-
|
|
526
|
-
env = os.environ
|
|
527
|
-
|
|
528
|
-
def cap(key: str, default: int, floor: int) -> int:
|
|
529
|
-
return max(floor, _int(env, key, default))
|
|
530
|
-
|
|
531
|
-
return cls(
|
|
532
|
-
window=cap("LATTICEAI_AGENT_TRANSCRIPT_WINDOW", cls.window, 2),
|
|
533
|
-
result_chars=cap("LATTICEAI_AGENT_TRANSCRIPT_CHARS", cls.result_chars, 120),
|
|
534
|
-
verify_chars=cap("LATTICEAI_AGENT_VERIFY_CHARS", cls.verify_chars, 200),
|
|
535
|
-
)
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
@dataclass(frozen=True)
|
|
539
|
-
class PhaseBudgets:
|
|
540
|
-
"""Per-phase token budgets for the agent loop.
|
|
541
|
-
|
|
542
|
-
One shared budget let a weak model burn everything on planning prose and
|
|
543
|
-
reach EXECUTE with nothing left. Each role phase now has its own cap, so
|
|
544
|
-
a verbose planner can never starve execution or verification. Defaults
|
|
545
|
-
match the historical hardcoded values; every cap is overridable through
|
|
546
|
-
the ``Config.from_env`` environment pattern.
|
|
547
|
-
"""
|
|
548
|
-
|
|
549
|
-
plan_tokens: int = 1024
|
|
550
|
-
execute_tokens: int = 4096
|
|
551
|
-
verify_tokens: int = 512
|
|
552
|
-
memory_tokens: int = 256
|
|
553
|
-
|
|
554
|
-
@classmethod
|
|
555
|
-
def from_env(cls, env: Optional[Mapping[str, str]] = None) -> "PhaseBudgets":
|
|
556
|
-
from latticeai.core.config import _int
|
|
557
|
-
|
|
558
|
-
if env is None:
|
|
559
|
-
import os
|
|
560
|
-
|
|
561
|
-
env = os.environ
|
|
562
|
-
|
|
563
|
-
def cap(key: str, default: int) -> int:
|
|
564
|
-
# A misconfigured/absurd value must not brick the loop: floor at a
|
|
565
|
-
# budget that still fits one JSON action object.
|
|
566
|
-
return max(128, _int(env, key, default))
|
|
567
|
-
|
|
568
|
-
return cls(
|
|
569
|
-
plan_tokens=cap("LATTICEAI_AGENT_PLAN_TOKENS", cls.plan_tokens),
|
|
570
|
-
execute_tokens=cap("LATTICEAI_AGENT_EXECUTE_TOKENS", cls.execute_tokens),
|
|
571
|
-
verify_tokens=cap("LATTICEAI_AGENT_VERIFY_TOKENS", cls.verify_tokens),
|
|
572
|
-
memory_tokens=cap("LATTICEAI_AGENT_MEMORY_TOKENS", cls.memory_tokens),
|
|
573
|
-
)
|
|
574
|
-
|
|
575
|
-
|
|
576
136
|
@dataclass
|
|
577
137
|
class AgentDeps:
|
|
578
138
|
"""The ports a :class:`SingleAgentRuntime` needs from the outside world.
|
|
@@ -1445,7 +1005,7 @@ class SingleAgentRuntime:
|
|
|
1445
1005
|
# sanitize/repair honesty flags per written file, not just prose.
|
|
1446
1006
|
checklist = artifact_checklist(ctx.transcript, d.file_create_actions)
|
|
1447
1007
|
checklist_hint = (
|
|
1448
|
-
f"\n\n{
|
|
1008
|
+
f"\n\n{format_artifact_checklist(checklist)}" if checklist else ""
|
|
1449
1009
|
)
|
|
1450
1010
|
# Requirement coverage (review 루프 §2): the critic previously judged
|
|
1451
1011
|
# "did this fulfill the request?" from prose alone. It now also sees
|
|
@@ -1459,7 +1019,7 @@ class SingleAgentRuntime:
|
|
|
1459
1019
|
f"[LANGUAGE HINT: {lang_hint}]\n\n"
|
|
1460
1020
|
f"Original request: {req.message}\n"
|
|
1461
1021
|
f"Plan goal: {ctx.plan.get('goal', req.message)}{checklist_hint}"
|
|
1462
|
-
f"{
|
|
1022
|
+
f"{format_requirement_coverage(coverage)}\n\n"
|
|
1463
1023
|
f"Full transcript:\n{json.dumps(verify_transcript, ensure_ascii=False, indent=2)}"
|
|
1464
1024
|
)
|
|
1465
1025
|
raw = await d.generate_as(
|
|
@@ -249,7 +249,7 @@ def _build_deps(
|
|
|
249
249
|
knowledge_save=lambda *a, **kw: None,
|
|
250
250
|
audit=lambda *a, **kw: None,
|
|
251
251
|
planner_prompt="plan", executor_prompt="exec", critic_prompt="critic",
|
|
252
|
-
memory_updater_prompt="mem", agent_root=Path("/tmp/agent-eval"),
|
|
252
|
+
memory_updater_prompt="mem", agent_root=Path("/tmp/agent-eval"), # noqa: S108 — eval sandbox root, created with 0700 by the harness
|
|
253
253
|
change_governor=governor,
|
|
254
254
|
)
|
|
255
255
|
|
|
@@ -257,7 +257,7 @@ def _build_deps(
|
|
|
257
257
|
_PLAN = '{"action": "plan", "goal": "task", "steps": [{"action": "write_file"}]}'
|
|
258
258
|
_WRITE = '{"action": "write_file", "args": {"path": "note.txt", "content": "hi"}}'
|
|
259
259
|
_FINAL = '{"action": "final", "message": "done"}'
|
|
260
|
-
_PASS = '{"action": "verdict", "verdict": "PASS", "next_state": "DONE", "reason": "ok"}'
|
|
260
|
+
_PASS = '{"action": "verdict", "verdict": "PASS", "next_state": "DONE", "reason": "ok"}' # noqa: S105 — a transcript marker string, not a credential
|
|
261
261
|
|
|
262
262
|
# ── dirty write_file payloads (ArtifactWritePipeline scenarios) ──────────
|
|
263
263
|
# What weak local models actually put in args.content: chat framing + a
|