axion-code 1.0.0__py3-none-any.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.
- axion/__init__.py +3 -0
- axion/api/__init__.py +0 -0
- axion/api/anthropic.py +460 -0
- axion/api/client.py +259 -0
- axion/api/error.py +161 -0
- axion/api/ollama.py +597 -0
- axion/api/openai_compat.py +805 -0
- axion/api/openai_responses.py +627 -0
- axion/api/prompt_cache.py +31 -0
- axion/api/sse.py +98 -0
- axion/api/types.py +451 -0
- axion/cli/__init__.py +0 -0
- axion/cli/init_cmd.py +50 -0
- axion/cli/input.py +290 -0
- axion/cli/main.py +2953 -0
- axion/cli/render.py +489 -0
- axion/cli/tui.py +766 -0
- axion/commands/__init__.py +0 -0
- axion/commands/handlers/__init__.py +0 -0
- axion/commands/handlers/agents.py +51 -0
- axion/commands/handlers/builtin_commands.py +367 -0
- axion/commands/handlers/mcp.py +59 -0
- axion/commands/handlers/models.py +75 -0
- axion/commands/handlers/plugins.py +55 -0
- axion/commands/handlers/skills.py +61 -0
- axion/commands/parsing.py +317 -0
- axion/commands/registry.py +166 -0
- axion/compat_harness/__init__.py +0 -0
- axion/compat_harness/extractor.py +145 -0
- axion/plugins/__init__.py +0 -0
- axion/plugins/hooks.py +22 -0
- axion/plugins/manager.py +391 -0
- axion/plugins/manifest.py +270 -0
- axion/runtime/__init__.py +0 -0
- axion/runtime/bash.py +388 -0
- axion/runtime/bootstrap.py +39 -0
- axion/runtime/claude_subscription.py +300 -0
- axion/runtime/compact.py +233 -0
- axion/runtime/config.py +397 -0
- axion/runtime/conversation.py +1073 -0
- axion/runtime/file_ops.py +613 -0
- axion/runtime/git.py +213 -0
- axion/runtime/hooks.py +235 -0
- axion/runtime/image.py +212 -0
- axion/runtime/lanes.py +282 -0
- axion/runtime/lsp.py +425 -0
- axion/runtime/mcp/__init__.py +0 -0
- axion/runtime/mcp/client.py +76 -0
- axion/runtime/mcp/lifecycle.py +96 -0
- axion/runtime/mcp/stdio.py +318 -0
- axion/runtime/mcp/tool_bridge.py +79 -0
- axion/runtime/memory.py +196 -0
- axion/runtime/oauth.py +329 -0
- axion/runtime/openai_subscription.py +346 -0
- axion/runtime/permissions.py +247 -0
- axion/runtime/plan_mode.py +96 -0
- axion/runtime/policy_engine.py +259 -0
- axion/runtime/prompt.py +586 -0
- axion/runtime/recovery.py +261 -0
- axion/runtime/remote.py +28 -0
- axion/runtime/sandbox.py +68 -0
- axion/runtime/scheduler.py +231 -0
- axion/runtime/session.py +365 -0
- axion/runtime/sharing.py +159 -0
- axion/runtime/skills.py +124 -0
- axion/runtime/tasks.py +258 -0
- axion/runtime/usage.py +241 -0
- axion/runtime/workers.py +186 -0
- axion/telemetry/__init__.py +0 -0
- axion/telemetry/events.py +67 -0
- axion/telemetry/profile.py +49 -0
- axion/telemetry/sink.py +60 -0
- axion/telemetry/tracer.py +95 -0
- axion/tools/__init__.py +0 -0
- axion/tools/lane_completion.py +33 -0
- axion/tools/registry.py +853 -0
- axion/tools/tool_search.py +226 -0
- axion_code-1.0.0.dist-info/METADATA +709 -0
- axion_code-1.0.0.dist-info/RECORD +82 -0
- axion_code-1.0.0.dist-info/WHEEL +4 -0
- axion_code-1.0.0.dist-info/entry_points.txt +2 -0
- axion_code-1.0.0.dist-info/licenses/LICENSE +21 -0
axion/tools/registry.py
ADDED
|
@@ -0,0 +1,853 @@
|
|
|
1
|
+
"""Global tool registry and tool definitions.
|
|
2
|
+
|
|
3
|
+
Maps to: rust/crates/tools/src/lib.rs
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from axion.runtime.bash import BashCommandInput, execute_bash
|
|
15
|
+
from axion.runtime.file_ops import (
|
|
16
|
+
edit_file,
|
|
17
|
+
glob_search,
|
|
18
|
+
grep_search,
|
|
19
|
+
read_file,
|
|
20
|
+
write_file,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class ToolSpec:
|
|
28
|
+
"""Specification for a single tool."""
|
|
29
|
+
|
|
30
|
+
name: str
|
|
31
|
+
description: str
|
|
32
|
+
input_schema: dict[str, Any]
|
|
33
|
+
required_permission: str = "read-only"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class RuntimeToolDefinition:
|
|
38
|
+
"""A tool definition with its spec and source information."""
|
|
39
|
+
|
|
40
|
+
spec: ToolSpec
|
|
41
|
+
source: str = "builtin"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
# Built-in tool specifications
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
BASH_TOOL = ToolSpec(
|
|
49
|
+
name="Bash",
|
|
50
|
+
description="Executes a given bash command and returns its output.",
|
|
51
|
+
input_schema={
|
|
52
|
+
"type": "object",
|
|
53
|
+
"properties": {
|
|
54
|
+
"command": {"type": "string", "description": "The command to execute"},
|
|
55
|
+
"description": {"type": "string", "description": "Description of what this command does"},
|
|
56
|
+
"timeout": {"type": "number", "description": "Optional timeout in milliseconds"},
|
|
57
|
+
"run_in_background": {"type": "boolean", "description": "Run in background"},
|
|
58
|
+
},
|
|
59
|
+
"required": ["command"],
|
|
60
|
+
},
|
|
61
|
+
required_permission="workspace-write",
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
READ_TOOL = ToolSpec(
|
|
65
|
+
name="Read",
|
|
66
|
+
description="Reads a file from the local filesystem.",
|
|
67
|
+
input_schema={
|
|
68
|
+
"type": "object",
|
|
69
|
+
"properties": {
|
|
70
|
+
"file_path": {"type": "string", "description": "Absolute path to the file"},
|
|
71
|
+
"offset": {"type": "number", "description": "Line number to start reading from"},
|
|
72
|
+
"limit": {"type": "number", "description": "Number of lines to read"},
|
|
73
|
+
},
|
|
74
|
+
"required": ["file_path"],
|
|
75
|
+
},
|
|
76
|
+
required_permission="read-only",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
WRITE_TOOL = ToolSpec(
|
|
80
|
+
name="Write",
|
|
81
|
+
description="Writes a file to the local filesystem.",
|
|
82
|
+
input_schema={
|
|
83
|
+
"type": "object",
|
|
84
|
+
"properties": {
|
|
85
|
+
"file_path": {"type": "string", "description": "Absolute path to the file"},
|
|
86
|
+
"content": {"type": "string", "description": "The content to write"},
|
|
87
|
+
},
|
|
88
|
+
"required": ["file_path", "content"],
|
|
89
|
+
},
|
|
90
|
+
required_permission="workspace-write",
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
EDIT_TOOL = ToolSpec(
|
|
94
|
+
name="Edit",
|
|
95
|
+
description="Performs exact string replacements in files.",
|
|
96
|
+
input_schema={
|
|
97
|
+
"type": "object",
|
|
98
|
+
"properties": {
|
|
99
|
+
"file_path": {"type": "string", "description": "Absolute path to the file"},
|
|
100
|
+
"old_string": {"type": "string", "description": "The text to replace"},
|
|
101
|
+
"new_string": {"type": "string", "description": "The replacement text"},
|
|
102
|
+
"replace_all": {"type": "boolean", "description": "Replace all occurrences"},
|
|
103
|
+
},
|
|
104
|
+
"required": ["file_path", "old_string", "new_string"],
|
|
105
|
+
},
|
|
106
|
+
required_permission="workspace-write",
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
GLOB_TOOL = ToolSpec(
|
|
110
|
+
name="Glob",
|
|
111
|
+
description="Fast file pattern matching tool.",
|
|
112
|
+
input_schema={
|
|
113
|
+
"type": "object",
|
|
114
|
+
"properties": {
|
|
115
|
+
"pattern": {"type": "string", "description": "Glob pattern to match"},
|
|
116
|
+
"path": {"type": "string", "description": "Directory to search in"},
|
|
117
|
+
},
|
|
118
|
+
"required": ["pattern"],
|
|
119
|
+
},
|
|
120
|
+
required_permission="read-only",
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
GREP_TOOL = ToolSpec(
|
|
124
|
+
name="Grep",
|
|
125
|
+
description="Search tool built on regex matching.",
|
|
126
|
+
input_schema={
|
|
127
|
+
"type": "object",
|
|
128
|
+
"properties": {
|
|
129
|
+
"pattern": {"type": "string", "description": "Regex pattern to search for"},
|
|
130
|
+
"path": {"type": "string", "description": "File or directory to search"},
|
|
131
|
+
"glob": {"type": "string", "description": "Glob pattern to filter files"},
|
|
132
|
+
"output_mode": {
|
|
133
|
+
"type": "string",
|
|
134
|
+
"enum": ["content", "files_with_matches", "count"],
|
|
135
|
+
},
|
|
136
|
+
"-i": {"type": "boolean", "description": "Case insensitive search"},
|
|
137
|
+
"-n": {"type": "boolean", "description": "Show line numbers"},
|
|
138
|
+
"-A": {"type": "number", "description": "Lines after match"},
|
|
139
|
+
"-B": {"type": "number", "description": "Lines before match"},
|
|
140
|
+
"-C": {"type": "number", "description": "Context lines"},
|
|
141
|
+
"head_limit": {"type": "number", "description": "Limit output entries"},
|
|
142
|
+
},
|
|
143
|
+
"required": ["pattern"],
|
|
144
|
+
},
|
|
145
|
+
required_permission="read-only",
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
WEB_SEARCH_TOOL = ToolSpec(
|
|
149
|
+
name="WebSearch",
|
|
150
|
+
description="Search the web for information.",
|
|
151
|
+
input_schema={
|
|
152
|
+
"type": "object",
|
|
153
|
+
"properties": {
|
|
154
|
+
"query": {"type": "string", "description": "Search query"},
|
|
155
|
+
},
|
|
156
|
+
"required": ["query"],
|
|
157
|
+
},
|
|
158
|
+
required_permission="read-only",
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
WEB_FETCH_TOOL = ToolSpec(
|
|
162
|
+
name="WebFetch",
|
|
163
|
+
description="Fetch content from a URL.",
|
|
164
|
+
input_schema={
|
|
165
|
+
"type": "object",
|
|
166
|
+
"properties": {
|
|
167
|
+
"url": {"type": "string", "description": "URL to fetch"},
|
|
168
|
+
},
|
|
169
|
+
"required": ["url"],
|
|
170
|
+
},
|
|
171
|
+
required_permission="read-only",
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
AGENT_TOOL = ToolSpec(
|
|
175
|
+
name="Agent",
|
|
176
|
+
description="Launch a sub-agent to handle complex tasks.",
|
|
177
|
+
input_schema={
|
|
178
|
+
"type": "object",
|
|
179
|
+
"properties": {
|
|
180
|
+
"prompt": {"type": "string", "description": "Task for the agent"},
|
|
181
|
+
"description": {"type": "string", "description": "Short description"},
|
|
182
|
+
"subagent_type": {"type": "string", "description": "Agent type"},
|
|
183
|
+
},
|
|
184
|
+
"required": ["prompt", "description"],
|
|
185
|
+
},
|
|
186
|
+
required_permission="read-only",
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
TODO_TOOL = ToolSpec(
|
|
190
|
+
name="TodoWrite",
|
|
191
|
+
description="Create and manage a structured task list.",
|
|
192
|
+
input_schema={
|
|
193
|
+
"type": "object",
|
|
194
|
+
"properties": {
|
|
195
|
+
"todos": {
|
|
196
|
+
"type": "array",
|
|
197
|
+
"items": {
|
|
198
|
+
"type": "object",
|
|
199
|
+
"properties": {
|
|
200
|
+
"content": {"type": "string"},
|
|
201
|
+
"status": {"type": "string", "enum": ["pending", "in_progress", "completed"]},
|
|
202
|
+
"activeForm": {"type": "string"},
|
|
203
|
+
},
|
|
204
|
+
"required": ["content", "status", "activeForm"],
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
"required": ["todos"],
|
|
209
|
+
},
|
|
210
|
+
required_permission="read-only",
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# ---------------------------------------------------------------------------
|
|
215
|
+
# Global tool registry
|
|
216
|
+
# ---------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
ALL_BUILTIN_TOOLS: list[ToolSpec] = [
|
|
219
|
+
BASH_TOOL,
|
|
220
|
+
READ_TOOL,
|
|
221
|
+
WRITE_TOOL,
|
|
222
|
+
EDIT_TOOL,
|
|
223
|
+
GLOB_TOOL,
|
|
224
|
+
GREP_TOOL,
|
|
225
|
+
WEB_SEARCH_TOOL,
|
|
226
|
+
WEB_FETCH_TOOL,
|
|
227
|
+
AGENT_TOOL,
|
|
228
|
+
TODO_TOOL,
|
|
229
|
+
]
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
class GlobalToolRegistry:
|
|
233
|
+
"""Registry of all available tools.
|
|
234
|
+
|
|
235
|
+
Maps to: rust/crates/tools/src/lib.rs::GlobalToolRegistry
|
|
236
|
+
"""
|
|
237
|
+
|
|
238
|
+
def __init__(self) -> None:
|
|
239
|
+
self._tools: dict[str, RuntimeToolDefinition] = {}
|
|
240
|
+
# Register builtins
|
|
241
|
+
for spec in ALL_BUILTIN_TOOLS:
|
|
242
|
+
self._tools[spec.name] = RuntimeToolDefinition(spec=spec, source="builtin")
|
|
243
|
+
|
|
244
|
+
def get(self, name: str) -> RuntimeToolDefinition | None:
|
|
245
|
+
return self._tools.get(name)
|
|
246
|
+
|
|
247
|
+
def register(self, definition: RuntimeToolDefinition) -> None:
|
|
248
|
+
self._tools[definition.spec.name] = definition
|
|
249
|
+
|
|
250
|
+
def all_tools(self) -> list[RuntimeToolDefinition]:
|
|
251
|
+
return list(self._tools.values())
|
|
252
|
+
|
|
253
|
+
def tool_names(self) -> list[str]:
|
|
254
|
+
return list(self._tools.keys())
|
|
255
|
+
|
|
256
|
+
def to_api_tools(self) -> list[dict[str, Any]]:
|
|
257
|
+
"""Convert all tools to API tool definitions."""
|
|
258
|
+
return [
|
|
259
|
+
{
|
|
260
|
+
"name": t.spec.name,
|
|
261
|
+
"description": t.spec.description,
|
|
262
|
+
"input_schema": t.spec.input_schema,
|
|
263
|
+
}
|
|
264
|
+
for t in self._tools.values()
|
|
265
|
+
]
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# Module-level singleton
|
|
269
|
+
_registry: GlobalToolRegistry | None = None
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def get_tool_registry() -> GlobalToolRegistry:
|
|
273
|
+
"""Get the global tool registry singleton."""
|
|
274
|
+
global _registry
|
|
275
|
+
if _registry is None:
|
|
276
|
+
_registry = GlobalToolRegistry()
|
|
277
|
+
return _registry
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
# ---------------------------------------------------------------------------
|
|
281
|
+
# Tool executor implementation
|
|
282
|
+
# ---------------------------------------------------------------------------
|
|
283
|
+
|
|
284
|
+
class BuiltinToolExecutor:
|
|
285
|
+
"""Executes built-in tools (Bash, Read, Write, Edit, Glob, Grep).
|
|
286
|
+
|
|
287
|
+
Implements the ToolExecutor protocol from conversation.py.
|
|
288
|
+
"""
|
|
289
|
+
|
|
290
|
+
def __init__(
|
|
291
|
+
self,
|
|
292
|
+
cwd: str | None = None,
|
|
293
|
+
hook_runner: Any | None = None,
|
|
294
|
+
) -> None:
|
|
295
|
+
self.cwd = cwd
|
|
296
|
+
self.hook_runner = hook_runner # Optional HookRunner instance
|
|
297
|
+
|
|
298
|
+
async def execute(self, tool_name: str, tool_input: str) -> str:
|
|
299
|
+
"""Execute a tool and return the result as a string.
|
|
300
|
+
|
|
301
|
+
If a hook_runner is attached, pre/post tool-use hooks are invoked
|
|
302
|
+
around the actual tool execution.
|
|
303
|
+
"""
|
|
304
|
+
# --- Pre-tool-use hook ---
|
|
305
|
+
if self.hook_runner is not None:
|
|
306
|
+
pre_result = await self.hook_runner.run_pre_tool_use(tool_name, tool_input)
|
|
307
|
+
if pre_result.denied:
|
|
308
|
+
deny_msg = "; ".join(pre_result.messages) or "Denied by pre-tool-use hook"
|
|
309
|
+
return f"Hook denied: {deny_msg}"
|
|
310
|
+
|
|
311
|
+
try:
|
|
312
|
+
params = json.loads(tool_input) if tool_input else {}
|
|
313
|
+
except json.JSONDecodeError:
|
|
314
|
+
params = {}
|
|
315
|
+
|
|
316
|
+
_is_error = False
|
|
317
|
+
try:
|
|
318
|
+
result = await self._dispatch(tool_name, params)
|
|
319
|
+
except Exception as exc:
|
|
320
|
+
_is_error = True
|
|
321
|
+
result = f"Tool error: {exc}"
|
|
322
|
+
# --- Post-tool-use failure hook ---
|
|
323
|
+
if self.hook_runner is not None:
|
|
324
|
+
fail_result = await self.hook_runner.run_post_tool_use_failure(
|
|
325
|
+
tool_name, tool_input, str(exc)
|
|
326
|
+
)
|
|
327
|
+
if fail_result.messages:
|
|
328
|
+
result += "\n" + "\n".join(f"[hook] {m}" for m in fail_result.messages)
|
|
329
|
+
return result
|
|
330
|
+
|
|
331
|
+
# --- Post-tool-use hook ---
|
|
332
|
+
if self.hook_runner is not None:
|
|
333
|
+
post_result = await self.hook_runner.run_post_tool_use(
|
|
334
|
+
tool_name, tool_input, result, is_error=False
|
|
335
|
+
)
|
|
336
|
+
if post_result.denied:
|
|
337
|
+
deny_msg = "; ".join(post_result.messages) or "Denied by post-tool-use hook"
|
|
338
|
+
return f"Post-hook error: {deny_msg}\nOriginal output: {result}"
|
|
339
|
+
|
|
340
|
+
return result
|
|
341
|
+
|
|
342
|
+
async def _dispatch(self, tool_name: str, params: dict[str, Any]) -> str:
|
|
343
|
+
"""Dispatch to the appropriate tool handler."""
|
|
344
|
+
match tool_name:
|
|
345
|
+
case "Bash":
|
|
346
|
+
return await self._exec_bash(params)
|
|
347
|
+
case "Read":
|
|
348
|
+
return self._exec_read(params)
|
|
349
|
+
case "Write":
|
|
350
|
+
return self._exec_write(params)
|
|
351
|
+
case "Edit":
|
|
352
|
+
return self._exec_edit(params)
|
|
353
|
+
case "Glob":
|
|
354
|
+
return self._exec_glob(params)
|
|
355
|
+
case "Grep":
|
|
356
|
+
return self._exec_grep(params)
|
|
357
|
+
case "WebFetch":
|
|
358
|
+
return await self._exec_web_fetch(params)
|
|
359
|
+
case "WebSearch":
|
|
360
|
+
return await self._exec_web_search(params)
|
|
361
|
+
case "TodoWrite":
|
|
362
|
+
return self._exec_todo_write(params)
|
|
363
|
+
case "Agent":
|
|
364
|
+
return await self._exec_agent(params)
|
|
365
|
+
case "NotebookEdit":
|
|
366
|
+
return self._exec_notebook_edit(params)
|
|
367
|
+
case "Skill":
|
|
368
|
+
return self._exec_skill(params)
|
|
369
|
+
case "ToolSearch":
|
|
370
|
+
return self._exec_tool_search(params)
|
|
371
|
+
case _:
|
|
372
|
+
return f"Tool '{tool_name}' is not yet implemented."
|
|
373
|
+
|
|
374
|
+
async def _exec_bash(self, params: dict[str, Any]) -> str:
|
|
375
|
+
from pathlib import Path
|
|
376
|
+
|
|
377
|
+
cmd = params.get("command", "")
|
|
378
|
+
timeout = int(params.get("timeout", 60_000)) # 60s default
|
|
379
|
+
desc = params.get("description", "")
|
|
380
|
+
|
|
381
|
+
cmd_input = BashCommandInput(
|
|
382
|
+
command=cmd,
|
|
383
|
+
timeout_ms=timeout,
|
|
384
|
+
description=desc,
|
|
385
|
+
run_in_background=params.get("run_in_background", False),
|
|
386
|
+
cwd=Path(self.cwd) if self.cwd else None,
|
|
387
|
+
)
|
|
388
|
+
result = await execute_bash(cmd_input)
|
|
389
|
+
|
|
390
|
+
output_parts = []
|
|
391
|
+
if result.stdout:
|
|
392
|
+
output_parts.append(result.stdout)
|
|
393
|
+
if result.stderr:
|
|
394
|
+
output_parts.append(f"STDERR:\n{result.stderr}")
|
|
395
|
+
if result.exit_code is not None and result.exit_code != 0:
|
|
396
|
+
output_parts.append(f"Exit code: {result.exit_code}")
|
|
397
|
+
if result.timed_out:
|
|
398
|
+
output_parts.append("(Command timed out)")
|
|
399
|
+
|
|
400
|
+
return "\n".join(output_parts) if output_parts else "(no output)"
|
|
401
|
+
|
|
402
|
+
@staticmethod
|
|
403
|
+
def _exec_read(params: dict[str, Any]) -> str:
|
|
404
|
+
result = read_file(
|
|
405
|
+
file_path=params["file_path"],
|
|
406
|
+
start_line=params.get("offset"),
|
|
407
|
+
end_line=(
|
|
408
|
+
params["offset"] + params["limit"]
|
|
409
|
+
if params.get("offset") and params.get("limit")
|
|
410
|
+
else params.get("limit")
|
|
411
|
+
),
|
|
412
|
+
)
|
|
413
|
+
return result.content
|
|
414
|
+
|
|
415
|
+
@staticmethod
|
|
416
|
+
def _exec_write(params: dict[str, Any]) -> str:
|
|
417
|
+
result = write_file(
|
|
418
|
+
file_path=params["file_path"],
|
|
419
|
+
content=params["content"],
|
|
420
|
+
)
|
|
421
|
+
action = "Created" if result.kind == "create" else "Updated"
|
|
422
|
+
return f"{action} {result.file_path}"
|
|
423
|
+
|
|
424
|
+
@staticmethod
|
|
425
|
+
def _exec_edit(params: dict[str, Any]) -> str:
|
|
426
|
+
result = edit_file(
|
|
427
|
+
file_path=params["file_path"],
|
|
428
|
+
old_string=params["old_string"],
|
|
429
|
+
new_string=params["new_string"],
|
|
430
|
+
replace_all=params.get("replace_all", False),
|
|
431
|
+
)
|
|
432
|
+
return f"Replaced {result.replacements} occurrence(s) in {result.file_path}"
|
|
433
|
+
|
|
434
|
+
@staticmethod
|
|
435
|
+
def _exec_glob(params: dict[str, Any]) -> str:
|
|
436
|
+
result = glob_search(
|
|
437
|
+
pattern=params["pattern"],
|
|
438
|
+
path=params.get("path"),
|
|
439
|
+
)
|
|
440
|
+
if not result.filenames:
|
|
441
|
+
return "No files found."
|
|
442
|
+
lines = [f"Found {result.num_files} file(s) in {result.duration_ms:.0f}ms:"]
|
|
443
|
+
for f in result.filenames:
|
|
444
|
+
lines.append(f" {f}")
|
|
445
|
+
if result.truncated:
|
|
446
|
+
lines.append(" ... (results truncated)")
|
|
447
|
+
return "\n".join(lines)
|
|
448
|
+
|
|
449
|
+
@staticmethod
|
|
450
|
+
def _exec_grep(params: dict[str, Any]) -> str:
|
|
451
|
+
result = grep_search(
|
|
452
|
+
pattern=params["pattern"],
|
|
453
|
+
path=params.get("path"),
|
|
454
|
+
glob_filter=params.get("glob"),
|
|
455
|
+
case_insensitive=params.get("-i", False),
|
|
456
|
+
)
|
|
457
|
+
if not result.matches:
|
|
458
|
+
return "No matches found."
|
|
459
|
+
lines = [f"Found {len(result.matches)} match(es) in {result.duration_ms:.0f}ms:"]
|
|
460
|
+
for m in result.matches:
|
|
461
|
+
lines.append(f" {m.file}:{m.line_number}: {m.content}")
|
|
462
|
+
if result.truncated:
|
|
463
|
+
lines.append(" ... (results truncated)")
|
|
464
|
+
return "\n".join(lines)
|
|
465
|
+
|
|
466
|
+
# -----------------------------------------------------------------------
|
|
467
|
+
# WebFetch — actually fetches URLs using httpx
|
|
468
|
+
# -----------------------------------------------------------------------
|
|
469
|
+
|
|
470
|
+
@staticmethod
|
|
471
|
+
async def _exec_web_fetch(params: dict[str, Any]) -> str:
|
|
472
|
+
"""Fetch content from a URL."""
|
|
473
|
+
import sys as _sys
|
|
474
|
+
|
|
475
|
+
import httpx
|
|
476
|
+
|
|
477
|
+
url = params.get("url", "")
|
|
478
|
+
if not url:
|
|
479
|
+
return "Error: url parameter is required"
|
|
480
|
+
|
|
481
|
+
_sys.stderr.write(f"\r\033[K \u28cb Fetching {url[:80]}...")
|
|
482
|
+
_sys.stderr.flush()
|
|
483
|
+
|
|
484
|
+
try:
|
|
485
|
+
async with httpx.AsyncClient(
|
|
486
|
+
timeout=30.0,
|
|
487
|
+
follow_redirects=True,
|
|
488
|
+
headers={"User-Agent": "Axion-Code/1.0.0"},
|
|
489
|
+
) as client:
|
|
490
|
+
response = await client.get(url)
|
|
491
|
+
content_type = response.headers.get("content-type", "")
|
|
492
|
+
|
|
493
|
+
if response.status_code != 200:
|
|
494
|
+
return f"HTTP {response.status_code}: {response.reason_phrase}"
|
|
495
|
+
|
|
496
|
+
# Handle text content
|
|
497
|
+
if "text/" in content_type or "json" in content_type or "xml" in content_type:
|
|
498
|
+
text = response.text
|
|
499
|
+
# Truncate very long responses
|
|
500
|
+
if len(text) > 50_000:
|
|
501
|
+
text = text[:50_000] + "\n\n[Content truncated at 50,000 characters]"
|
|
502
|
+
return text
|
|
503
|
+
|
|
504
|
+
# Binary content — return metadata
|
|
505
|
+
size = len(response.content)
|
|
506
|
+
return (
|
|
507
|
+
f"Binary content ({content_type}), {size:,} bytes.\n"
|
|
508
|
+
f"Cannot display binary content as text."
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
except httpx.TimeoutException:
|
|
512
|
+
return f"Error: Request to {url} timed out after 30 seconds"
|
|
513
|
+
except httpx.HTTPError as exc:
|
|
514
|
+
return f"Error fetching {url}: {exc}"
|
|
515
|
+
finally:
|
|
516
|
+
_sys.stderr.write("\r\033[K")
|
|
517
|
+
_sys.stderr.flush()
|
|
518
|
+
|
|
519
|
+
# -----------------------------------------------------------------------
|
|
520
|
+
# WebSearch — uses DuckDuckGo HTML search (no API key needed)
|
|
521
|
+
# -----------------------------------------------------------------------
|
|
522
|
+
|
|
523
|
+
@staticmethod
|
|
524
|
+
async def _exec_web_search(params: dict[str, Any]) -> str:
|
|
525
|
+
"""Search the web using DuckDuckGo."""
|
|
526
|
+
import re
|
|
527
|
+
import sys as _sys
|
|
528
|
+
|
|
529
|
+
import httpx
|
|
530
|
+
|
|
531
|
+
query = params.get("query", "")
|
|
532
|
+
if not query:
|
|
533
|
+
return "Error: query parameter is required"
|
|
534
|
+
|
|
535
|
+
_sys.stderr.write(f"\r\033[K \u28cb Searching: {query[:60]}...")
|
|
536
|
+
_sys.stderr.flush()
|
|
537
|
+
|
|
538
|
+
try:
|
|
539
|
+
async with httpx.AsyncClient(
|
|
540
|
+
timeout=15.0,
|
|
541
|
+
follow_redirects=True,
|
|
542
|
+
headers={
|
|
543
|
+
"User-Agent": "Mozilla/5.0 (compatible; Axion-Code/1.0.0)",
|
|
544
|
+
},
|
|
545
|
+
) as client:
|
|
546
|
+
# Use DuckDuckGo HTML lite (no API key required)
|
|
547
|
+
response = await client.get(
|
|
548
|
+
"https://html.duckduckgo.com/html/",
|
|
549
|
+
params={"q": query},
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
if response.status_code != 200:
|
|
553
|
+
return f"Search failed: HTTP {response.status_code}"
|
|
554
|
+
|
|
555
|
+
html = response.text
|
|
556
|
+
|
|
557
|
+
# Extract results from DuckDuckGo HTML
|
|
558
|
+
results: list[str] = []
|
|
559
|
+
|
|
560
|
+
# Find result blocks
|
|
561
|
+
result_pattern = re.compile(
|
|
562
|
+
r'<a[^>]*class="result__a"[^>]*href="([^"]*)"[^>]*>(.*?)</a>.*?'
|
|
563
|
+
r'<a[^>]*class="result__snippet"[^>]*>(.*?)</a>',
|
|
564
|
+
re.DOTALL,
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
for match in result_pattern.finditer(html):
|
|
568
|
+
url = match.group(1)
|
|
569
|
+
title = re.sub(r"<[^>]+>", "", match.group(2)).strip()
|
|
570
|
+
snippet = re.sub(r"<[^>]+>", "", match.group(3)).strip()
|
|
571
|
+
|
|
572
|
+
if title and url:
|
|
573
|
+
results.append(f"**{title}**\n {url}\n {snippet}\n")
|
|
574
|
+
|
|
575
|
+
if len(results) >= 10:
|
|
576
|
+
break
|
|
577
|
+
|
|
578
|
+
# Fallback: try simpler pattern
|
|
579
|
+
if not results:
|
|
580
|
+
link_pattern = re.compile(
|
|
581
|
+
r'<a[^>]*class="result__a"[^>]*>(.*?)</a>', re.DOTALL
|
|
582
|
+
)
|
|
583
|
+
for match in link_pattern.finditer(html):
|
|
584
|
+
title = re.sub(r"<[^>]+>", "", match.group(1)).strip()
|
|
585
|
+
if title:
|
|
586
|
+
results.append(f"- {title}")
|
|
587
|
+
if len(results) >= 10:
|
|
588
|
+
break
|
|
589
|
+
|
|
590
|
+
if not results:
|
|
591
|
+
return f"No results found for: {query}"
|
|
592
|
+
|
|
593
|
+
header = f"Search results for: {query}\n\n"
|
|
594
|
+
return header + "\n".join(results)
|
|
595
|
+
|
|
596
|
+
except httpx.TimeoutException:
|
|
597
|
+
return "Error: Search request timed out"
|
|
598
|
+
except httpx.HTTPError as exc:
|
|
599
|
+
return f"Error performing search: {exc}"
|
|
600
|
+
finally:
|
|
601
|
+
_sys.stderr.write("\r\033[K")
|
|
602
|
+
_sys.stderr.flush()
|
|
603
|
+
|
|
604
|
+
# -----------------------------------------------------------------------
|
|
605
|
+
# TodoWrite — manages a task list
|
|
606
|
+
# -----------------------------------------------------------------------
|
|
607
|
+
|
|
608
|
+
_todo_list: list[dict[str, str]] = []
|
|
609
|
+
|
|
610
|
+
@classmethod
|
|
611
|
+
def _exec_todo_write(cls, params: dict[str, Any]) -> str:
|
|
612
|
+
"""Create and manage a structured task list."""
|
|
613
|
+
todos = params.get("todos", [])
|
|
614
|
+
if not todos:
|
|
615
|
+
return "No todos provided."
|
|
616
|
+
|
|
617
|
+
cls._todo_list = []
|
|
618
|
+
for todo in todos:
|
|
619
|
+
cls._todo_list.append({
|
|
620
|
+
"content": todo.get("content", ""),
|
|
621
|
+
"status": todo.get("status", "pending"),
|
|
622
|
+
"activeForm": todo.get("activeForm", ""),
|
|
623
|
+
})
|
|
624
|
+
|
|
625
|
+
# Format output
|
|
626
|
+
lines = ["Task list updated:", ""]
|
|
627
|
+
for i, todo in enumerate(cls._todo_list, 1):
|
|
628
|
+
status = todo["status"]
|
|
629
|
+
icon = {"pending": "○", "in_progress": "◉", "completed": "✓"}.get(status, "?")
|
|
630
|
+
lines.append(f" {icon} {i}. [{status}] {todo['content']}")
|
|
631
|
+
|
|
632
|
+
return "\n".join(lines)
|
|
633
|
+
|
|
634
|
+
@classmethod
|
|
635
|
+
def get_todo_list(cls) -> list[dict[str, str]]:
|
|
636
|
+
"""Get the current todo list."""
|
|
637
|
+
return list(cls._todo_list)
|
|
638
|
+
|
|
639
|
+
# -----------------------------------------------------------------------
|
|
640
|
+
# Agent — spawns a sub-agent as a subprocess
|
|
641
|
+
# -----------------------------------------------------------------------
|
|
642
|
+
|
|
643
|
+
async def _exec_agent(self, params: dict[str, Any]) -> str:
|
|
644
|
+
"""Launch a sub-agent to handle complex tasks.
|
|
645
|
+
|
|
646
|
+
Spawns a new axion process with the agent's prompt, runs it, and returns
|
|
647
|
+
the result. This provides context isolation — the sub-agent gets a fresh
|
|
648
|
+
conversation. Multiple Agent calls run in parallel via asyncio.gather
|
|
649
|
+
in the conversation runtime.
|
|
650
|
+
"""
|
|
651
|
+
import asyncio
|
|
652
|
+
import sys
|
|
653
|
+
|
|
654
|
+
prompt_text = params.get("prompt", "")
|
|
655
|
+
description = params.get("description", "agent task")
|
|
656
|
+
model = params.get("model")
|
|
657
|
+
|
|
658
|
+
if not prompt_text:
|
|
659
|
+
return "Error: prompt parameter is required"
|
|
660
|
+
|
|
661
|
+
# Show progress indicator
|
|
662
|
+
sys.stderr.write(f"\r 🔀 Spawning agent: {description}...\n")
|
|
663
|
+
sys.stderr.flush()
|
|
664
|
+
|
|
665
|
+
# Build the sub-agent command
|
|
666
|
+
cmd = [sys.executable, "-m", "axion.cli.main", "-p", prompt_text]
|
|
667
|
+
if model:
|
|
668
|
+
cmd.extend(["-m", model])
|
|
669
|
+
cmd.extend(["--output-format", "json"])
|
|
670
|
+
|
|
671
|
+
try:
|
|
672
|
+
process = await asyncio.create_subprocess_exec(
|
|
673
|
+
*cmd,
|
|
674
|
+
stdout=asyncio.subprocess.PIPE,
|
|
675
|
+
stderr=asyncio.subprocess.PIPE,
|
|
676
|
+
cwd=self.cwd,
|
|
677
|
+
env={**os.environ},
|
|
678
|
+
)
|
|
679
|
+
|
|
680
|
+
stdout, stderr = await asyncio.wait_for(
|
|
681
|
+
process.communicate(), timeout=300.0, # 5 min timeout
|
|
682
|
+
)
|
|
683
|
+
|
|
684
|
+
exit_code = process.returncode
|
|
685
|
+
output = stdout.decode("utf-8", errors="replace")
|
|
686
|
+
stderr_text = stderr.decode("utf-8", errors="replace")
|
|
687
|
+
|
|
688
|
+
# Clear progress
|
|
689
|
+
sys.stderr.write(f"\r ✅ Agent completed: {description}\n")
|
|
690
|
+
sys.stderr.flush()
|
|
691
|
+
|
|
692
|
+
if exit_code != 0 and not output.strip():
|
|
693
|
+
return f"Agent failed (exit code {exit_code}). stderr: {stderr_text[:1000]}"
|
|
694
|
+
|
|
695
|
+
# Try to parse JSON output and extract the message
|
|
696
|
+
try:
|
|
697
|
+
data = json.loads(output)
|
|
698
|
+
message = data.get("message", "")
|
|
699
|
+
if message:
|
|
700
|
+
return message
|
|
701
|
+
# If no message, return the full JSON summary
|
|
702
|
+
return json.dumps(data, indent=2)
|
|
703
|
+
except json.JSONDecodeError:
|
|
704
|
+
return output if output.strip() else f"Agent completed with no output. stderr: {stderr_text[:500]}"
|
|
705
|
+
|
|
706
|
+
except asyncio.TimeoutError:
|
|
707
|
+
sys.stderr.write(f"\r ⏰ Agent timed out: {description}\n")
|
|
708
|
+
sys.stderr.flush()
|
|
709
|
+
try:
|
|
710
|
+
process.kill() # type: ignore[possibly-undefined]
|
|
711
|
+
except Exception:
|
|
712
|
+
pass
|
|
713
|
+
return f"Agent timed out after 300 seconds. Task: {description}"
|
|
714
|
+
except Exception as exc:
|
|
715
|
+
sys.stderr.write(f"\r ❌ Agent failed: {description}\n")
|
|
716
|
+
sys.stderr.flush()
|
|
717
|
+
return f"Agent execution failed: {exc}"
|
|
718
|
+
|
|
719
|
+
# -----------------------------------------------------------------------
|
|
720
|
+
# NotebookEdit — edits Jupyter notebook cells
|
|
721
|
+
# -----------------------------------------------------------------------
|
|
722
|
+
|
|
723
|
+
@staticmethod
|
|
724
|
+
def _exec_notebook_edit(params: dict[str, Any]) -> str:
|
|
725
|
+
"""Edit a Jupyter notebook cell."""
|
|
726
|
+
notebook_path = params.get("notebook_path", "")
|
|
727
|
+
cell_index = params.get("cell_index")
|
|
728
|
+
new_source = params.get("new_source", "")
|
|
729
|
+
cell_type = params.get("cell_type", "code")
|
|
730
|
+
operation = params.get("operation", "replace") # replace, insert, delete
|
|
731
|
+
|
|
732
|
+
if not notebook_path:
|
|
733
|
+
return "Error: notebook_path is required"
|
|
734
|
+
|
|
735
|
+
from pathlib import Path
|
|
736
|
+
|
|
737
|
+
path = Path(notebook_path)
|
|
738
|
+
if not path.exists():
|
|
739
|
+
return f"Error: Notebook not found: {notebook_path}"
|
|
740
|
+
|
|
741
|
+
try:
|
|
742
|
+
nb_data = json.loads(path.read_text(encoding="utf-8"))
|
|
743
|
+
except (json.JSONDecodeError, OSError) as exc:
|
|
744
|
+
return f"Error reading notebook: {exc}"
|
|
745
|
+
|
|
746
|
+
cells = nb_data.get("cells", [])
|
|
747
|
+
|
|
748
|
+
if operation == "replace" and cell_index is not None:
|
|
749
|
+
if cell_index < 0 or cell_index >= len(cells):
|
|
750
|
+
return f"Error: cell_index {cell_index} out of range (0-{len(cells) - 1})"
|
|
751
|
+
cells[cell_index]["source"] = new_source.splitlines(keepends=True)
|
|
752
|
+
if cell_type:
|
|
753
|
+
cells[cell_index]["cell_type"] = cell_type
|
|
754
|
+
|
|
755
|
+
elif operation == "insert":
|
|
756
|
+
insert_at = cell_index if cell_index is not None else len(cells)
|
|
757
|
+
new_cell = {
|
|
758
|
+
"cell_type": cell_type,
|
|
759
|
+
"source": new_source.splitlines(keepends=True),
|
|
760
|
+
"metadata": {},
|
|
761
|
+
}
|
|
762
|
+
if cell_type == "code":
|
|
763
|
+
new_cell["outputs"] = []
|
|
764
|
+
new_cell["execution_count"] = None
|
|
765
|
+
cells.insert(insert_at, new_cell)
|
|
766
|
+
|
|
767
|
+
elif operation == "delete" and cell_index is not None:
|
|
768
|
+
if cell_index < 0 or cell_index >= len(cells):
|
|
769
|
+
return f"Error: cell_index {cell_index} out of range"
|
|
770
|
+
cells.pop(cell_index)
|
|
771
|
+
|
|
772
|
+
else:
|
|
773
|
+
return f"Error: unsupported operation '{operation}'"
|
|
774
|
+
|
|
775
|
+
nb_data["cells"] = cells
|
|
776
|
+
path.write_text(json.dumps(nb_data, indent=1, ensure_ascii=False), encoding="utf-8")
|
|
777
|
+
|
|
778
|
+
return f"Notebook {operation}d cell at index {cell_index} in {notebook_path}"
|
|
779
|
+
|
|
780
|
+
# -----------------------------------------------------------------------
|
|
781
|
+
# Skill — loads and executes skill definitions
|
|
782
|
+
# -----------------------------------------------------------------------
|
|
783
|
+
|
|
784
|
+
def _exec_skill(self, params: dict[str, Any]) -> str:
|
|
785
|
+
"""Load and execute a skill by name or path."""
|
|
786
|
+
from pathlib import Path
|
|
787
|
+
|
|
788
|
+
from axion.runtime.skills import execute_skill, load_skill, resolve_skill
|
|
789
|
+
|
|
790
|
+
skill_name = params.get("skill", params.get("name", ""))
|
|
791
|
+
user_args = params.get("args", "")
|
|
792
|
+
|
|
793
|
+
if not skill_name:
|
|
794
|
+
return "Error: skill name is required"
|
|
795
|
+
|
|
796
|
+
# Try to resolve by name from conventional directories
|
|
797
|
+
cwd = Path(self.cwd) if self.cwd else Path.cwd()
|
|
798
|
+
skill_path = resolve_skill(skill_name, cwd)
|
|
799
|
+
|
|
800
|
+
# Fallback: treat as direct path
|
|
801
|
+
if skill_path is None:
|
|
802
|
+
candidate = Path(skill_name)
|
|
803
|
+
if candidate.is_file():
|
|
804
|
+
skill_path = candidate
|
|
805
|
+
|
|
806
|
+
if skill_path is None:
|
|
807
|
+
return f"Error: skill '{skill_name}' not found"
|
|
808
|
+
|
|
809
|
+
try:
|
|
810
|
+
skill = load_skill(skill_path)
|
|
811
|
+
except Exception as exc:
|
|
812
|
+
return f"Error loading skill '{skill_name}': {exc}"
|
|
813
|
+
|
|
814
|
+
return execute_skill(skill, user_args)
|
|
815
|
+
|
|
816
|
+
# -----------------------------------------------------------------------
|
|
817
|
+
# ToolSearch — deferred tool schema loading
|
|
818
|
+
# -----------------------------------------------------------------------
|
|
819
|
+
|
|
820
|
+
@staticmethod
|
|
821
|
+
def _exec_tool_search(params: dict[str, Any]) -> str:
|
|
822
|
+
"""Search for tools by keyword or fetch schemas by name."""
|
|
823
|
+
from axion.tools.tool_search import tool_search
|
|
824
|
+
|
|
825
|
+
query = params.get("query", "")
|
|
826
|
+
max_results = int(params.get("max_results", 5))
|
|
827
|
+
|
|
828
|
+
if not query:
|
|
829
|
+
return "Error: query parameter is required"
|
|
830
|
+
|
|
831
|
+
output = tool_search(query, max_results=max_results)
|
|
832
|
+
|
|
833
|
+
# Format results
|
|
834
|
+
if output.schemas:
|
|
835
|
+
# Direct selection — return full schemas
|
|
836
|
+
import json
|
|
837
|
+
lines = [output.message, ""]
|
|
838
|
+
for schema in output.schemas:
|
|
839
|
+
lines.append(f"## {schema['name']}")
|
|
840
|
+
lines.append(f"Description: {schema['description'][:200]}")
|
|
841
|
+
lines.append(f"Schema: {json.dumps(schema['input_schema'], indent=2)}")
|
|
842
|
+
lines.append("")
|
|
843
|
+
return "\n".join(lines)
|
|
844
|
+
|
|
845
|
+
if output.results:
|
|
846
|
+
lines = [output.message, ""]
|
|
847
|
+
for r in output.results:
|
|
848
|
+
lines.append(f" - **{r.name}** (score: {r.score:.1f}): {r.description}")
|
|
849
|
+
lines.append("")
|
|
850
|
+
lines.append("Use 'select:ToolName' to fetch the full schema.")
|
|
851
|
+
return "\n".join(lines)
|
|
852
|
+
|
|
853
|
+
return f"No tools found matching: {query}"
|