localcode 0.4.0__py3-none-macosx_13_0_arm64.whl
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.
- localcode/__init__.py +11 -0
- localcode/__main__.py +5 -0
- localcode/_subproc_env.py +54 -0
- localcode/agent/__init__.py +177 -0
- localcode/agent/app_tasks.py +157 -0
- localcode/agent/constants.py +275 -0
- localcode/agent/context.py +1152 -0
- localcode/agent/goal.py +244 -0
- localcode/agent/helpers.py +1036 -0
- localcode/agent/hollow_module.py +230 -0
- localcode/agent/hooks.py +414 -0
- localcode/agent/loop.py +2855 -0
- localcode/agent/plateau.py +297 -0
- localcode/agent/project_check_gate.py +91 -0
- localcode/agent/prompt_context.py +264 -0
- localcode/agent/prompts.py +368 -0
- localcode/agent/reasoning_loop.py +79 -0
- localcode/agent/recovery.py +606 -0
- localcode/agent/recovery_controller.py +25 -0
- localcode/agent/round_policy.py +23 -0
- localcode/agent/sections.py +241 -0
- localcode/agent/state_machine.py +100 -0
- localcode/agent/streaming.py +324 -0
- localcode/agent/tool_execution.py +368 -0
- localcode/agent/tool_orchestration.py +54 -0
- localcode/agent/turn_finalization.py +135 -0
- localcode/app.py +1595 -0
- localcode/approvals.py +17 -0
- localcode/auto_compact.py +176 -0
- localcode/autonomy.py +103 -0
- localcode/bin/__init__.py +0 -0
- localcode/bin/llama-server +0 -0
- localcode/bin/localcode-ui +4 -0
- localcode/bootstrap.py +1191 -0
- localcode/cache.py +194 -0
- localcode/checkpoint.py +375 -0
- localcode/compact.py +73 -0
- localcode/compaction.py +341 -0
- localcode/composer.py +72 -0
- localcode/config.py +521 -0
- localcode/context.py +161 -0
- localcode/display.py +58 -0
- localcode/embeddings.py +480 -0
- localcode/entrypoint.py +536 -0
- localcode/errors.py +350 -0
- localcode/events.py +409 -0
- localcode/evidence.py +102 -0
- localcode/execution_policy.py +48 -0
- localcode/features.py +308 -0
- localcode/formatting.py +91 -0
- localcode/headless_json.py +265 -0
- localcode/health.py +288 -0
- localcode/hf_quants.py +203 -0
- localcode/history.py +594 -0
- localcode/indexer.py +144 -0
- localcode/injection_defense.py +204 -0
- localcode/launcher.py +373 -0
- localcode/logging_utils.py +28 -0
- localcode/lsp.py +153 -0
- localcode/mcp/__init__.py +88 -0
- localcode/mcp/_bridge.py +61 -0
- localcode/mcp/_config.py +33 -0
- localcode/mcp/_transports.py +128 -0
- localcode/mcp/client.py +395 -0
- localcode/memory_guard.py +291 -0
- localcode/model_config.py +288 -0
- localcode/model_delete.py +444 -0
- localcode/model_families.py +279 -0
- localcode/models.py +236 -0
- localcode/models_catalog.py +901 -0
- localcode/notebook.py +123 -0
- localcode/output.py +403 -0
- localcode/patching.py +65 -0
- localcode/paths.py +303 -0
- localcode/performance.py +623 -0
- localcode/permissions.py +79 -0
- localcode/permissions_v2.py +310 -0
- localcode/plans.py +198 -0
- localcode/process_registry.py +271 -0
- localcode/protocol/__init__.py +56 -0
- localcode/protocol/events.py +116 -0
- localcode/protocol/jsonl.py +153 -0
- localcode/protocol/outcomes.py +231 -0
- localcode/reasoning_capabilities.py +59 -0
- localcode/recommendations.py +87 -0
- localcode/recovery.py +134 -0
- localcode/redaction.py +164 -0
- localcode/runtime.py +2331 -0
- localcode/server_manager.py +1069 -0
- localcode/session.py +296 -0
- localcode/shell.py +83 -0
- localcode/skills/debug.md +21 -0
- localcode/skills/edit-verified.md +30 -0
- localcode/skills/explain.md +19 -0
- localcode/skills/finish-verified.md +11 -0
- localcode/skills/git-commit-safely.md +30 -0
- localcode/skills/locate.md +21 -0
- localcode/skills/plan-task.md +51 -0
- localcode/skills/review.md +22 -0
- localcode/skills/run-tests.md +24 -0
- localcode/skills.py +728 -0
- localcode/snapshots.py +195 -0
- localcode/sounds.py +50 -0
- localcode/telemetry.py +273 -0
- localcode/theme.py +137 -0
- localcode/thermal.py +160 -0
- localcode/thinking.py +114 -0
- localcode/tool_router.py +246 -0
- localcode/toolkit.py +1241 -0
- localcode/tools/__init__.py +400 -0
- localcode/tools/agent.py +259 -0
- localcode/tools/append_file.py +55 -0
- localcode/tools/background_process.py +115 -0
- localcode/tools/base.py +168 -0
- localcode/tools/bash.py +1160 -0
- localcode/tools/code_navigation.py +97 -0
- localcode/tools/edit_diff.py +80 -0
- localcode/tools/edit_file.py +477 -0
- localcode/tools/facts.py +149 -0
- localcode/tools/glob_tool.py +60 -0
- localcode/tools/grep.py +65 -0
- localcode/tools/inspect_symbol.py +266 -0
- localcode/tools/launch_app.py +93 -0
- localcode/tools/list_files.py +42 -0
- localcode/tools/multi_edit.py +167 -0
- localcode/tools/plan_mode.py +86 -0
- localcode/tools/project_check.py +685 -0
- localcode/tools/read_file.py +261 -0
- localcode/tools/read_state.py +223 -0
- localcode/tools/skill_tool.py +41 -0
- localcode/tools/syntax_check.py +216 -0
- localcode/tools/todo_write.py +200 -0
- localcode/tools/tool_call_repair.py +157 -0
- localcode/tools/web_fetch.py +182 -0
- localcode/tools/web_search.py +45 -0
- localcode/tools/write_file.py +281 -0
- localcode/tui/__init__.py +1 -0
- localcode/tui/app.py +485 -0
- localcode/tui/bridge.py +78 -0
- localcode/tui/clipboard_image.py +153 -0
- localcode/tui/paste_collapse.py +95 -0
- localcode/tui/screens/__init__.py +0 -0
- localcode/tui/screens/chat.py +5052 -0
- localcode/tui/screens/confirm.py +123 -0
- localcode/tui/screens/mode_picker.py +79 -0
- localcode/tui/screens/model_picker.py +962 -0
- localcode/tui/screens/setup.py +891 -0
- localcode/tui/styles/__init__.py +0 -0
- localcode/tui/styles/app.tcss +197 -0
- localcode/tui/widgets/__init__.py +0 -0
- localcode/tui/widgets/approval.py +2 -0
- localcode/tui/widgets/chat_log.py +2063 -0
- localcode/tui/widgets/messages/__init__.py +21 -0
- localcode/tui/widgets/messages/diff.py +287 -0
- localcode/tui/widgets/voice_visualizer.py +122 -0
- localcode/turn_diff.py +180 -0
- localcode/ui/FORK_COMMIT +1 -0
- localcode/ui/__init__.py +58 -0
- localcode/ui/launch.py +204 -0
- localcode/ui/picker_cli.py +165 -0
- localcode/ui/plugin/localcode.ts +626 -0
- localcode/ui/ports.py +42 -0
- localcode/ui/server_cmd.py +89 -0
- localcode/ui/supervisor.py +736 -0
- localcode/undo.py +121 -0
- localcode/verification.py +87 -0
- localcode/voice.py +948 -0
- localcode-0.4.0.dist-info/METADATA +160 -0
- localcode-0.4.0.dist-info/RECORD +173 -0
- localcode-0.4.0.dist-info/WHEEL +5 -0
- localcode-0.4.0.dist-info/entry_points.txt +3 -0
- localcode-0.4.0.dist-info/licenses/LICENSE +201 -0
- localcode-0.4.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
"""Tool registry for the agent loop.
|
|
2
|
+
|
|
3
|
+
Each tool has its own module under this package exporting:
|
|
4
|
+
* SCHEMA — the OpenAI function schema
|
|
5
|
+
* execute(ctx, args) — the implementation
|
|
6
|
+
|
|
7
|
+
This module assembles the registry into two forms:
|
|
8
|
+
* ALL_SCHEMAS — list of schema dicts, passed to llama-server's
|
|
9
|
+
`tools` parameter so the model sees them.
|
|
10
|
+
* dispatch(name, ctx, args) — name → execute resolver used by
|
|
11
|
+
`src/localcode/agent.py:_execute_tool`.
|
|
12
|
+
|
|
13
|
+
Adding a tool:
|
|
14
|
+
1. Create src/localcode/tools/<tool_name>.py with SCHEMA + execute.
|
|
15
|
+
2. Import it in this file.
|
|
16
|
+
3. Register it in `_TOOLS` below.
|
|
17
|
+
|
|
18
|
+
That's it. No edits to agent.py for the happy path (plan-mode gating
|
|
19
|
+
still lives there because it's cross-tool policy).
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import os
|
|
24
|
+
|
|
25
|
+
from typing import Callable
|
|
26
|
+
|
|
27
|
+
from . import (
|
|
28
|
+
agent,
|
|
29
|
+
append_file,
|
|
30
|
+
bash,
|
|
31
|
+
background_process,
|
|
32
|
+
code_navigation,
|
|
33
|
+
edit_diff,
|
|
34
|
+
edit_file,
|
|
35
|
+
glob_tool,
|
|
36
|
+
grep,
|
|
37
|
+
inspect_symbol,
|
|
38
|
+
launch_app,
|
|
39
|
+
list_files,
|
|
40
|
+
multi_edit,
|
|
41
|
+
plan_mode,
|
|
42
|
+
read_file,
|
|
43
|
+
skill_tool,
|
|
44
|
+
todo_write,
|
|
45
|
+
web_fetch,
|
|
46
|
+
web_search,
|
|
47
|
+
write_file,
|
|
48
|
+
)
|
|
49
|
+
from ..paths import PathContainmentError
|
|
50
|
+
from .base import ToolContext, ToolResult
|
|
51
|
+
from .facts import extract_tool_facts
|
|
52
|
+
|
|
53
|
+
# name → (schema, executor). For plan_mode the module provides two
|
|
54
|
+
# (schema, executor) pairs under a single file.
|
|
55
|
+
_TOOLS: dict[str, tuple[dict, Callable[[ToolContext, dict], str]]] = {
|
|
56
|
+
"read_file": (read_file.SCHEMA, read_file.execute),
|
|
57
|
+
"write_file": (write_file.SCHEMA, write_file.execute),
|
|
58
|
+
"append_file": (append_file.SCHEMA, append_file.execute),
|
|
59
|
+
"edit_file": (edit_file.SCHEMA, edit_file.execute),
|
|
60
|
+
"multi_edit": (multi_edit.SCHEMA, multi_edit.execute),
|
|
61
|
+
"edit_diff": (edit_diff.SCHEMA, edit_diff.execute),
|
|
62
|
+
"bash": (bash.SCHEMA, bash.execute),
|
|
63
|
+
"background_process": (background_process.SCHEMA, background_process.execute),
|
|
64
|
+
"code_navigation": (code_navigation.SCHEMA, code_navigation.execute),
|
|
65
|
+
"launch_app": (launch_app.SCHEMA, launch_app.execute),
|
|
66
|
+
"grep": (grep.SCHEMA, grep.execute),
|
|
67
|
+
"inspect_symbol": (inspect_symbol.SCHEMA, inspect_symbol.execute),
|
|
68
|
+
"glob": (glob_tool.SCHEMA, glob_tool.execute),
|
|
69
|
+
"list_files": (list_files.SCHEMA, list_files.execute),
|
|
70
|
+
"web_search": (web_search.SCHEMA, web_search.execute),
|
|
71
|
+
"web_fetch": (web_fetch.SCHEMA, web_fetch.execute),
|
|
72
|
+
"skill": (skill_tool.SCHEMA, skill_tool.execute),
|
|
73
|
+
"todo_write": (todo_write.SCHEMA, todo_write.execute),
|
|
74
|
+
"agent": (agent.SCHEMA, agent.execute),
|
|
75
|
+
"enter_plan_mode": (plan_mode.ENTER_SCHEMA, plan_mode.execute_enter),
|
|
76
|
+
"exit_plan_mode": (plan_mode.EXIT_SCHEMA, plan_mode.execute_exit),
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
_PUBLIC_TOOL_NAMES = [
|
|
80
|
+
"read_file",
|
|
81
|
+
"write_file",
|
|
82
|
+
"append_file",
|
|
83
|
+
"edit_file",
|
|
84
|
+
"multi_edit",
|
|
85
|
+
"edit_diff",
|
|
86
|
+
"bash",
|
|
87
|
+
"background_process",
|
|
88
|
+
"code_navigation",
|
|
89
|
+
"launch_app",
|
|
90
|
+
"grep",
|
|
91
|
+
"inspect_symbol",
|
|
92
|
+
"glob",
|
|
93
|
+
"list_files",
|
|
94
|
+
"web_search",
|
|
95
|
+
"web_fetch",
|
|
96
|
+
"skill",
|
|
97
|
+
"todo_write",
|
|
98
|
+
"agent",
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
ALL_SCHEMAS: list[dict] = [
|
|
102
|
+
_TOOLS[name][0]
|
|
103
|
+
for name in _PUBLIC_TOOL_NAMES
|
|
104
|
+
if name in _TOOLS
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
AVAILABLE_NAMES: list[str] = list(_PUBLIC_TOOL_NAMES)
|
|
108
|
+
|
|
109
|
+
def schemas_for_names(
|
|
110
|
+
names: list[str] | tuple[str, ...] | set[str],
|
|
111
|
+
*,
|
|
112
|
+
content_max_chars: int | None = None,
|
|
113
|
+
) -> list[dict]:
|
|
114
|
+
"""Return schemas in registry order for a selected tool subset."""
|
|
115
|
+
wanted = {str(name).strip() for name in names}
|
|
116
|
+
return [
|
|
117
|
+
_TOOLS[name][0]
|
|
118
|
+
for name in _PUBLIC_TOOL_NAMES
|
|
119
|
+
if name in wanted and name in _TOOLS
|
|
120
|
+
]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def schemas_for_goal(
|
|
124
|
+
goal_type: str,
|
|
125
|
+
user_text: str = "",
|
|
126
|
+
task_stage: str = "",
|
|
127
|
+
recovery_mode: str = "",
|
|
128
|
+
) -> list[dict]:
|
|
129
|
+
"""Return the flat coding tool surface used every round.
|
|
130
|
+
|
|
131
|
+
Tool routing, build stages, and recovery-specific schema mutation made the
|
|
132
|
+
controller brittle. The model sees the same compact set every round; tools
|
|
133
|
+
return actionable errors and the loop may add at most two generic
|
|
134
|
+
correction nudges.
|
|
135
|
+
"""
|
|
136
|
+
selected = {
|
|
137
|
+
"read_file", "bash", "edit_file", "write_file", "append_file", "list_files",
|
|
138
|
+
"background_process", "code_navigation",
|
|
139
|
+
# Search/discovery — without these the model resorts to bash+grep / curl
|
|
140
|
+
# for everything and the user sees the "google scraped HTML" failure mode.
|
|
141
|
+
"grep", "glob", "web_search", "web_fetch", "skill",
|
|
142
|
+
# Read a third-party API's REAL signature from installed types instead of
|
|
143
|
+
# guessing it (the fsrs.repeat()[1] failure class). LSP-hover, on demand.
|
|
144
|
+
"inspect_symbol",
|
|
145
|
+
# Sub-agent — lets the model spawn focused sub-tasks (explore/plan/verify
|
|
146
|
+
# /general-purpose) so it doesn't burn its own context on long searches.
|
|
147
|
+
"agent",
|
|
148
|
+
# Working-memory checklist — lets the model record a plan and track
|
|
149
|
+
# done/in-progress/remaining across rounds so it stops repeating work.
|
|
150
|
+
"todo_write",
|
|
151
|
+
}
|
|
152
|
+
# LOCALCODE_DISABLE_TOOLS="web_search,agent" removes tools from the surface for
|
|
153
|
+
# this process. Exists for controlled comparisons (every frontend must offer
|
|
154
|
+
# the same tools in a benchmark) and for users who want a tool gone.
|
|
155
|
+
_disabled = {t.strip() for t in os.environ.get("LOCALCODE_DISABLE_TOOLS", "").split(",") if t.strip()}
|
|
156
|
+
selected -= _disabled
|
|
157
|
+
schemas = schemas_for_names(selected)
|
|
158
|
+
# MCP tools — any tools exposed by user-configured MCP servers in
|
|
159
|
+
# ~/.localcode/mcp.json. Names are prefixed `mcp_<server>_<tool>`
|
|
160
|
+
# so they don't collide with built-ins. Best-effort: if no servers
|
|
161
|
+
# are configured or any fail to connect, we just return built-ins.
|
|
162
|
+
try:
|
|
163
|
+
from ..mcp import mcp_tool_schemas
|
|
164
|
+
schemas = schemas + mcp_tool_schemas()
|
|
165
|
+
except Exception:
|
|
166
|
+
pass
|
|
167
|
+
return schemas
|
|
168
|
+
|
|
169
|
+
_MODULES = {
|
|
170
|
+
"read_file": read_file,
|
|
171
|
+
"write_file": write_file,
|
|
172
|
+
"append_file": append_file,
|
|
173
|
+
"edit_file": edit_file,
|
|
174
|
+
"multi_edit": multi_edit,
|
|
175
|
+
"edit_diff": edit_diff,
|
|
176
|
+
"bash": bash,
|
|
177
|
+
"launch_app": launch_app,
|
|
178
|
+
"grep": grep,
|
|
179
|
+
"inspect_symbol": inspect_symbol,
|
|
180
|
+
"glob": glob_tool,
|
|
181
|
+
"list_files": list_files,
|
|
182
|
+
"web_search": web_search,
|
|
183
|
+
"web_fetch": web_fetch,
|
|
184
|
+
"skill": skill_tool,
|
|
185
|
+
"todo_write": todo_write,
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def is_concurrency_safe(name: str, args: dict) -> bool:
|
|
190
|
+
mod = _MODULES.get((name or "").strip())
|
|
191
|
+
checker = getattr(mod, "is_concurrency_safe", None)
|
|
192
|
+
if checker is None:
|
|
193
|
+
return False
|
|
194
|
+
try:
|
|
195
|
+
return bool(checker(args))
|
|
196
|
+
except Exception:
|
|
197
|
+
return False
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _format_missing_args_error(tool_name: str, schema: dict, args: dict,
|
|
201
|
+
missing: list[str]) -> str:
|
|
202
|
+
"""Build a model-actionable error when required args are missing.
|
|
203
|
+
|
|
204
|
+
The old message — "Error: 'path' argument is required for write_file."
|
|
205
|
+
— told the model WHICH field was missing but not WHAT IT DID emit,
|
|
206
|
+
WHY it's wrong, or HOW to fix it. IQ3 quant would read that, think
|
|
207
|
+
about it, retry with the SAME missing arg, get the same error, and
|
|
208
|
+
eventually spiral into a "I'll use bash / no, write_file / no,
|
|
209
|
+
bash" repetition loop (the failure mode in images 103-105).
|
|
210
|
+
|
|
211
|
+
This message echoes the exact args the model emitted, spells out
|
|
212
|
+
what's missing with the schema, and shows the correct call shape
|
|
213
|
+
inline — giving the model everything it needs to self-correct on
|
|
214
|
+
the very next round instead of guessing."""
|
|
215
|
+
import json as _json
|
|
216
|
+
required = schema.get("function", {}).get("parameters", {}).get("required", [])
|
|
217
|
+
|
|
218
|
+
# Short snippet of what the model DID send, so it can compare.
|
|
219
|
+
try:
|
|
220
|
+
got_preview = _json.dumps(
|
|
221
|
+
{k: (v[:60] + "…" if isinstance(v, str) and len(v) > 60 else v)
|
|
222
|
+
for k, v in args.items()},
|
|
223
|
+
ensure_ascii=False,
|
|
224
|
+
)
|
|
225
|
+
except Exception:
|
|
226
|
+
got_preview = str(args)[:200]
|
|
227
|
+
|
|
228
|
+
example_args = {}
|
|
229
|
+
for field in required:
|
|
230
|
+
if field in args:
|
|
231
|
+
example_args[field] = args[field]
|
|
232
|
+
else:
|
|
233
|
+
# Placeholder spelling out what the model needs to provide.
|
|
234
|
+
example_args[field] = f"<{field} value — REQUIRED>"
|
|
235
|
+
try:
|
|
236
|
+
example_call = _json.dumps(example_args, ensure_ascii=False)
|
|
237
|
+
except Exception:
|
|
238
|
+
example_call = str(example_args)
|
|
239
|
+
|
|
240
|
+
return (
|
|
241
|
+
f"Error: {tool_name} was called without required argument(s): "
|
|
242
|
+
f"{', '.join(repr(m) for m in missing)}.\n"
|
|
243
|
+
f"You sent: {got_preview}\n"
|
|
244
|
+
f"Required fields for {tool_name}: {required}.\n"
|
|
245
|
+
f"Retry by calling {tool_name} with ALL required fields filled in, "
|
|
246
|
+
f"e.g. arguments={example_call}"
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def dispatch_result(name: str, ctx: ToolContext, args: dict) -> ToolResult:
|
|
251
|
+
# Strip whitespace before lookup. Quantized models (notably Qwen 3.6
|
|
252
|
+
# IQ2_M) sometimes emit tool names with a trailing or leading space
|
|
253
|
+
# — `'list_files '` instead of `'list_files'` — which would cause an
|
|
254
|
+
# otherwise-valid call to fail.
|
|
255
|
+
clean = (name or "").strip()
|
|
256
|
+
# MCP tool? Dispatch to the right server and return its text content.
|
|
257
|
+
# These tool names have the form mcp_<server>_<tool> and don't appear
|
|
258
|
+
# in _TOOLS — they're added dynamically by mcp_tool_schemas().
|
|
259
|
+
if clean.startswith("mcp_"):
|
|
260
|
+
try:
|
|
261
|
+
from ..mcp import dispatch_mcp_tool
|
|
262
|
+
text = dispatch_mcp_tool(clean, args if isinstance(args, dict) else {})
|
|
263
|
+
if text is not None:
|
|
264
|
+
ok = not str(text).startswith("REJECTED:") and not str(text).startswith("MCP call ")
|
|
265
|
+
return ToolResult(text=str(text), ok=ok,
|
|
266
|
+
facts={"tool": clean, "ok": ok})
|
|
267
|
+
except Exception as e:
|
|
268
|
+
return ToolResult(text=f"MCP dispatch error: {e}", ok=False,
|
|
269
|
+
facts={"tool": clean, "ok": False})
|
|
270
|
+
pair = _TOOLS.get(clean)
|
|
271
|
+
if pair is None:
|
|
272
|
+
# Use the error-code system so the user gets a documented [Eccc]
|
|
273
|
+
# prefix instead of a free-text string. Code E2101 is the
|
|
274
|
+
# "Unknown tool" entry; see src/localcode/errors.py.
|
|
275
|
+
from ..errors import LocalCodeError, by_code
|
|
276
|
+
code = by_code("E2101")
|
|
277
|
+
if code is not None:
|
|
278
|
+
text = str(LocalCodeError(code=code,
|
|
279
|
+
detail=f"'{name}'. Available: " + ", ".join(AVAILABLE_NAMES)))
|
|
280
|
+
return ToolResult(text=text, ok=False, facts={"tool": clean, "ok": False})
|
|
281
|
+
# Fallback (registry missing — shouldn't happen in production)
|
|
282
|
+
text = f"[E2101] Unknown tool: '{name}'. Available: " + ", ".join(AVAILABLE_NAMES)
|
|
283
|
+
return ToolResult(text=text, ok=False, facts={"tool": clean, "ok": False})
|
|
284
|
+
schema, executor = pair
|
|
285
|
+
|
|
286
|
+
# Pre-dispatch schema validation. Without this, each tool did its
|
|
287
|
+
# own "is field X present?" check and returned a terse error that
|
|
288
|
+
# failed to tell the model what it *did* emit. Centralising here
|
|
289
|
+
# means every tool benefits — and the message can reference the
|
|
290
|
+
# schema's `required` list concretely. See
|
|
291
|
+
# `_format_missing_args_error` for the root-cause rationale.
|
|
292
|
+
if isinstance(args, dict):
|
|
293
|
+
required = schema.get("function", {}).get("parameters", {}).get("required", []) or []
|
|
294
|
+
missing = [f for f in required if f not in args or args[f] is None]
|
|
295
|
+
if missing:
|
|
296
|
+
text = _format_missing_args_error(clean, schema, args, missing)
|
|
297
|
+
return ToolResult(text=text, ok=False, facts={"tool": clean, "ok": False, "missing_args": missing})
|
|
298
|
+
else:
|
|
299
|
+
# Model emitted something non-dict for args. Recoverable by the
|
|
300
|
+
# model if told clearly what we expected.
|
|
301
|
+
text = (
|
|
302
|
+
f"Error: {clean} expected a JSON object for arguments, got {type(args).__name__}. "
|
|
303
|
+
f"Retry with a JSON object like {{\"field\": \"value\"}}."
|
|
304
|
+
)
|
|
305
|
+
return ToolResult(text=text, ok=False, facts={"tool": clean, "ok": False})
|
|
306
|
+
|
|
307
|
+
# Tool-call telemetry — one append-only line per dispatch in the
|
|
308
|
+
# project's lifecycle.log. Lets the user (and us) answer "which
|
|
309
|
+
# tools is the model actually picking?" without diffing SQLite
|
|
310
|
+
# session stores. Records the tool name + a short args summary
|
|
311
|
+
# (path / command first 60 chars, never full content). Best-effort:
|
|
312
|
+
# any failure here is silently swallowed so the tool itself still
|
|
313
|
+
# runs and returns its real result.
|
|
314
|
+
try:
|
|
315
|
+
from ..server_manager import _lifecycle_log
|
|
316
|
+
if clean == "edit_file":
|
|
317
|
+
# Capture short previews of old/new so log analysis can spot
|
|
318
|
+
# edit oscillation (model edits A→B then B→A repeatedly) and
|
|
319
|
+
# near-no-op patterns. Truncated to 60 chars each so the
|
|
320
|
+
# event stays small but the first non-whitespace token is
|
|
321
|
+
# always visible. Real failure 2026-04-26: 12+ edit_file
|
|
322
|
+
# calls returning "1 replacement" each — we couldn't tell
|
|
323
|
+
# from logs whether they were the same edit or a flip-flop
|
|
324
|
+
# because old_string/new_string weren't logged.
|
|
325
|
+
_o = (args.get("old_string", "") or "").strip().replace("\n", "↵")[:60]
|
|
326
|
+
_n = (args.get("new_string", "") or "").strip().replace("\n", "↵")[:60]
|
|
327
|
+
arg_summary = (
|
|
328
|
+
f"path={args.get('path', '?')!r} "
|
|
329
|
+
f"old={_o!r} new={_n!r}"
|
|
330
|
+
)
|
|
331
|
+
elif clean in ("write_file", "append_file", "multi_edit"):
|
|
332
|
+
arg_summary = f"path={args.get('path', '?')!r}"
|
|
333
|
+
elif clean == "bash":
|
|
334
|
+
arg_summary = f"cmd={(args.get('command', '') or '')[:60]!r}"
|
|
335
|
+
elif clean in ("read_file", "list_files", "glob"):
|
|
336
|
+
arg_summary = f"path={args.get('path', args.get('pattern', '?'))!r}"
|
|
337
|
+
elif clean == "grep":
|
|
338
|
+
arg_summary = f"pat={args.get('pattern', '?')!r}"
|
|
339
|
+
else:
|
|
340
|
+
arg_summary = ""
|
|
341
|
+
_lifecycle_log("tool_call", name=clean, **(
|
|
342
|
+
{"args": arg_summary} if arg_summary else {}
|
|
343
|
+
))
|
|
344
|
+
except Exception:
|
|
345
|
+
pass
|
|
346
|
+
|
|
347
|
+
try:
|
|
348
|
+
raw_result = executor(ctx, args)
|
|
349
|
+
except PathContainmentError as exc:
|
|
350
|
+
# A write tool aimed outside the project root (absolute path, `../`
|
|
351
|
+
# traversal, or a symlink whose target escapes). Refused here so the
|
|
352
|
+
# model gets one clear, actionable sentence instead of a traceback.
|
|
353
|
+
text = (
|
|
354
|
+
f"REJECTED: {exc} "
|
|
355
|
+
"Writes are confined to the project directory; re-issue the call "
|
|
356
|
+
"with a path relative to the project root."
|
|
357
|
+
)
|
|
358
|
+
return ToolResult(text=text, ok=False,
|
|
359
|
+
facts={"tool": clean, "ok": False, "path_escape": True})
|
|
360
|
+
result = raw_result if isinstance(raw_result, ToolResult) else _normalize_result(clean, args, str(raw_result))
|
|
361
|
+
|
|
362
|
+
# Auto-surface typecheck diagnostics after a successful CODE edit — the weak
|
|
363
|
+
# model rarely runs them itself, so we feed them back in-context. Debounced +
|
|
364
|
+
# new-errors-only (see diagnostics_after_edit), so it can't dominate the edit
|
|
365
|
+
# path or nag with the same wall of errors.
|
|
366
|
+
if clean in ("edit_file", "write_file", "multi_edit", "append_file") and result.ok:
|
|
367
|
+
_path = str((args or {}).get("path") or (args or {}).get("file_path") or "")
|
|
368
|
+
if _path.rsplit(".", 1)[-1].lower() in (
|
|
369
|
+
"ts", "tsx", "js", "jsx", "mjs", "cjs", "py", "go", "rs", "vue", "svelte"
|
|
370
|
+
):
|
|
371
|
+
try:
|
|
372
|
+
from .project_check import diagnostics_after_edit
|
|
373
|
+
_diag = diagnostics_after_edit(str(ctx.check_root))
|
|
374
|
+
if _diag:
|
|
375
|
+
result = ToolResult(text=result.text + _diag, ok=result.ok, facts=result.facts)
|
|
376
|
+
except Exception:
|
|
377
|
+
pass
|
|
378
|
+
|
|
379
|
+
return result
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def dispatch(name: str, ctx: ToolContext, args: dict) -> str:
|
|
383
|
+
return dispatch_result(name, ctx, args).text
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _normalize_result(tool_name: str, args: dict, text: str) -> ToolResult:
|
|
387
|
+
facts = extract_tool_facts(tool_name, args, text)
|
|
388
|
+
return ToolResult(text=text, ok=bool(facts.get("ok", True)), facts=facts)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
__all__ = [
|
|
392
|
+
"ALL_SCHEMAS",
|
|
393
|
+
"AVAILABLE_NAMES",
|
|
394
|
+
"ToolContext",
|
|
395
|
+
"ToolResult",
|
|
396
|
+
"dispatch",
|
|
397
|
+
"dispatch_result",
|
|
398
|
+
"schemas_for_goal",
|
|
399
|
+
"schemas_for_names",
|
|
400
|
+
]
|
localcode/tools/agent.py
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""agent — spawn a sub-agent to handle a focused multi-step task.
|
|
2
|
+
|
|
3
|
+
A sub-agent is a fresh model session with:
|
|
4
|
+
- its own system prompt (specialized for the agent type)
|
|
5
|
+
- a tool subset (so e.g. an "explore" agent only gets read tools)
|
|
6
|
+
- the same model + runtime as the parent
|
|
7
|
+
- independent message history (results bubble back to the parent
|
|
8
|
+
only via the final assistant message)
|
|
9
|
+
|
|
10
|
+
Built-in agent types (registry below) mirror Codex's pattern:
|
|
11
|
+
- general-purpose : broad research / multi-step tasks (all tools)
|
|
12
|
+
- explore : codebase search + read only (no edits)
|
|
13
|
+
- plan : reads + outputs structured plan, no edits
|
|
14
|
+
- verify : runs tests/builds, no edits
|
|
15
|
+
|
|
16
|
+
Calling pattern from the parent model:
|
|
17
|
+
{"name": "agent", "input": {
|
|
18
|
+
"subagent_type": "explore",
|
|
19
|
+
"description": "find all CSV parsing call-sites",
|
|
20
|
+
"prompt": "search for csv.reader and pandas.read_csv usage..."
|
|
21
|
+
}}
|
|
22
|
+
|
|
23
|
+
Returns the sub-agent's final assistant text as the tool result.
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from .base import ToolContext
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
SCHEMA = {
|
|
31
|
+
"type": "function",
|
|
32
|
+
"function": {
|
|
33
|
+
"name": "agent",
|
|
34
|
+
"description": (
|
|
35
|
+
"Spawn a sub-agent to handle a focused, multi-step task. Use for "
|
|
36
|
+
"open-ended research (codebase searches, design exploration) that "
|
|
37
|
+
"would otherwise consume many tool calls in your own context. "
|
|
38
|
+
"Each subagent_type has a curated tool subset and system prompt."
|
|
39
|
+
),
|
|
40
|
+
"parameters": {
|
|
41
|
+
"type": "object",
|
|
42
|
+
"properties": {
|
|
43
|
+
"subagent_type": {
|
|
44
|
+
"type": "string",
|
|
45
|
+
"enum": ["general-purpose", "explore", "plan", "verify"],
|
|
46
|
+
"description": (
|
|
47
|
+
"Which built-in sub-agent to spawn. "
|
|
48
|
+
"'general-purpose' has all tools; 'explore' is read-only "
|
|
49
|
+
"(grep/glob/read for codebase mapping); 'plan' is read-only "
|
|
50
|
+
"and returns a structured implementation plan; 'verify' "
|
|
51
|
+
"runs tests and bash for verification."
|
|
52
|
+
),
|
|
53
|
+
},
|
|
54
|
+
"description": {
|
|
55
|
+
"type": "string",
|
|
56
|
+
"description": (
|
|
57
|
+
"Short (3-5 word) label for what the agent will do — "
|
|
58
|
+
"shown to the user in the activity feed."
|
|
59
|
+
),
|
|
60
|
+
},
|
|
61
|
+
"prompt": {
|
|
62
|
+
"type": "string",
|
|
63
|
+
"description": (
|
|
64
|
+
"The full task for the sub-agent. Self-contained — the "
|
|
65
|
+
"sub-agent does not see your context. Include all "
|
|
66
|
+
"background, constraints, and the exact deliverable."
|
|
67
|
+
),
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
"required": ["subagent_type", "description", "prompt"],
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# Built-in sub-agent registry. Each entry defines the tool subset + a
|
|
77
|
+
# specialized system prompt. Adding new agents is one dict entry; no
|
|
78
|
+
# changes to the dispatch path needed.
|
|
79
|
+
_AGENTS = {
|
|
80
|
+
"general-purpose": {
|
|
81
|
+
"tools": [
|
|
82
|
+
"read_file", "write_file", "edit_file", "append_file",
|
|
83
|
+
"bash", "grep", "glob", "list_files", "web_search", "web_fetch",
|
|
84
|
+
],
|
|
85
|
+
"system": (
|
|
86
|
+
"You are a sub-agent spawned by the main LocalCode agent. Your "
|
|
87
|
+
"scope is the task the parent gave you. Complete it fully, then "
|
|
88
|
+
"respond with a concise report — what you did, what you found, "
|
|
89
|
+
"and any blockers. The caller will relay this verbatim to the "
|
|
90
|
+
"user. Stay tightly focused; don't gold-plate."
|
|
91
|
+
),
|
|
92
|
+
},
|
|
93
|
+
"explore": {
|
|
94
|
+
"tools": ["read_file", "grep", "glob", "list_files"],
|
|
95
|
+
"system": (
|
|
96
|
+
"You are an exploration sub-agent. READ-ONLY — you cannot edit "
|
|
97
|
+
"or write files. Use grep/glob/read/list_files to map the "
|
|
98
|
+
"codebase the parent agent is asking about. Report file paths "
|
|
99
|
+
"with line numbers and short excerpts. Optimize for breadth "
|
|
100
|
+
"and accuracy, not for ideas."
|
|
101
|
+
),
|
|
102
|
+
},
|
|
103
|
+
"plan": {
|
|
104
|
+
"tools": ["read_file", "grep", "glob", "list_files"],
|
|
105
|
+
"system": (
|
|
106
|
+
"You are a planning sub-agent. READ-ONLY. Read the relevant "
|
|
107
|
+
"code, then return a numbered implementation plan: file paths, "
|
|
108
|
+
"function names, what to change. Identify risks. Do NOT write "
|
|
109
|
+
"any code or files."
|
|
110
|
+
),
|
|
111
|
+
},
|
|
112
|
+
"verify": {
|
|
113
|
+
"tools": ["read_file", "bash", "grep", "glob", "list_files"],
|
|
114
|
+
"system": (
|
|
115
|
+
"You are a verification sub-agent. Run the tests, builds, or "
|
|
116
|
+
"checks the parent requested. Read the relevant code to ground "
|
|
117
|
+
"your verification. Do NOT make edits — only run and report."
|
|
118
|
+
),
|
|
119
|
+
},
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _dispatch_guarded(ctx: ToolContext, name: str, tool_args: dict) -> str:
|
|
124
|
+
"""Run one sub-agent tool call through the SAME guarded path the parent uses.
|
|
125
|
+
|
|
126
|
+
Calling the tool's raw executor here would bypass every protection the
|
|
127
|
+
main loop applies: the autonomy-independent `_safety_hard_block`, the
|
|
128
|
+
pre_tool_use hook veto, the destructive-write guards, and the user
|
|
129
|
+
approval gate. A sub-agent is still the same process acting on the same
|
|
130
|
+
machine, so it gets the same gate — one `agent` call must not buy
|
|
131
|
+
unapproved shell/file access.
|
|
132
|
+
|
|
133
|
+
Imports are function-local: `localcode.agent.helpers` imports
|
|
134
|
+
`localcode.tools`, so a module-level import here would be circular.
|
|
135
|
+
"""
|
|
136
|
+
from ..agent.helpers import (
|
|
137
|
+
_approval_display_command,
|
|
138
|
+
_execute_tool_result,
|
|
139
|
+
_first_token,
|
|
140
|
+
_needs_confirmation,
|
|
141
|
+
_request_approval_verdict,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
app = getattr(ctx, "app", None)
|
|
145
|
+
out = getattr(ctx, "out", None)
|
|
146
|
+
|
|
147
|
+
# Approval gate — identical semantics to agent/loop.py: verdict is one of
|
|
148
|
+
# "once" / "always" / "deny"; "always" whitelists the command's first
|
|
149
|
+
# token for the session; "deny" refuses the call.
|
|
150
|
+
try:
|
|
151
|
+
needs = _needs_confirmation(name, tool_args, app)
|
|
152
|
+
except Exception:
|
|
153
|
+
needs = True # fail closed
|
|
154
|
+
if needs:
|
|
155
|
+
cmd = _approval_display_command(app, name, tool_args)
|
|
156
|
+
try:
|
|
157
|
+
verdict = _request_approval_verdict(app, out, name, cmd)
|
|
158
|
+
except Exception:
|
|
159
|
+
verdict = "deny"
|
|
160
|
+
if verdict == "always":
|
|
161
|
+
allow_set = getattr(app, "_session_allow", None)
|
|
162
|
+
if allow_set is None and app is not None:
|
|
163
|
+
app._session_allow = set()
|
|
164
|
+
allow_set = app._session_allow
|
|
165
|
+
if allow_set is not None:
|
|
166
|
+
allow_set.add(_first_token(cmd))
|
|
167
|
+
elif verdict != "once":
|
|
168
|
+
return "Denied by user."
|
|
169
|
+
|
|
170
|
+
try:
|
|
171
|
+
return _execute_tool_result(app, name, tool_args, out).text
|
|
172
|
+
except Exception as e:
|
|
173
|
+
return f"Tool {name} raised: {e}"
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def execute(ctx: ToolContext, args: dict) -> str:
|
|
177
|
+
"""Run a sub-agent loop with the requested type + prompt.
|
|
178
|
+
|
|
179
|
+
Implementation: reuses the parent's `LocalCodeApp` for the runtime
|
|
180
|
+
gateway (same llama-server, same model) but constructs a fresh
|
|
181
|
+
message list with the sub-agent's system prompt and only the tool
|
|
182
|
+
subset the agent is permitted to use. The loop runs synchronously
|
|
183
|
+
in the caller's thread — the parent's bash tool is also synchronous,
|
|
184
|
+
so this matches existing latency expectations.
|
|
185
|
+
"""
|
|
186
|
+
subagent_type = args.get("subagent_type", "general-purpose")
|
|
187
|
+
description = args.get("description", "sub-agent task")
|
|
188
|
+
prompt = args.get("prompt", "").strip()
|
|
189
|
+
if not prompt:
|
|
190
|
+
return "REJECTED: agent prompt is required."
|
|
191
|
+
spec = _AGENTS.get(subagent_type)
|
|
192
|
+
if spec is None:
|
|
193
|
+
return f"REJECTED: unknown subagent_type {subagent_type!r}. " \
|
|
194
|
+
f"Valid: {list(_AGENTS.keys())}"
|
|
195
|
+
|
|
196
|
+
# Resolve tool subset
|
|
197
|
+
try:
|
|
198
|
+
from . import schemas_for_names, _TOOLS
|
|
199
|
+
except ImportError:
|
|
200
|
+
return "REJECTED: tool registry unavailable."
|
|
201
|
+
tool_schemas = schemas_for_names(spec["tools"])
|
|
202
|
+
tool_map = {n: _TOOLS[n] for n in spec["tools"] if n in _TOOLS}
|
|
203
|
+
|
|
204
|
+
# Build the message list. The sub-agent's system prompt is fully
|
|
205
|
+
# self-contained — it does NOT inherit the parent's prompt.
|
|
206
|
+
messages = [
|
|
207
|
+
{"role": "system", "content": spec["system"]},
|
|
208
|
+
{"role": "user", "content": prompt},
|
|
209
|
+
]
|
|
210
|
+
|
|
211
|
+
# Reuse the parent app's runtime gateway. Stream tokens, dispatch
|
|
212
|
+
# tool calls, accumulate final assistant text. Hard cap on rounds
|
|
213
|
+
# so a runaway sub-agent can't burn the parent's budget.
|
|
214
|
+
MAX_ROUNDS = 12
|
|
215
|
+
final_text_parts: list[str] = []
|
|
216
|
+
try:
|
|
217
|
+
engine = ctx.app.engine # LocalCodeApp -> .engine = LocalCodeRuntimeGateway
|
|
218
|
+
except AttributeError:
|
|
219
|
+
return "REJECTED: parent backend not initialized."
|
|
220
|
+
|
|
221
|
+
for _round in range(MAX_ROUNDS):
|
|
222
|
+
# One model turn
|
|
223
|
+
try:
|
|
224
|
+
response = engine.chat(messages, tools=tool_schemas, stream=False)
|
|
225
|
+
except Exception as e:
|
|
226
|
+
return f"Sub-agent error: {e}"
|
|
227
|
+
assistant_text = response.get("content", "") or ""
|
|
228
|
+
tool_calls = response.get("tool_calls", []) or []
|
|
229
|
+
if assistant_text:
|
|
230
|
+
final_text_parts.append(assistant_text)
|
|
231
|
+
if not tool_calls:
|
|
232
|
+
break # natural completion
|
|
233
|
+
# Dispatch each tool call and append the result as a tool message
|
|
234
|
+
messages.append({"role": "assistant",
|
|
235
|
+
"content": assistant_text,
|
|
236
|
+
"tool_calls": tool_calls})
|
|
237
|
+
for call in tool_calls:
|
|
238
|
+
name = call.get("function", {}).get("name", "")
|
|
239
|
+
raw_args = call.get("function", {}).get("arguments", "{}")
|
|
240
|
+
try:
|
|
241
|
+
import json as _json
|
|
242
|
+
tool_args = _json.loads(raw_args) if isinstance(raw_args, str) else raw_args
|
|
243
|
+
except Exception:
|
|
244
|
+
tool_args = {}
|
|
245
|
+
entry = tool_map.get(name)
|
|
246
|
+
if entry is None:
|
|
247
|
+
tool_result = f"Tool {name!r} not available to this sub-agent."
|
|
248
|
+
else:
|
|
249
|
+
tool_result = _dispatch_guarded(ctx, name, tool_args)
|
|
250
|
+
messages.append({"role": "tool",
|
|
251
|
+
"tool_call_id": call.get("id", ""),
|
|
252
|
+
"content": str(tool_result)[:8000]})
|
|
253
|
+
return ("\n\n".join(final_text_parts).strip()
|
|
254
|
+
or f"Sub-agent ({subagent_type}) completed without output.")
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def is_concurrency_safe(args: dict) -> bool:
|
|
258
|
+
# Sub-agents may invoke bash/edit; not safe to run two at once.
|
|
259
|
+
return False
|