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.
Files changed (173) hide show
  1. localcode/__init__.py +11 -0
  2. localcode/__main__.py +5 -0
  3. localcode/_subproc_env.py +54 -0
  4. localcode/agent/__init__.py +177 -0
  5. localcode/agent/app_tasks.py +157 -0
  6. localcode/agent/constants.py +275 -0
  7. localcode/agent/context.py +1152 -0
  8. localcode/agent/goal.py +244 -0
  9. localcode/agent/helpers.py +1036 -0
  10. localcode/agent/hollow_module.py +230 -0
  11. localcode/agent/hooks.py +414 -0
  12. localcode/agent/loop.py +2855 -0
  13. localcode/agent/plateau.py +297 -0
  14. localcode/agent/project_check_gate.py +91 -0
  15. localcode/agent/prompt_context.py +264 -0
  16. localcode/agent/prompts.py +368 -0
  17. localcode/agent/reasoning_loop.py +79 -0
  18. localcode/agent/recovery.py +606 -0
  19. localcode/agent/recovery_controller.py +25 -0
  20. localcode/agent/round_policy.py +23 -0
  21. localcode/agent/sections.py +241 -0
  22. localcode/agent/state_machine.py +100 -0
  23. localcode/agent/streaming.py +324 -0
  24. localcode/agent/tool_execution.py +368 -0
  25. localcode/agent/tool_orchestration.py +54 -0
  26. localcode/agent/turn_finalization.py +135 -0
  27. localcode/app.py +1595 -0
  28. localcode/approvals.py +17 -0
  29. localcode/auto_compact.py +176 -0
  30. localcode/autonomy.py +103 -0
  31. localcode/bin/__init__.py +0 -0
  32. localcode/bin/llama-server +0 -0
  33. localcode/bin/localcode-ui +4 -0
  34. localcode/bootstrap.py +1191 -0
  35. localcode/cache.py +194 -0
  36. localcode/checkpoint.py +375 -0
  37. localcode/compact.py +73 -0
  38. localcode/compaction.py +341 -0
  39. localcode/composer.py +72 -0
  40. localcode/config.py +521 -0
  41. localcode/context.py +161 -0
  42. localcode/display.py +58 -0
  43. localcode/embeddings.py +480 -0
  44. localcode/entrypoint.py +536 -0
  45. localcode/errors.py +350 -0
  46. localcode/events.py +409 -0
  47. localcode/evidence.py +102 -0
  48. localcode/execution_policy.py +48 -0
  49. localcode/features.py +308 -0
  50. localcode/formatting.py +91 -0
  51. localcode/headless_json.py +265 -0
  52. localcode/health.py +288 -0
  53. localcode/hf_quants.py +203 -0
  54. localcode/history.py +594 -0
  55. localcode/indexer.py +144 -0
  56. localcode/injection_defense.py +204 -0
  57. localcode/launcher.py +373 -0
  58. localcode/logging_utils.py +28 -0
  59. localcode/lsp.py +153 -0
  60. localcode/mcp/__init__.py +88 -0
  61. localcode/mcp/_bridge.py +61 -0
  62. localcode/mcp/_config.py +33 -0
  63. localcode/mcp/_transports.py +128 -0
  64. localcode/mcp/client.py +395 -0
  65. localcode/memory_guard.py +291 -0
  66. localcode/model_config.py +288 -0
  67. localcode/model_delete.py +444 -0
  68. localcode/model_families.py +279 -0
  69. localcode/models.py +236 -0
  70. localcode/models_catalog.py +901 -0
  71. localcode/notebook.py +123 -0
  72. localcode/output.py +403 -0
  73. localcode/patching.py +65 -0
  74. localcode/paths.py +303 -0
  75. localcode/performance.py +623 -0
  76. localcode/permissions.py +79 -0
  77. localcode/permissions_v2.py +310 -0
  78. localcode/plans.py +198 -0
  79. localcode/process_registry.py +271 -0
  80. localcode/protocol/__init__.py +56 -0
  81. localcode/protocol/events.py +116 -0
  82. localcode/protocol/jsonl.py +153 -0
  83. localcode/protocol/outcomes.py +231 -0
  84. localcode/reasoning_capabilities.py +59 -0
  85. localcode/recommendations.py +87 -0
  86. localcode/recovery.py +134 -0
  87. localcode/redaction.py +164 -0
  88. localcode/runtime.py +2331 -0
  89. localcode/server_manager.py +1069 -0
  90. localcode/session.py +296 -0
  91. localcode/shell.py +83 -0
  92. localcode/skills/debug.md +21 -0
  93. localcode/skills/edit-verified.md +30 -0
  94. localcode/skills/explain.md +19 -0
  95. localcode/skills/finish-verified.md +11 -0
  96. localcode/skills/git-commit-safely.md +30 -0
  97. localcode/skills/locate.md +21 -0
  98. localcode/skills/plan-task.md +51 -0
  99. localcode/skills/review.md +22 -0
  100. localcode/skills/run-tests.md +24 -0
  101. localcode/skills.py +728 -0
  102. localcode/snapshots.py +195 -0
  103. localcode/sounds.py +50 -0
  104. localcode/telemetry.py +273 -0
  105. localcode/theme.py +137 -0
  106. localcode/thermal.py +160 -0
  107. localcode/thinking.py +114 -0
  108. localcode/tool_router.py +246 -0
  109. localcode/toolkit.py +1241 -0
  110. localcode/tools/__init__.py +400 -0
  111. localcode/tools/agent.py +259 -0
  112. localcode/tools/append_file.py +55 -0
  113. localcode/tools/background_process.py +115 -0
  114. localcode/tools/base.py +168 -0
  115. localcode/tools/bash.py +1160 -0
  116. localcode/tools/code_navigation.py +97 -0
  117. localcode/tools/edit_diff.py +80 -0
  118. localcode/tools/edit_file.py +477 -0
  119. localcode/tools/facts.py +149 -0
  120. localcode/tools/glob_tool.py +60 -0
  121. localcode/tools/grep.py +65 -0
  122. localcode/tools/inspect_symbol.py +266 -0
  123. localcode/tools/launch_app.py +93 -0
  124. localcode/tools/list_files.py +42 -0
  125. localcode/tools/multi_edit.py +167 -0
  126. localcode/tools/plan_mode.py +86 -0
  127. localcode/tools/project_check.py +685 -0
  128. localcode/tools/read_file.py +261 -0
  129. localcode/tools/read_state.py +223 -0
  130. localcode/tools/skill_tool.py +41 -0
  131. localcode/tools/syntax_check.py +216 -0
  132. localcode/tools/todo_write.py +200 -0
  133. localcode/tools/tool_call_repair.py +157 -0
  134. localcode/tools/web_fetch.py +182 -0
  135. localcode/tools/web_search.py +45 -0
  136. localcode/tools/write_file.py +281 -0
  137. localcode/tui/__init__.py +1 -0
  138. localcode/tui/app.py +485 -0
  139. localcode/tui/bridge.py +78 -0
  140. localcode/tui/clipboard_image.py +153 -0
  141. localcode/tui/paste_collapse.py +95 -0
  142. localcode/tui/screens/__init__.py +0 -0
  143. localcode/tui/screens/chat.py +5052 -0
  144. localcode/tui/screens/confirm.py +123 -0
  145. localcode/tui/screens/mode_picker.py +79 -0
  146. localcode/tui/screens/model_picker.py +962 -0
  147. localcode/tui/screens/setup.py +891 -0
  148. localcode/tui/styles/__init__.py +0 -0
  149. localcode/tui/styles/app.tcss +197 -0
  150. localcode/tui/widgets/__init__.py +0 -0
  151. localcode/tui/widgets/approval.py +2 -0
  152. localcode/tui/widgets/chat_log.py +2063 -0
  153. localcode/tui/widgets/messages/__init__.py +21 -0
  154. localcode/tui/widgets/messages/diff.py +287 -0
  155. localcode/tui/widgets/voice_visualizer.py +122 -0
  156. localcode/turn_diff.py +180 -0
  157. localcode/ui/FORK_COMMIT +1 -0
  158. localcode/ui/__init__.py +58 -0
  159. localcode/ui/launch.py +204 -0
  160. localcode/ui/picker_cli.py +165 -0
  161. localcode/ui/plugin/localcode.ts +626 -0
  162. localcode/ui/ports.py +42 -0
  163. localcode/ui/server_cmd.py +89 -0
  164. localcode/ui/supervisor.py +736 -0
  165. localcode/undo.py +121 -0
  166. localcode/verification.py +87 -0
  167. localcode/voice.py +948 -0
  168. localcode-0.4.0.dist-info/METADATA +160 -0
  169. localcode-0.4.0.dist-info/RECORD +173 -0
  170. localcode-0.4.0.dist-info/WHEEL +5 -0
  171. localcode-0.4.0.dist-info/entry_points.txt +3 -0
  172. localcode-0.4.0.dist-info/licenses/LICENSE +201 -0
  173. 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
+ ]
@@ -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