opencomputer 0.1.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.
- opencomputer/__init__.py +3 -0
- opencomputer/agent/__init__.py +1 -0
- opencomputer/agent/compaction.py +245 -0
- opencomputer/agent/config.py +108 -0
- opencomputer/agent/config_store.py +210 -0
- opencomputer/agent/injection.py +60 -0
- opencomputer/agent/loop.py +326 -0
- opencomputer/agent/memory.py +132 -0
- opencomputer/agent/prompt_builder.py +66 -0
- opencomputer/agent/prompts/base.j2 +23 -0
- opencomputer/agent/state.py +251 -0
- opencomputer/agent/step.py +31 -0
- opencomputer/cli.py +483 -0
- opencomputer/doctor.py +216 -0
- opencomputer/gateway/__init__.py +1 -0
- opencomputer/gateway/dispatch.py +89 -0
- opencomputer/gateway/protocol.py +84 -0
- opencomputer/gateway/server.py +77 -0
- opencomputer/gateway/wire_server.py +256 -0
- opencomputer/hooks/__init__.py +1 -0
- opencomputer/hooks/engine.py +79 -0
- opencomputer/hooks/runner.py +42 -0
- opencomputer/mcp/__init__.py +1 -0
- opencomputer/mcp/client.py +208 -0
- opencomputer/plugins/__init__.py +1 -0
- opencomputer/plugins/discovery.py +107 -0
- opencomputer/plugins/loader.py +155 -0
- opencomputer/plugins/registry.py +56 -0
- opencomputer/setup_wizard.py +235 -0
- opencomputer/skills/debug-python-import-error/SKILL.md +58 -0
- opencomputer/tools/__init__.py +1 -0
- opencomputer/tools/bash.py +78 -0
- opencomputer/tools/delegate.py +98 -0
- opencomputer/tools/glob.py +70 -0
- opencomputer/tools/grep.py +117 -0
- opencomputer/tools/read.py +81 -0
- opencomputer/tools/registry.py +69 -0
- opencomputer/tools/skill_manage.py +265 -0
- opencomputer/tools/write.py +58 -0
- opencomputer-0.1.0.dist-info/METADATA +190 -0
- opencomputer-0.1.0.dist-info/RECORD +51 -0
- opencomputer-0.1.0.dist-info/WHEEL +4 -0
- opencomputer-0.1.0.dist-info/entry_points.txt +3 -0
- plugin_sdk/__init__.py +66 -0
- plugin_sdk/channel_contract.py +74 -0
- plugin_sdk/core.py +129 -0
- plugin_sdk/hooks.py +80 -0
- plugin_sdk/injection.py +60 -0
- plugin_sdk/provider_contract.py +95 -0
- plugin_sdk/runtime_context.py +39 -0
- plugin_sdk/tool_contract.py +67 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""
|
|
2
|
+
opencomputer setup — interactive first-run wizard.
|
|
3
|
+
|
|
4
|
+
Walks a new user through: pick provider → enter API key → optionally add
|
|
5
|
+
channel tokens → write config.yaml → test the provider connection.
|
|
6
|
+
|
|
7
|
+
Design notes:
|
|
8
|
+
- Never stores the API key in config.yaml — we ask the user to export it
|
|
9
|
+
as an env var (the provider reads $ANTHROPIC_API_KEY / $OPENAI_API_KEY
|
|
10
|
+
natively). Saves the ENV VAR NAME so we can remind the user later.
|
|
11
|
+
- Safe to re-run — each step detects existing config and asks "overwrite?"
|
|
12
|
+
- Provider test is short (just a <10-token ping) to confirm auth works.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import os
|
|
19
|
+
from dataclasses import replace
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from rich.console import Console
|
|
23
|
+
from rich.prompt import Confirm, Prompt
|
|
24
|
+
|
|
25
|
+
from opencomputer.agent.config import (
|
|
26
|
+
Config,
|
|
27
|
+
MCPServerConfig,
|
|
28
|
+
ModelConfig,
|
|
29
|
+
default_config,
|
|
30
|
+
)
|
|
31
|
+
from opencomputer.agent.config_store import (
|
|
32
|
+
config_file_path,
|
|
33
|
+
load_config,
|
|
34
|
+
save_config,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
console = Console()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# Known providers the wizard supports out of the box. Adding a new provider
|
|
41
|
+
# plugin? It'll still work — the user can edit config.yaml by hand.
|
|
42
|
+
_SUPPORTED_PROVIDERS = {
|
|
43
|
+
"anthropic": {
|
|
44
|
+
"label": "Anthropic (Claude)",
|
|
45
|
+
"env_key": "ANTHROPIC_API_KEY",
|
|
46
|
+
"default_model": "claude-opus-4-7",
|
|
47
|
+
"signup_url": "https://console.anthropic.com/settings/keys",
|
|
48
|
+
},
|
|
49
|
+
"openai": {
|
|
50
|
+
"label": "OpenAI (GPT)",
|
|
51
|
+
"env_key": "OPENAI_API_KEY",
|
|
52
|
+
"default_model": "gpt-5.4",
|
|
53
|
+
"signup_url": "https://platform.openai.com/api-keys",
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _print_banner() -> None:
|
|
59
|
+
console.print("\n[bold cyan]╭─────────────────────────────────────╮[/bold cyan]")
|
|
60
|
+
console.print("[bold cyan]│ OpenComputer — Setup Wizard │[/bold cyan]")
|
|
61
|
+
console.print("[bold cyan]╰─────────────────────────────────────╯[/bold cyan]")
|
|
62
|
+
console.print()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _pick_provider() -> tuple[str, dict]:
|
|
66
|
+
console.print("[bold]Step 1 — pick an LLM provider[/bold]")
|
|
67
|
+
for i, (pid, meta) in enumerate(_SUPPORTED_PROVIDERS.items(), 1):
|
|
68
|
+
console.print(f" [cyan]{i}[/cyan]. {meta['label']} — [dim]{pid}[/dim]")
|
|
69
|
+
while True:
|
|
70
|
+
choice = Prompt.ask(
|
|
71
|
+
"Choose", default="1", choices=[str(i) for i in range(1, len(_SUPPORTED_PROVIDERS) + 1)]
|
|
72
|
+
)
|
|
73
|
+
try:
|
|
74
|
+
idx = int(choice) - 1
|
|
75
|
+
except ValueError:
|
|
76
|
+
continue
|
|
77
|
+
pid = list(_SUPPORTED_PROVIDERS.keys())[idx]
|
|
78
|
+
return pid, _SUPPORTED_PROVIDERS[pid]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _prompt_model(default_model: str) -> str:
|
|
82
|
+
console.print(f"\n[bold]Step 2 — which model?[/bold] [dim](default: {default_model})[/dim]")
|
|
83
|
+
return Prompt.ask("Model", default=default_model)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _prompt_api_key(env_key: str, signup_url: str) -> None:
|
|
87
|
+
console.print("\n[bold]Step 3 — API key[/bold]")
|
|
88
|
+
console.print(f"[dim]Get one at {signup_url} if you don't have it yet.[/dim]")
|
|
89
|
+
|
|
90
|
+
current = os.environ.get(env_key, "")
|
|
91
|
+
if current:
|
|
92
|
+
console.print(
|
|
93
|
+
f"[green]✓[/green] {env_key} is already set in your environment "
|
|
94
|
+
f"(ends in …{current[-4:]})."
|
|
95
|
+
)
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
console.print(
|
|
99
|
+
f"[yellow]![/yellow] {env_key} is NOT set. "
|
|
100
|
+
f"Before running, export it in your shell:"
|
|
101
|
+
)
|
|
102
|
+
console.print(f" [bold]export {env_key}=your-key-here[/bold]")
|
|
103
|
+
console.print(
|
|
104
|
+
"[dim]Tip: add it to ~/.zshrc or ~/.bashrc to persist across sessions.[/dim]"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _optional_channel(cfg: Config) -> None:
|
|
109
|
+
console.print("\n[bold]Step 4 — messaging channel (optional)[/bold]")
|
|
110
|
+
console.print("[dim]Skip if you only want to use the CLI for now.[/dim]")
|
|
111
|
+
|
|
112
|
+
want_telegram = Confirm.ask("Set up Telegram?", default=False)
|
|
113
|
+
if want_telegram:
|
|
114
|
+
console.print(
|
|
115
|
+
"1. Open Telegram → message @BotFather → /newbot\n"
|
|
116
|
+
"2. Name the bot, get the token.\n"
|
|
117
|
+
"3. Export the token:"
|
|
118
|
+
)
|
|
119
|
+
console.print(" [bold]export TELEGRAM_BOT_TOKEN=123:ABC...[/bold]")
|
|
120
|
+
console.print(
|
|
121
|
+
"[dim]Then run `opencomputer gateway` — the Telegram plugin "
|
|
122
|
+
"picks up the token automatically.[/dim]"
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _optional_mcp(cfg: Config) -> Config:
|
|
127
|
+
console.print("\n[bold]Step 5 — MCP servers (optional)[/bold]")
|
|
128
|
+
console.print(
|
|
129
|
+
"[dim]MCP servers expose external tools to the agent "
|
|
130
|
+
"(stock prices, databases, browsers, etc.)[/dim]"
|
|
131
|
+
)
|
|
132
|
+
want_mcp = Confirm.ask("Add an MCP server?", default=False)
|
|
133
|
+
if not want_mcp:
|
|
134
|
+
return cfg
|
|
135
|
+
|
|
136
|
+
servers: list[MCPServerConfig] = list(cfg.mcp.servers)
|
|
137
|
+
while True:
|
|
138
|
+
name = Prompt.ask("Server name (kebab-case, e.g. 'investor-agent')")
|
|
139
|
+
if not name.strip():
|
|
140
|
+
break
|
|
141
|
+
command = Prompt.ask("Command to launch it (e.g. 'python3')")
|
|
142
|
+
args_str = Prompt.ask(
|
|
143
|
+
"Args (space-separated, e.g. '-m investor_agent.server')", default=""
|
|
144
|
+
)
|
|
145
|
+
args = tuple(args_str.split()) if args_str else ()
|
|
146
|
+
servers.append(
|
|
147
|
+
MCPServerConfig(name=name, command=command, args=args, enabled=True)
|
|
148
|
+
)
|
|
149
|
+
console.print(f"[green]✓[/green] added {name}")
|
|
150
|
+
if not Confirm.ask("Add another?", default=False):
|
|
151
|
+
break
|
|
152
|
+
|
|
153
|
+
return replace(cfg, mcp=replace(cfg.mcp, servers=tuple(servers)))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
async def _test_provider(provider_id: str, env_key: str) -> bool:
|
|
157
|
+
"""Fire one tiny request to confirm auth works. Returns True on success."""
|
|
158
|
+
if not os.environ.get(env_key):
|
|
159
|
+
console.print(
|
|
160
|
+
f"[yellow]skipped[/yellow] — {env_key} not set, can't test auth yet"
|
|
161
|
+
)
|
|
162
|
+
return False
|
|
163
|
+
|
|
164
|
+
from opencomputer.agent.config import default_config
|
|
165
|
+
from opencomputer.plugins.registry import registry as plugin_registry
|
|
166
|
+
from plugin_sdk.core import Message
|
|
167
|
+
|
|
168
|
+
# Discover + activate providers
|
|
169
|
+
repo_root = Path(__file__).resolve().parent.parent
|
|
170
|
+
ext_dir = repo_root / "extensions"
|
|
171
|
+
if ext_dir.exists():
|
|
172
|
+
plugin_registry.load_all([ext_dir])
|
|
173
|
+
|
|
174
|
+
provider_cls = plugin_registry.providers.get(provider_id)
|
|
175
|
+
if provider_cls is None:
|
|
176
|
+
console.print(f"[red]✗[/red] provider plugin for '{provider_id}' not found")
|
|
177
|
+
return False
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
provider = provider_cls() if isinstance(provider_cls, type) else provider_cls
|
|
181
|
+
resp = await provider.complete(
|
|
182
|
+
model=default_config().model.model if provider_id == "anthropic" else "gpt-5.4",
|
|
183
|
+
messages=[Message(role="user", content="reply with exactly: OK")],
|
|
184
|
+
max_tokens=8,
|
|
185
|
+
)
|
|
186
|
+
console.print(
|
|
187
|
+
f"[green]✓[/green] provider responded — "
|
|
188
|
+
f"{resp.usage.input_tokens} in / {resp.usage.output_tokens} out tokens"
|
|
189
|
+
)
|
|
190
|
+
return True
|
|
191
|
+
except Exception as e: # noqa: BLE001
|
|
192
|
+
console.print(f"[red]✗[/red] provider test failed: {type(e).__name__}: {e}")
|
|
193
|
+
return False
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def run_setup() -> None:
|
|
197
|
+
"""Interactive setup wizard entry point."""
|
|
198
|
+
_print_banner()
|
|
199
|
+
|
|
200
|
+
existing = config_file_path().exists()
|
|
201
|
+
if existing:
|
|
202
|
+
console.print(
|
|
203
|
+
f"[yellow]![/yellow] Existing config found at [dim]{config_file_path()}[/dim]"
|
|
204
|
+
)
|
|
205
|
+
if not Confirm.ask("Overwrite?", default=False):
|
|
206
|
+
console.print("[dim]Aborted.[/dim]")
|
|
207
|
+
return
|
|
208
|
+
|
|
209
|
+
cfg = load_config() if existing else default_config()
|
|
210
|
+
provider_id, meta = _pick_provider()
|
|
211
|
+
model = _prompt_model(meta["default_model"])
|
|
212
|
+
new_model_cfg = ModelConfig(
|
|
213
|
+
provider=provider_id,
|
|
214
|
+
model=model,
|
|
215
|
+
api_key_env=meta["env_key"],
|
|
216
|
+
)
|
|
217
|
+
cfg = replace(cfg, model=new_model_cfg)
|
|
218
|
+
|
|
219
|
+
_prompt_api_key(meta["env_key"], meta["signup_url"])
|
|
220
|
+
_optional_channel(cfg)
|
|
221
|
+
cfg = _optional_mcp(cfg)
|
|
222
|
+
|
|
223
|
+
save_config(cfg)
|
|
224
|
+
console.print(f"\n[green]✓[/green] wrote config → [dim]{config_file_path()}[/dim]")
|
|
225
|
+
|
|
226
|
+
console.print("\n[bold]Step 6 — test the provider connection[/bold]")
|
|
227
|
+
if Confirm.ask("Send a tiny test request now?", default=True):
|
|
228
|
+
asyncio.run(_test_provider(provider_id, meta["env_key"]))
|
|
229
|
+
|
|
230
|
+
console.print(
|
|
231
|
+
"\n[bold green]Setup complete.[/bold green] Run [bold]opencomputer[/bold] to chat."
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
__all__ = ["run_setup"]
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: Debug Python import error
|
|
3
|
+
description: Use when the user hits a ModuleNotFoundError, ImportError, ImportError when running a Python script, circular import, "no module named X", or asks about fixing a broken Python import.
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Debugging Python Import Errors
|
|
8
|
+
|
|
9
|
+
When the user hits an import error, follow this systematic checklist.
|
|
10
|
+
|
|
11
|
+
## 1. Identify the exact error
|
|
12
|
+
|
|
13
|
+
Look at the traceback — the key things to extract:
|
|
14
|
+
- Exact module name that failed (`ModuleNotFoundError: No module named 'foo'`)
|
|
15
|
+
- Which file triggered the import
|
|
16
|
+
- Any partial match (sometimes `X.Y` fails where `X` works — points to a missing submodule)
|
|
17
|
+
|
|
18
|
+
## 2. The 4 most common causes (check in order)
|
|
19
|
+
|
|
20
|
+
### A. Missing dependency
|
|
21
|
+
Most common. Package simply isn't installed.
|
|
22
|
+
- Check: `pip list | grep <module>` or `python -c "import <module>"`
|
|
23
|
+
- Fix: `pip install <module>` (or `pip install -e .` if editable in-project)
|
|
24
|
+
|
|
25
|
+
### B. Virtual environment not activated
|
|
26
|
+
The script is running against system Python, not the venv.
|
|
27
|
+
- Check: `which python` and `echo $VIRTUAL_ENV`
|
|
28
|
+
- Fix: `source .venv/bin/activate` (or equivalent)
|
|
29
|
+
|
|
30
|
+
### C. Wrong working directory / sys.path
|
|
31
|
+
Package lives somewhere not on `sys.path`.
|
|
32
|
+
- Check: `python -c "import sys; print(sys.path)"`
|
|
33
|
+
- Check: is the project installed with `pip install -e .`? That adds src to path.
|
|
34
|
+
- Fix: install in editable mode or use `PYTHONPATH=...`.
|
|
35
|
+
|
|
36
|
+
### D. Circular import
|
|
37
|
+
`A` imports `B` which imports `A` back.
|
|
38
|
+
- Signal: error says "partially initialized module" or "cannot import name X from Y"
|
|
39
|
+
- Fix: restructure — extract the shared piece into a third module that both import.
|
|
40
|
+
|
|
41
|
+
## 3. Plugin/extension specific (OpenComputer)
|
|
42
|
+
|
|
43
|
+
If the error is in an OpenComputer plugin:
|
|
44
|
+
- Plugin entry module names collide — check `opencomputer/plugins/loader.py` — we clear common names between plugin loads.
|
|
45
|
+
- `from X import Y` at top of plugin.py — use the try/except ImportError dual pattern or `importlib.util.spec_from_file_location`.
|
|
46
|
+
|
|
47
|
+
## 4. Verify the fix
|
|
48
|
+
|
|
49
|
+
After making a change:
|
|
50
|
+
```bash
|
|
51
|
+
python -c "import <module>; print('OK')"
|
|
52
|
+
# or run the failing script again
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## 5. Save the root cause
|
|
56
|
+
|
|
57
|
+
If this was a non-obvious fix (circular import fix, sys.path trick), save it
|
|
58
|
+
to a skill so you don't debug it again next time.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Built-in tools — Read, Write, Bash, Grep, Glob, skill_manage, delegate."""
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Bash tool — run a shell command with a timeout."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
|
|
7
|
+
from plugin_sdk.core import ToolCall, ToolResult
|
|
8
|
+
from plugin_sdk.tool_contract import BaseTool, ToolSchema
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BashTool(BaseTool):
|
|
12
|
+
parallel_safe = False # side effects
|
|
13
|
+
|
|
14
|
+
@property
|
|
15
|
+
def schema(self) -> ToolSchema:
|
|
16
|
+
return ToolSchema(
|
|
17
|
+
name="Bash",
|
|
18
|
+
description="Execute a bash command and return stdout+stderr. "
|
|
19
|
+
"Commands run in /bin/bash with a configurable timeout. "
|
|
20
|
+
"Use for scripted tasks, git, package management, file ops.",
|
|
21
|
+
parameters={
|
|
22
|
+
"type": "object",
|
|
23
|
+
"properties": {
|
|
24
|
+
"command": {
|
|
25
|
+
"type": "string",
|
|
26
|
+
"description": "The bash command to execute.",
|
|
27
|
+
},
|
|
28
|
+
"timeout_s": {
|
|
29
|
+
"type": "integer",
|
|
30
|
+
"description": "Max execution time in seconds (default 60, max 600).",
|
|
31
|
+
"minimum": 1,
|
|
32
|
+
"maximum": 600,
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
"required": ["command"],
|
|
36
|
+
},
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
async def execute(self, call: ToolCall) -> ToolResult:
|
|
40
|
+
args = call.arguments
|
|
41
|
+
cmd = args.get("command", "")
|
|
42
|
+
timeout = min(int(args.get("timeout_s", 60)), 600)
|
|
43
|
+
if not cmd.strip():
|
|
44
|
+
return ToolResult(
|
|
45
|
+
tool_call_id=call.id, content="Error: empty command", is_error=True
|
|
46
|
+
)
|
|
47
|
+
try:
|
|
48
|
+
proc = await asyncio.create_subprocess_shell(
|
|
49
|
+
cmd,
|
|
50
|
+
stdout=asyncio.subprocess.PIPE,
|
|
51
|
+
stderr=asyncio.subprocess.PIPE,
|
|
52
|
+
)
|
|
53
|
+
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
|
54
|
+
exit_code = proc.returncode or 0
|
|
55
|
+
except TimeoutError:
|
|
56
|
+
return ToolResult(
|
|
57
|
+
tool_call_id=call.id,
|
|
58
|
+
content=f"Error: command timed out after {timeout}s",
|
|
59
|
+
is_error=True,
|
|
60
|
+
)
|
|
61
|
+
except Exception as e:
|
|
62
|
+
return ToolResult(
|
|
63
|
+
tool_call_id=call.id,
|
|
64
|
+
content=f"Error: {type(e).__name__}: {e}",
|
|
65
|
+
is_error=True,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
out = stdout.decode("utf-8", errors="replace") if stdout else ""
|
|
69
|
+
err = stderr.decode("utf-8", errors="replace") if stderr else ""
|
|
70
|
+
combined = (
|
|
71
|
+
f"$ {cmd}\n"
|
|
72
|
+
f"exit={exit_code}\n"
|
|
73
|
+
f"--- stdout ---\n{out}"
|
|
74
|
+
+ (f"\n--- stderr ---\n{err}" if err else "")
|
|
75
|
+
)
|
|
76
|
+
return ToolResult(
|
|
77
|
+
tool_call_id=call.id, content=combined, is_error=exit_code != 0
|
|
78
|
+
)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""
|
|
2
|
+
delegate — spawn a fresh subagent in an isolated context.
|
|
3
|
+
|
|
4
|
+
Used when the main agent wants to offload a big exploration task without
|
|
5
|
+
polluting its own context. The subagent gets a fresh system prompt +
|
|
6
|
+
whatever briefing the main agent writes, runs its own while-loop, and
|
|
7
|
+
returns a single text summary.
|
|
8
|
+
|
|
9
|
+
Phase 1.5 stub: uses a simple approach where the subagent shares the
|
|
10
|
+
provider + tool registry, but keeps its own conversation messages.
|
|
11
|
+
Later phases can add context isolation, tool restrictions, etc.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from plugin_sdk.core import ToolCall, ToolResult
|
|
17
|
+
from plugin_sdk.runtime_context import DEFAULT_RUNTIME_CONTEXT, RuntimeContext
|
|
18
|
+
from plugin_sdk.tool_contract import BaseTool, ToolSchema
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DelegateTool(BaseTool):
|
|
22
|
+
parallel_safe = True # each delegate gets its own loop instance
|
|
23
|
+
|
|
24
|
+
# Lazy-import a factory the CLI can inject; until then raise a clear error
|
|
25
|
+
_factory = None
|
|
26
|
+
#: Class-level "current runtime" set by the parent loop before dispatching
|
|
27
|
+
#: tool calls. Ensures subagent loops inherit plan_mode / yolo_mode, etc.
|
|
28
|
+
_current_runtime: RuntimeContext = DEFAULT_RUNTIME_CONTEXT
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def set_factory(cls, factory) -> None:
|
|
32
|
+
"""Inject a callable that returns a fresh AgentLoop. Called once at CLI startup."""
|
|
33
|
+
# staticmethod wrap prevents Python from binding `self` when we later do
|
|
34
|
+
# `self._factory()` on an instance — lambdas and plain functions would
|
|
35
|
+
# otherwise get `self` auto-injected.
|
|
36
|
+
cls._factory = staticmethod(factory)
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def set_runtime(cls, runtime: RuntimeContext) -> None:
|
|
40
|
+
"""Set the runtime context to propagate into subagents. Called by AgentLoop."""
|
|
41
|
+
cls._current_runtime = runtime
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def schema(self) -> ToolSchema:
|
|
45
|
+
return ToolSchema(
|
|
46
|
+
name="delegate",
|
|
47
|
+
description=(
|
|
48
|
+
"Spawn a fresh subagent with isolated context to handle a specific task. "
|
|
49
|
+
"Use this when you need to do heavy exploration (reading many files, searching "
|
|
50
|
+
"code) and only want a summary back instead of polluting the main conversation. "
|
|
51
|
+
"The subagent runs until it produces a final answer, then returns its output."
|
|
52
|
+
),
|
|
53
|
+
parameters={
|
|
54
|
+
"type": "object",
|
|
55
|
+
"properties": {
|
|
56
|
+
"task": {
|
|
57
|
+
"type": "string",
|
|
58
|
+
"description": (
|
|
59
|
+
"Describe the task for the subagent completely. The subagent has "
|
|
60
|
+
"no memory of the main conversation — include all context it needs."
|
|
61
|
+
),
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
"required": ["task"],
|
|
65
|
+
},
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
async def execute(self, call: ToolCall) -> ToolResult:
|
|
69
|
+
task = call.arguments.get("task", "").strip()
|
|
70
|
+
if not task:
|
|
71
|
+
return ToolResult(
|
|
72
|
+
tool_call_id=call.id,
|
|
73
|
+
content="Error: task description required",
|
|
74
|
+
is_error=True,
|
|
75
|
+
)
|
|
76
|
+
if self._factory is None:
|
|
77
|
+
return ToolResult(
|
|
78
|
+
tool_call_id=call.id,
|
|
79
|
+
content=(
|
|
80
|
+
"Error: delegate is not initialized. "
|
|
81
|
+
"CLI bootstrapping must call DelegateTool.set_factory(...)."
|
|
82
|
+
),
|
|
83
|
+
is_error=True,
|
|
84
|
+
)
|
|
85
|
+
subagent_loop = self._factory()
|
|
86
|
+
# Propagate the parent's runtime context — plan mode, yolo mode, etc.
|
|
87
|
+
# must apply to subagents too, otherwise delegating becomes an escape hatch.
|
|
88
|
+
result = await subagent_loop.run_conversation(
|
|
89
|
+
user_message=task,
|
|
90
|
+
runtime=self._current_runtime,
|
|
91
|
+
)
|
|
92
|
+
return ToolResult(
|
|
93
|
+
tool_call_id=call.id,
|
|
94
|
+
content=result.final_message.content,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
__all__ = ["DelegateTool"]
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Glob tool — find files by pattern, sorted by mtime."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from plugin_sdk.core import ToolCall, ToolResult
|
|
8
|
+
from plugin_sdk.tool_contract import BaseTool, ToolSchema
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class GlobTool(BaseTool):
|
|
12
|
+
parallel_safe = True
|
|
13
|
+
|
|
14
|
+
@property
|
|
15
|
+
def schema(self) -> ToolSchema:
|
|
16
|
+
return ToolSchema(
|
|
17
|
+
name="Glob",
|
|
18
|
+
description=(
|
|
19
|
+
"Find files matching a glob pattern. Returns paths sorted by modification "
|
|
20
|
+
"time (newest first). Supports recursive patterns like '**/*.py'."
|
|
21
|
+
),
|
|
22
|
+
parameters={
|
|
23
|
+
"type": "object",
|
|
24
|
+
"properties": {
|
|
25
|
+
"pattern": {
|
|
26
|
+
"type": "string",
|
|
27
|
+
"description": "Glob pattern, e.g. '**/*.py' or 'src/**/*.ts'.",
|
|
28
|
+
},
|
|
29
|
+
"path": {
|
|
30
|
+
"type": "string",
|
|
31
|
+
"description": "Root to search from. Defaults to cwd.",
|
|
32
|
+
},
|
|
33
|
+
"max_results": {
|
|
34
|
+
"type": "integer",
|
|
35
|
+
"description": "Cap the result count. Default 500.",
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
"required": ["pattern"],
|
|
39
|
+
},
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
async def execute(self, call: ToolCall) -> ToolResult:
|
|
43
|
+
args = call.arguments
|
|
44
|
+
pattern = args.get("pattern", "")
|
|
45
|
+
path = args.get("path", ".")
|
|
46
|
+
max_results = int(args.get("max_results", 500))
|
|
47
|
+
|
|
48
|
+
if not pattern:
|
|
49
|
+
return ToolResult(
|
|
50
|
+
tool_call_id=call.id, content="Error: pattern required", is_error=True
|
|
51
|
+
)
|
|
52
|
+
root = Path(path)
|
|
53
|
+
if not root.exists():
|
|
54
|
+
return ToolResult(
|
|
55
|
+
tool_call_id=call.id,
|
|
56
|
+
content=f"Error: path does not exist: {root}",
|
|
57
|
+
is_error=True,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
matches = list(root.glob(pattern))
|
|
61
|
+
matches = [p for p in matches if p.is_file()]
|
|
62
|
+
matches.sort(key=lambda p: p.stat().st_mtime, reverse=True)
|
|
63
|
+
matches = matches[:max_results]
|
|
64
|
+
|
|
65
|
+
if not matches:
|
|
66
|
+
return ToolResult(tool_call_id=call.id, content="(no matches)")
|
|
67
|
+
return ToolResult(
|
|
68
|
+
tool_call_id=call.id,
|
|
69
|
+
content="\n".join(str(p) for p in matches),
|
|
70
|
+
)
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Grep tool — search file contents with ripgrep if available, else Python fallback."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from plugin_sdk.core import ToolCall, ToolResult
|
|
11
|
+
from plugin_sdk.tool_contract import BaseTool, ToolSchema
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class GrepTool(BaseTool):
|
|
15
|
+
parallel_safe = True
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def schema(self) -> ToolSchema:
|
|
19
|
+
return ToolSchema(
|
|
20
|
+
name="Grep",
|
|
21
|
+
description=(
|
|
22
|
+
"Search for a regex pattern in files. Uses ripgrep if available, "
|
|
23
|
+
"falls back to pure Python. Returns matching lines with file:line prefix."
|
|
24
|
+
),
|
|
25
|
+
parameters={
|
|
26
|
+
"type": "object",
|
|
27
|
+
"properties": {
|
|
28
|
+
"pattern": {
|
|
29
|
+
"type": "string",
|
|
30
|
+
"description": "Regex pattern to search for.",
|
|
31
|
+
},
|
|
32
|
+
"path": {
|
|
33
|
+
"type": "string",
|
|
34
|
+
"description": "Directory or file to search in. Defaults to cwd.",
|
|
35
|
+
},
|
|
36
|
+
"glob": {
|
|
37
|
+
"type": "string",
|
|
38
|
+
"description": "Optional glob filter (e.g. '*.py').",
|
|
39
|
+
},
|
|
40
|
+
"case_insensitive": {
|
|
41
|
+
"type": "boolean",
|
|
42
|
+
"description": "Case-insensitive match (-i). Default false.",
|
|
43
|
+
},
|
|
44
|
+
"max_results": {
|
|
45
|
+
"type": "integer",
|
|
46
|
+
"description": "Cap the number of matches. Default 200.",
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
"required": ["pattern"],
|
|
50
|
+
},
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
async def execute(self, call: ToolCall) -> ToolResult:
|
|
54
|
+
args = call.arguments
|
|
55
|
+
pattern = args.get("pattern", "")
|
|
56
|
+
path = args.get("path", ".")
|
|
57
|
+
glob = args.get("glob", "")
|
|
58
|
+
case_i = bool(args.get("case_insensitive", False))
|
|
59
|
+
max_results = int(args.get("max_results", 200))
|
|
60
|
+
|
|
61
|
+
if not pattern:
|
|
62
|
+
return ToolResult(tool_call_id=call.id, content="Error: pattern required", is_error=True)
|
|
63
|
+
|
|
64
|
+
rg = shutil.which("rg")
|
|
65
|
+
if rg:
|
|
66
|
+
cmd = [rg, "--no-heading", "--line-number", "--color=never"]
|
|
67
|
+
if case_i:
|
|
68
|
+
cmd.append("-i")
|
|
69
|
+
if glob:
|
|
70
|
+
cmd += ["--glob", glob]
|
|
71
|
+
cmd += ["--max-count", str(max_results)]
|
|
72
|
+
cmd += [pattern, path]
|
|
73
|
+
proc = await asyncio.create_subprocess_exec(
|
|
74
|
+
*cmd,
|
|
75
|
+
stdout=asyncio.subprocess.PIPE,
|
|
76
|
+
stderr=asyncio.subprocess.PIPE,
|
|
77
|
+
)
|
|
78
|
+
out, err = await proc.communicate()
|
|
79
|
+
txt = (out or b"").decode("utf-8", errors="replace")
|
|
80
|
+
return ToolResult(
|
|
81
|
+
tool_call_id=call.id,
|
|
82
|
+
content=txt or "(no matches)",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# pure-python fallback
|
|
86
|
+
try:
|
|
87
|
+
regex = re.compile(pattern, re.IGNORECASE if case_i else 0)
|
|
88
|
+
except re.error as e:
|
|
89
|
+
return ToolResult(
|
|
90
|
+
tool_call_id=call.id, content=f"Error: invalid regex: {e}", is_error=True
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
target = Path(path)
|
|
94
|
+
files: list[Path]
|
|
95
|
+
if target.is_file():
|
|
96
|
+
files = [target]
|
|
97
|
+
else:
|
|
98
|
+
files = list(target.rglob(glob or "*"))
|
|
99
|
+
files = [f for f in files if f.is_file()]
|
|
100
|
+
|
|
101
|
+
hits: list[str] = []
|
|
102
|
+
for f in files:
|
|
103
|
+
if len(hits) >= max_results:
|
|
104
|
+
break
|
|
105
|
+
try:
|
|
106
|
+
for i, line in enumerate(f.read_text(encoding="utf-8", errors="replace").splitlines(), 1):
|
|
107
|
+
if regex.search(line):
|
|
108
|
+
hits.append(f"{f}:{i}:{line}")
|
|
109
|
+
if len(hits) >= max_results:
|
|
110
|
+
break
|
|
111
|
+
except Exception:
|
|
112
|
+
continue
|
|
113
|
+
|
|
114
|
+
return ToolResult(
|
|
115
|
+
tool_call_id=call.id,
|
|
116
|
+
content="\n".join(hits) or "(no matches)",
|
|
117
|
+
)
|