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.
Files changed (82) hide show
  1. axion/__init__.py +3 -0
  2. axion/api/__init__.py +0 -0
  3. axion/api/anthropic.py +460 -0
  4. axion/api/client.py +259 -0
  5. axion/api/error.py +161 -0
  6. axion/api/ollama.py +597 -0
  7. axion/api/openai_compat.py +805 -0
  8. axion/api/openai_responses.py +627 -0
  9. axion/api/prompt_cache.py +31 -0
  10. axion/api/sse.py +98 -0
  11. axion/api/types.py +451 -0
  12. axion/cli/__init__.py +0 -0
  13. axion/cli/init_cmd.py +50 -0
  14. axion/cli/input.py +290 -0
  15. axion/cli/main.py +2953 -0
  16. axion/cli/render.py +489 -0
  17. axion/cli/tui.py +766 -0
  18. axion/commands/__init__.py +0 -0
  19. axion/commands/handlers/__init__.py +0 -0
  20. axion/commands/handlers/agents.py +51 -0
  21. axion/commands/handlers/builtin_commands.py +367 -0
  22. axion/commands/handlers/mcp.py +59 -0
  23. axion/commands/handlers/models.py +75 -0
  24. axion/commands/handlers/plugins.py +55 -0
  25. axion/commands/handlers/skills.py +61 -0
  26. axion/commands/parsing.py +317 -0
  27. axion/commands/registry.py +166 -0
  28. axion/compat_harness/__init__.py +0 -0
  29. axion/compat_harness/extractor.py +145 -0
  30. axion/plugins/__init__.py +0 -0
  31. axion/plugins/hooks.py +22 -0
  32. axion/plugins/manager.py +391 -0
  33. axion/plugins/manifest.py +270 -0
  34. axion/runtime/__init__.py +0 -0
  35. axion/runtime/bash.py +388 -0
  36. axion/runtime/bootstrap.py +39 -0
  37. axion/runtime/claude_subscription.py +300 -0
  38. axion/runtime/compact.py +233 -0
  39. axion/runtime/config.py +397 -0
  40. axion/runtime/conversation.py +1073 -0
  41. axion/runtime/file_ops.py +613 -0
  42. axion/runtime/git.py +213 -0
  43. axion/runtime/hooks.py +235 -0
  44. axion/runtime/image.py +212 -0
  45. axion/runtime/lanes.py +282 -0
  46. axion/runtime/lsp.py +425 -0
  47. axion/runtime/mcp/__init__.py +0 -0
  48. axion/runtime/mcp/client.py +76 -0
  49. axion/runtime/mcp/lifecycle.py +96 -0
  50. axion/runtime/mcp/stdio.py +318 -0
  51. axion/runtime/mcp/tool_bridge.py +79 -0
  52. axion/runtime/memory.py +196 -0
  53. axion/runtime/oauth.py +329 -0
  54. axion/runtime/openai_subscription.py +346 -0
  55. axion/runtime/permissions.py +247 -0
  56. axion/runtime/plan_mode.py +96 -0
  57. axion/runtime/policy_engine.py +259 -0
  58. axion/runtime/prompt.py +586 -0
  59. axion/runtime/recovery.py +261 -0
  60. axion/runtime/remote.py +28 -0
  61. axion/runtime/sandbox.py +68 -0
  62. axion/runtime/scheduler.py +231 -0
  63. axion/runtime/session.py +365 -0
  64. axion/runtime/sharing.py +159 -0
  65. axion/runtime/skills.py +124 -0
  66. axion/runtime/tasks.py +258 -0
  67. axion/runtime/usage.py +241 -0
  68. axion/runtime/workers.py +186 -0
  69. axion/telemetry/__init__.py +0 -0
  70. axion/telemetry/events.py +67 -0
  71. axion/telemetry/profile.py +49 -0
  72. axion/telemetry/sink.py +60 -0
  73. axion/telemetry/tracer.py +95 -0
  74. axion/tools/__init__.py +0 -0
  75. axion/tools/lane_completion.py +33 -0
  76. axion/tools/registry.py +853 -0
  77. axion/tools/tool_search.py +226 -0
  78. axion_code-1.0.0.dist-info/METADATA +709 -0
  79. axion_code-1.0.0.dist-info/RECORD +82 -0
  80. axion_code-1.0.0.dist-info/WHEEL +4 -0
  81. axion_code-1.0.0.dist-info/entry_points.txt +2 -0
  82. axion_code-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -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}"