@agxnte/reidx 2.0.2
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.
- package/README.md +724 -0
- package/bin/reidx.js +33 -0
- package/package.json +40 -0
- package/pyproject.toml +45 -0
- package/scripts/find-python.js +24 -0
- package/scripts/postinstall.js +35 -0
- package/src/reidx/__init__.py +3 -0
- package/src/reidx/__main__.py +9 -0
- package/src/reidx/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/app/__init__.py +3 -0
- package/src/reidx/app/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/app/__pycache__/commands.cpython-312.pyc +0 -0
- package/src/reidx/app/commands.py +221 -0
- package/src/reidx/automation/__init__.py +3 -0
- package/src/reidx/automation/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/automation/__pycache__/exec.cpython-312.pyc +0 -0
- package/src/reidx/automation/exec.py +35 -0
- package/src/reidx/config/__init__.py +10 -0
- package/src/reidx/config/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/config/__pycache__/loader.cpython-312.pyc +0 -0
- package/src/reidx/config/__pycache__/models.cpython-312.pyc +0 -0
- package/src/reidx/config/__pycache__/settings.cpython-312.pyc +0 -0
- package/src/reidx/config/loader.py +104 -0
- package/src/reidx/config/models.py +49 -0
- package/src/reidx/config/settings.py +127 -0
- package/src/reidx/deepreid/__init__.py +8 -0
- package/src/reidx/deepreid/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/deepreid/__pycache__/pipeline.cpython-312.pyc +0 -0
- package/src/reidx/deepreid/pipeline.py +221 -0
- package/src/reidx/diagnostics/__init__.py +0 -0
- package/src/reidx/diagnostics/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/diagnostics/__pycache__/logger.cpython-312.pyc +0 -0
- package/src/reidx/diagnostics/logger.py +56 -0
- package/src/reidx/goals/__init__.py +21 -0
- package/src/reidx/goals/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/goals/__pycache__/models.cpython-312.pyc +0 -0
- package/src/reidx/goals/__pycache__/store.cpython-312.pyc +0 -0
- package/src/reidx/goals/models.py +101 -0
- package/src/reidx/goals/store.py +233 -0
- package/src/reidx/integrations/__init__.py +3 -0
- package/src/reidx/integrations/mcp.py +70 -0
- package/src/reidx/nyx/__init__.py +3 -0
- package/src/reidx/nyx/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/nyx/__pycache__/persona.cpython-312.pyc +0 -0
- package/src/reidx/nyx/persona.py +35 -0
- package/src/reidx/policy/__init__.py +15 -0
- package/src/reidx/policy/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/policy/__pycache__/engine.cpython-312.pyc +0 -0
- package/src/reidx/policy/__pycache__/models.cpython-312.pyc +0 -0
- package/src/reidx/policy/engine.py +101 -0
- package/src/reidx/policy/models.py +35 -0
- package/src/reidx/provider/__init__.py +16 -0
- package/src/reidx/provider/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/provider/__pycache__/_http.cpython-312.pyc +0 -0
- package/src/reidx/provider/__pycache__/anthropic.cpython-312.pyc +0 -0
- package/src/reidx/provider/__pycache__/base.cpython-312.pyc +0 -0
- package/src/reidx/provider/__pycache__/ollama.cpython-312.pyc +0 -0
- package/src/reidx/provider/__pycache__/openai.cpython-312.pyc +0 -0
- package/src/reidx/provider/__pycache__/registry.cpython-312.pyc +0 -0
- package/src/reidx/provider/__pycache__/store.cpython-312.pyc +0 -0
- package/src/reidx/provider/__pycache__/stub.cpython-312.pyc +0 -0
- package/src/reidx/provider/_http.py +29 -0
- package/src/reidx/provider/anthropic.py +176 -0
- package/src/reidx/provider/base.py +50 -0
- package/src/reidx/provider/ollama.py +109 -0
- package/src/reidx/provider/openai.py +144 -0
- package/src/reidx/provider/registry.py +88 -0
- package/src/reidx/provider/store.py +141 -0
- package/src/reidx/provider/stub.py +60 -0
- package/src/reidx/provider_manager/__init__.py +40 -0
- package/src/reidx/provider_manager/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/provider_manager/__pycache__/catalog.cpython-312.pyc +0 -0
- package/src/reidx/provider_manager/__pycache__/database.cpython-312.pyc +0 -0
- package/src/reidx/provider_manager/__pycache__/keychain.cpython-312.pyc +0 -0
- package/src/reidx/provider_manager/__pycache__/palette.cpython-312.pyc +0 -0
- package/src/reidx/provider_manager/catalog.py +330 -0
- package/src/reidx/provider_manager/database.py +234 -0
- package/src/reidx/provider_manager/keychain.py +102 -0
- package/src/reidx/provider_manager/palette.py +831 -0
- package/src/reidx/runtime/__init__.py +5 -0
- package/src/reidx/runtime/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/runtime/__pycache__/agent.cpython-312.pyc +0 -0
- package/src/reidx/runtime/__pycache__/orchestrator.cpython-312.pyc +0 -0
- package/src/reidx/runtime/__pycache__/reasoning.cpython-312.pyc +0 -0
- package/src/reidx/runtime/__pycache__/state.cpython-312.pyc +0 -0
- package/src/reidx/runtime/__pycache__/subagent.cpython-312.pyc +0 -0
- package/src/reidx/runtime/agent.py +198 -0
- package/src/reidx/runtime/orchestrator.py +244 -0
- package/src/reidx/runtime/reasoning.py +77 -0
- package/src/reidx/runtime/state.py +32 -0
- package/src/reidx/runtime/subagent.py +174 -0
- package/src/reidx/session/__init__.py +4 -0
- package/src/reidx/session/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/session/__pycache__/models.cpython-312.pyc +0 -0
- package/src/reidx/session/__pycache__/store.cpython-312.pyc +0 -0
- package/src/reidx/session/models.py +43 -0
- package/src/reidx/session/store.py +101 -0
- package/src/reidx/tasks/__init__.py +4 -0
- package/src/reidx/tasks/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/tasks/__pycache__/models.cpython-312.pyc +0 -0
- package/src/reidx/tasks/__pycache__/store.cpython-312.pyc +0 -0
- package/src/reidx/tasks/models.py +44 -0
- package/src/reidx/tasks/store.py +86 -0
- package/src/reidx/tools/__init__.py +44 -0
- package/src/reidx/tools/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/tools/__pycache__/base.cpython-312.pyc +0 -0
- package/src/reidx/tools/__pycache__/file_tools.cpython-312.pyc +0 -0
- package/src/reidx/tools/__pycache__/registry.cpython-312.pyc +0 -0
- package/src/reidx/tools/__pycache__/shell_tool.cpython-312.pyc +0 -0
- package/src/reidx/tools/__pycache__/spawn_agent.cpython-312.pyc +0 -0
- package/src/reidx/tools/__pycache__/web_tools.cpython-312.pyc +0 -0
- package/src/reidx/tools/base.py +84 -0
- package/src/reidx/tools/file_tools.py +222 -0
- package/src/reidx/tools/registry.py +45 -0
- package/src/reidx/tools/shell_tool.py +75 -0
- package/src/reidx/tools/spawn_agent.py +185 -0
- package/src/reidx/tools/web_tools.py +216 -0
- package/src/reidx/ui/__init__.py +41 -0
- package/src/reidx/ui/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/ui/__pycache__/app.cpython-312.pyc +0 -0
- package/src/reidx/ui/__pycache__/commands.cpython-312.pyc +0 -0
- package/src/reidx/ui/__pycache__/render.cpython-312.pyc +0 -0
- package/src/reidx/ui/__pycache__/repl.cpython-312.pyc +0 -0
- package/src/reidx/ui/__pycache__/theme.cpython-312.pyc +0 -0
- package/src/reidx/ui/app.py +1343 -0
- package/src/reidx/ui/commands.py +663 -0
- package/src/reidx/ui/render.py +517 -0
- package/src/reidx/ui/repl.py +14 -0
- package/src/reidx/ui/theme.py +126 -0
- package/src/reidx/workflows/__init__.py +4 -0
- package/src/reidx/workflows/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/workflows/__pycache__/models.cpython-312.pyc +0 -0
- package/src/reidx/workflows/__pycache__/store.cpython-312.pyc +0 -0
- package/src/reidx/workflows/models.py +19 -0
- package/src/reidx/workflows/store.py +56 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""File tools: read, write, patch, list, find, grep.
|
|
2
|
+
|
|
3
|
+
All file tools confine access to the workspace root plus any additional writable
|
|
4
|
+
roots via _safe_path(). Path traversal outside the workspace is denied. patch_file
|
|
5
|
+
is exact single-match string replacement — honest scaffolding; structured edits
|
|
6
|
+
and diff generation are TODO (see roadmap Phase 5).
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from reidx.policy.models import PermissionDecision, RiskLevel
|
|
15
|
+
from reidx.tools.base import BaseTool, ToolContext, ToolDefinition, ToolResult
|
|
16
|
+
|
|
17
|
+
_READ_PARAMS = {
|
|
18
|
+
"type": "object",
|
|
19
|
+
"properties": {"path": {"type": "string", "description": "Path relative to workspace root."}},
|
|
20
|
+
"required": ["path"],
|
|
21
|
+
}
|
|
22
|
+
_WRITE_PARAMS = {
|
|
23
|
+
"type": "object",
|
|
24
|
+
"properties": {
|
|
25
|
+
"path": {"type": "string"},
|
|
26
|
+
"content": {"type": "string"},
|
|
27
|
+
},
|
|
28
|
+
"required": ["path", "content"],
|
|
29
|
+
}
|
|
30
|
+
_PATCH_PARAMS = {
|
|
31
|
+
"type": "object",
|
|
32
|
+
"properties": {
|
|
33
|
+
"path": {"type": "string"},
|
|
34
|
+
"find": {"type": "string", "description": "Exact substring to locate (single match)."},
|
|
35
|
+
"replace": {"type": "string"},
|
|
36
|
+
},
|
|
37
|
+
"required": ["path", "find", "replace"],
|
|
38
|
+
}
|
|
39
|
+
_LIST_PARAMS = {
|
|
40
|
+
"type": "object",
|
|
41
|
+
"properties": {"path": {"type": "string", "description": "Subdirectory relative to root. Defaults to root."}},
|
|
42
|
+
}
|
|
43
|
+
_FIND_PARAMS = {
|
|
44
|
+
"type": "object",
|
|
45
|
+
"properties": {
|
|
46
|
+
"pattern": {"type": "string", "description": "glob pattern, e.g. '**/*.py'"},
|
|
47
|
+
"path": {"type": "string"},
|
|
48
|
+
},
|
|
49
|
+
"required": ["pattern"],
|
|
50
|
+
}
|
|
51
|
+
_GREP_PARAMS = {
|
|
52
|
+
"type": "object",
|
|
53
|
+
"properties": {
|
|
54
|
+
"pattern": {"type": "string", "description": "regex pattern"},
|
|
55
|
+
"path": {"type": "string"},
|
|
56
|
+
},
|
|
57
|
+
"required": ["pattern"],
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _resolve(path_str: str, ctx: ToolContext) -> Path:
|
|
62
|
+
p = Path(path_str)
|
|
63
|
+
if not p.is_absolute():
|
|
64
|
+
p = ctx.workspace_root / p
|
|
65
|
+
return p
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _safe_read(path: Path, ctx: ToolContext) -> ToolResult | None:
|
|
69
|
+
decision = ctx.policy.check_path(path, write=False)
|
|
70
|
+
if decision is PermissionDecision.DENY:
|
|
71
|
+
return ToolResult.fail(f"path outside workspace: {path}")
|
|
72
|
+
if decision is PermissionDecision.PROMPT:
|
|
73
|
+
if ctx.resolve_decision(f"Allow reading {path}?") is PermissionDecision.DENY:
|
|
74
|
+
return ToolResult.fail(f"read denied by user: {path}")
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _safe_write(path: Path, ctx: ToolContext) -> ToolResult | None:
|
|
79
|
+
decision = ctx.policy.check_path(path, write=True)
|
|
80
|
+
if decision is PermissionDecision.DENY:
|
|
81
|
+
return ToolResult.fail(f"write outside workspace: {path}")
|
|
82
|
+
if decision is PermissionDecision.PROMPT:
|
|
83
|
+
if ctx.resolve_decision(f"Allow writing {path}?") is PermissionDecision.DENY:
|
|
84
|
+
return ToolResult.fail(f"write denied by user: {path}")
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class ReadFileTool(BaseTool):
|
|
89
|
+
@property
|
|
90
|
+
def definition(self) -> ToolDefinition:
|
|
91
|
+
return ToolDefinition(name="read_file", description="Read a file's text content.", parameters=_READ_PARAMS, risk=RiskLevel.LOW)
|
|
92
|
+
|
|
93
|
+
def execute(self, args: dict[str, Any], ctx: ToolContext) -> ToolResult:
|
|
94
|
+
path = _resolve(str(args.get("path", "")), ctx)
|
|
95
|
+
blocked = _safe_read(path, ctx)
|
|
96
|
+
if blocked:
|
|
97
|
+
return blocked
|
|
98
|
+
if not path.exists() or not path.is_file():
|
|
99
|
+
return ToolResult.fail(f"not a file: {path}")
|
|
100
|
+
try:
|
|
101
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
102
|
+
except OSError as exc:
|
|
103
|
+
return ToolResult.fail(f"read error: {exc}")
|
|
104
|
+
return ToolResult.ok_(text, path=str(path), bytes=len(text))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class WriteFileTool(BaseTool):
|
|
108
|
+
@property
|
|
109
|
+
def definition(self) -> ToolDefinition:
|
|
110
|
+
return ToolDefinition(name="write_file", description="Create or overwrite a file.", parameters=_WRITE_PARAMS, risk=RiskLevel.MEDIUM)
|
|
111
|
+
|
|
112
|
+
def execute(self, args: dict[str, Any], ctx: ToolContext) -> ToolResult:
|
|
113
|
+
path = _resolve(str(args.get("path", "")), ctx)
|
|
114
|
+
blocked = _safe_write(path, ctx)
|
|
115
|
+
if blocked:
|
|
116
|
+
return blocked
|
|
117
|
+
content = str(args.get("content", ""))
|
|
118
|
+
try:
|
|
119
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
120
|
+
path.write_text(content, encoding="utf-8")
|
|
121
|
+
except OSError as exc:
|
|
122
|
+
return ToolResult.fail(f"write error: {exc}")
|
|
123
|
+
return ToolResult.ok_(f"wrote {len(content)} bytes to {path}", path=str(path))
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class PatchFileTool(BaseTool):
|
|
127
|
+
@property
|
|
128
|
+
def definition(self) -> ToolDefinition:
|
|
129
|
+
return ToolDefinition(
|
|
130
|
+
name="patch_file",
|
|
131
|
+
description="Replace one exact substring occurrence in a file.",
|
|
132
|
+
parameters=_PATCH_PARAMS,
|
|
133
|
+
risk=RiskLevel.MEDIUM,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
def execute(self, args: dict[str, Any], ctx: ToolContext) -> ToolResult:
|
|
137
|
+
path = _resolve(str(args.get("path", "")), ctx)
|
|
138
|
+
blocked = _safe_write(path, ctx)
|
|
139
|
+
if blocked:
|
|
140
|
+
return blocked
|
|
141
|
+
find = str(args.get("find", ""))
|
|
142
|
+
replace = str(args.get("replace", ""))
|
|
143
|
+
if not find:
|
|
144
|
+
return ToolResult.fail("find string is empty")
|
|
145
|
+
if not path.exists():
|
|
146
|
+
return ToolResult.fail(f"not a file: {path}")
|
|
147
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
148
|
+
count = text.count(find)
|
|
149
|
+
if count == 0:
|
|
150
|
+
return ToolResult.fail("find string not present")
|
|
151
|
+
if count > 1:
|
|
152
|
+
return ToolResult.fail(f"find string matches {count} times; patch requires a unique match (TODO: structured edits)")
|
|
153
|
+
new_text = text.replace(find, replace, 1)
|
|
154
|
+
path.write_text(new_text, encoding="utf-8")
|
|
155
|
+
return ToolResult.ok_("patched", path=str(path))
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class ListDirTool(BaseTool):
|
|
159
|
+
@property
|
|
160
|
+
def definition(self) -> ToolDefinition:
|
|
161
|
+
return ToolDefinition(name="list_dir", description="List entries in a directory.", parameters=_LIST_PARAMS, risk=RiskLevel.LOW)
|
|
162
|
+
|
|
163
|
+
def execute(self, args: dict[str, Any], ctx: ToolContext) -> ToolResult:
|
|
164
|
+
rel = str(args.get("path", ""))
|
|
165
|
+
path = _resolve(rel, ctx) if rel else ctx.workspace_root
|
|
166
|
+
blocked = _safe_read(path, ctx)
|
|
167
|
+
if blocked:
|
|
168
|
+
return blocked
|
|
169
|
+
if not path.is_dir():
|
|
170
|
+
return ToolResult.fail(f"not a directory: {path}")
|
|
171
|
+
entries = sorted(p.name + ("/" if p.is_dir() else "") for p in path.iterdir())
|
|
172
|
+
return ToolResult.ok_("\n".join(entries), count=len(entries))
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class FindFilesTool(BaseTool):
|
|
176
|
+
@property
|
|
177
|
+
def definition(self) -> ToolDefinition:
|
|
178
|
+
return ToolDefinition(name="find_files", description="Find files matching a glob pattern.", parameters=_FIND_PARAMS, risk=RiskLevel.LOW)
|
|
179
|
+
|
|
180
|
+
def execute(self, args: dict[str, Any], ctx: ToolContext) -> ToolResult:
|
|
181
|
+
pattern = str(args.get("pattern", "*"))
|
|
182
|
+
base = _resolve(str(args.get("path", "")), ctx) if args.get("path") else ctx.workspace_root
|
|
183
|
+
blocked = _safe_read(base, ctx)
|
|
184
|
+
if blocked:
|
|
185
|
+
return blocked
|
|
186
|
+
matches = sorted(str(p.relative_to(ctx.workspace_root)) for p in base.glob(pattern) if p.is_file())
|
|
187
|
+
return ToolResult.ok_("\n".join(matches), count=len(matches))
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class GrepFilesTool(BaseTool):
|
|
191
|
+
@property
|
|
192
|
+
def definition(self) -> ToolDefinition:
|
|
193
|
+
return ToolDefinition(name="grep_files", description="Search file contents with a regex.", parameters=_GREP_PARAMS, risk=RiskLevel.LOW)
|
|
194
|
+
|
|
195
|
+
def execute(self, args: dict[str, Any], ctx: ToolContext) -> ToolResult:
|
|
196
|
+
pattern = str(args.get("pattern", ""))
|
|
197
|
+
base = _resolve(str(args.get("path", "")), ctx) if args.get("path") else ctx.workspace_root
|
|
198
|
+
blocked = _safe_read(base, ctx)
|
|
199
|
+
if blocked:
|
|
200
|
+
return blocked
|
|
201
|
+
try:
|
|
202
|
+
rx = re.compile(pattern)
|
|
203
|
+
except re.error as exc:
|
|
204
|
+
return ToolResult.fail(f"bad regex: {exc}")
|
|
205
|
+
hits: list[str] = []
|
|
206
|
+
for p in base.rglob("*"):
|
|
207
|
+
if not p.is_file():
|
|
208
|
+
continue
|
|
209
|
+
try:
|
|
210
|
+
for i, line in enumerate(p.read_text(encoding="utf-8", errors="ignore").splitlines(), 1):
|
|
211
|
+
if rx.search(line):
|
|
212
|
+
hits.append(f"{p.relative_to(ctx.workspace_root)}:{i}: {line.strip()}")
|
|
213
|
+
except OSError:
|
|
214
|
+
continue
|
|
215
|
+
if len(hits) >= 200:
|
|
216
|
+
break
|
|
217
|
+
return ToolResult.ok_("\n".join(hits), count=len(hits))
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def register_file_tools(registry) -> None: # type: ignore[no-untyped-def]
|
|
221
|
+
for tool in (ReadFileTool(), WriteFileTool(), PatchFileTool(), ListDirTool(), FindFilesTool(), GrepFilesTool()):
|
|
222
|
+
registry.register(tool)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Tool registry: registers tools, lists definitions, dispatches calls.
|
|
2
|
+
|
|
3
|
+
Policy gating is owned by each tool (via ctx.policy.check_path / check_command /
|
|
4
|
+
evaluate) so action-specific checks happen in one place. The registry only routes
|
|
5
|
+
and converts exceptions to ToolResult failures.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from reidx.diagnostics.logger import get_logger
|
|
12
|
+
from reidx.tools.base import BaseTool, ToolContext, ToolDefinition, ToolResult
|
|
13
|
+
|
|
14
|
+
log = get_logger("reidx.tools")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ToolRegistry:
|
|
18
|
+
def __init__(self) -> None:
|
|
19
|
+
self._tools: dict[str, BaseTool] = {}
|
|
20
|
+
|
|
21
|
+
def register(self, tool: BaseTool) -> None:
|
|
22
|
+
name = tool.definition.name
|
|
23
|
+
if name in self._tools:
|
|
24
|
+
raise ValueError(f"tool '{name}' already registered")
|
|
25
|
+
self._tools[name] = tool
|
|
26
|
+
log.debug("registered tool: %s", name)
|
|
27
|
+
|
|
28
|
+
def get(self, name: str) -> BaseTool | None:
|
|
29
|
+
return self._tools.get(name)
|
|
30
|
+
|
|
31
|
+
def definitions(self) -> list[ToolDefinition]:
|
|
32
|
+
return [t.definition for t in self._tools.values()]
|
|
33
|
+
|
|
34
|
+
def schemas(self) -> list[dict[str, Any]]:
|
|
35
|
+
return [t.schema() for t in self._tools.values()]
|
|
36
|
+
|
|
37
|
+
def dispatch(self, name: str, args: dict[str, Any], ctx: ToolContext) -> ToolResult:
|
|
38
|
+
tool = self.get(name)
|
|
39
|
+
if tool is None:
|
|
40
|
+
return ToolResult.fail(f"unknown tool: {name}")
|
|
41
|
+
try:
|
|
42
|
+
return tool.execute(args, ctx)
|
|
43
|
+
except Exception as exc: # noqa: BLE001 - tools must not crash the runtime
|
|
44
|
+
log.exception("tool %s raised", name)
|
|
45
|
+
return ToolResult.fail(f"tool '{name}' crashed: {exc}")
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Shell tool: controlled command execution.
|
|
2
|
+
|
|
3
|
+
Gated by the policy engine (command allowlist/denylist + mode). Uses subprocess with
|
|
4
|
+
a timeout, captures stdout/stderr, and returns a structured ToolResult. Shell is
|
|
5
|
+
treated as HIGH risk by default — in balanced/strict modes the user is prompted.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import subprocess
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from reidx.policy.models import PermissionDecision, RiskLevel
|
|
13
|
+
from reidx.tools.base import BaseTool, ToolContext, ToolDefinition, ToolResult
|
|
14
|
+
|
|
15
|
+
_PARAMS = {
|
|
16
|
+
"type": "object",
|
|
17
|
+
"properties": {
|
|
18
|
+
"command": {"type": "string", "description": "Shell command to execute."},
|
|
19
|
+
"cwd": {"type": "string", "description": "Working directory. Defaults to workspace root."},
|
|
20
|
+
},
|
|
21
|
+
"required": ["command"],
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class RunCommandTool(BaseTool):
|
|
26
|
+
@property
|
|
27
|
+
def definition(self) -> ToolDefinition:
|
|
28
|
+
return ToolDefinition(
|
|
29
|
+
name="run_command",
|
|
30
|
+
description="Run a shell command with policy approval and timeout.",
|
|
31
|
+
parameters=_PARAMS,
|
|
32
|
+
risk=RiskLevel.HIGH,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
def execute(self, args: dict[str, Any], ctx: ToolContext) -> ToolResult:
|
|
36
|
+
command = str(args.get("command", "")).strip()
|
|
37
|
+
if not command:
|
|
38
|
+
return ToolResult.fail("empty command")
|
|
39
|
+
decision = ctx.policy.check_command(command)
|
|
40
|
+
if decision is PermissionDecision.DENY:
|
|
41
|
+
return ToolResult.fail(f"command blocked by policy: {command}")
|
|
42
|
+
if decision is PermissionDecision.PROMPT:
|
|
43
|
+
if ctx.resolve_decision(f"Run command? `{command}`") is PermissionDecision.DENY:
|
|
44
|
+
return ToolResult.fail("command denied by user")
|
|
45
|
+
|
|
46
|
+
cwd = str(args.get("cwd", "")) or str(ctx.workspace_root)
|
|
47
|
+
timeout = ctx.policy.config.policy.shell_timeout_seconds
|
|
48
|
+
try:
|
|
49
|
+
proc = subprocess.run(
|
|
50
|
+
command,
|
|
51
|
+
shell=True,
|
|
52
|
+
cwd=cwd,
|
|
53
|
+
capture_output=True,
|
|
54
|
+
text=True,
|
|
55
|
+
timeout=timeout,
|
|
56
|
+
check=False,
|
|
57
|
+
)
|
|
58
|
+
except subprocess.TimeoutExpired:
|
|
59
|
+
return ToolResult.fail(f"command timed out after {timeout}s")
|
|
60
|
+
except OSError as exc:
|
|
61
|
+
return ToolResult.fail(f"failed to spawn: {exc}")
|
|
62
|
+
out = proc.stdout
|
|
63
|
+
if proc.stderr:
|
|
64
|
+
out += ("\n--- stderr ---\n" + proc.stderr) if out else proc.stderr
|
|
65
|
+
ok = proc.returncode == 0
|
|
66
|
+
return ToolResult(
|
|
67
|
+
ok=ok,
|
|
68
|
+
output=out.rstrip(),
|
|
69
|
+
error="" if ok else f"exit code {proc.returncode}",
|
|
70
|
+
data={"exit_code": proc.returncode},
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def register_shell_tool(registry) -> None: # type: ignore[no-untyped-def]
|
|
75
|
+
registry.register(RunCommandTool())
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""spawn_agent tool: run a child Agent with an inline system prompt and a
|
|
2
|
+
scoped tool allowlist.
|
|
3
|
+
|
|
4
|
+
Design (see README): child gets its own Agent + PolicyEngine, sharing the
|
|
5
|
+
parent's ToolRegistry but wrapped so only the allowlisted tools are visible.
|
|
6
|
+
No provider swap by default — the child inherits the parent's provider so
|
|
7
|
+
switching to a local model at the top level (via `/use`) applies to child
|
|
8
|
+
agents too. Optionally the caller can override provider and/or model.
|
|
9
|
+
|
|
10
|
+
Lifecycle is reported to `orchestrator.subagents` (a SubagentManager) so the
|
|
11
|
+
TUI panel below the status line renders one live row per active child.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from reidx.diagnostics.logger import get_logger
|
|
18
|
+
from reidx.policy.engine import PolicyEngine
|
|
19
|
+
from reidx.provider.base import Message
|
|
20
|
+
from reidx.runtime.state import RuntimeState
|
|
21
|
+
from reidx.session.models import Session
|
|
22
|
+
from reidx.tools.base import BaseTool, ToolContext, ToolDefinition, ToolResult
|
|
23
|
+
from reidx.tools.registry import ToolRegistry
|
|
24
|
+
|
|
25
|
+
log = get_logger("reidx.tools.spawn_agent")
|
|
26
|
+
|
|
27
|
+
MAX_CHILD_STEPS = 6
|
|
28
|
+
DEFAULT_ALLOWED = ("read_file", "list_dir", "find_files", "grep_files")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SpawnAgentTool(BaseTool):
|
|
32
|
+
@property
|
|
33
|
+
def definition(self) -> ToolDefinition:
|
|
34
|
+
from reidx.policy.models import RiskLevel
|
|
35
|
+
return ToolDefinition(
|
|
36
|
+
name="spawn_agent",
|
|
37
|
+
description=(
|
|
38
|
+
"Run a specialized child agent with an inline system prompt and a "
|
|
39
|
+
"restricted tool set. Blocks until the child returns its final "
|
|
40
|
+
"text. Use for parallel research, focused review, or any task that "
|
|
41
|
+
"shouldn't pollute the main conversation. Child cannot spawn its "
|
|
42
|
+
"own subagents."
|
|
43
|
+
),
|
|
44
|
+
parameters={
|
|
45
|
+
"type": "object",
|
|
46
|
+
"properties": {
|
|
47
|
+
"name": {
|
|
48
|
+
"type": "string",
|
|
49
|
+
"description": "Short label shown in the subagent panel (e.g. 'researcher').",
|
|
50
|
+
},
|
|
51
|
+
"system_prompt": {
|
|
52
|
+
"type": "string",
|
|
53
|
+
"description": "System prompt that fully specifies the child's role and constraints.",
|
|
54
|
+
},
|
|
55
|
+
"task": {
|
|
56
|
+
"type": "string",
|
|
57
|
+
"description": "The task/user message for the child to work on.",
|
|
58
|
+
},
|
|
59
|
+
"tool_allowlist": {
|
|
60
|
+
"type": "array",
|
|
61
|
+
"items": {"type": "string"},
|
|
62
|
+
"description": (
|
|
63
|
+
"Names of tools the child may call. Omit for a "
|
|
64
|
+
"read-only default (read_file, list_dir, find_files, "
|
|
65
|
+
"grep_files). spawn_agent itself is never available "
|
|
66
|
+
"to the child."
|
|
67
|
+
),
|
|
68
|
+
},
|
|
69
|
+
"model": {
|
|
70
|
+
"type": "string",
|
|
71
|
+
"description": "Optional model override for the child (defaults to parent's).",
|
|
72
|
+
},
|
|
73
|
+
"provider": {
|
|
74
|
+
"type": "string",
|
|
75
|
+
"description": (
|
|
76
|
+
"Optional provider name override (must be registered; "
|
|
77
|
+
"see /providers)."
|
|
78
|
+
),
|
|
79
|
+
},
|
|
80
|
+
"max_steps": {
|
|
81
|
+
"type": "integer",
|
|
82
|
+
"description": f"Max tool-calling steps for the child (default {MAX_CHILD_STEPS}).",
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
"required": ["name", "system_prompt", "task"],
|
|
86
|
+
},
|
|
87
|
+
risk=RiskLevel.MEDIUM,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
def execute(self, args: dict[str, Any], ctx: ToolContext) -> ToolResult:
|
|
91
|
+
orch = ctx.extra.get("orchestrator")
|
|
92
|
+
if orch is None:
|
|
93
|
+
return ToolResult.fail("spawn_agent unavailable (no orchestrator in context)")
|
|
94
|
+
|
|
95
|
+
name = str(args.get("name") or "subagent").strip() or "subagent"
|
|
96
|
+
system_prompt = str(args.get("system_prompt") or "").strip()
|
|
97
|
+
task = str(args.get("task") or "").strip()
|
|
98
|
+
if not system_prompt or not task:
|
|
99
|
+
return ToolResult.fail("spawn_agent requires both 'system_prompt' and 'task'")
|
|
100
|
+
|
|
101
|
+
allowlist = args.get("tool_allowlist") or list(DEFAULT_ALLOWED)
|
|
102
|
+
if not isinstance(allowlist, list):
|
|
103
|
+
return ToolResult.fail("tool_allowlist must be a list of tool names")
|
|
104
|
+
# Prevent recursive spawning — deep chains of nested subagents amplify
|
|
105
|
+
# errors and blow up the panel. One layer only.
|
|
106
|
+
allowlist = [t for t in allowlist if t != "spawn_agent"]
|
|
107
|
+
|
|
108
|
+
# Build a filtered ToolRegistry so the child provider only sees the
|
|
109
|
+
# allowlisted schemas *and* the registry rejects unknown-name calls.
|
|
110
|
+
parent_tools: ToolRegistry = orch.tools
|
|
111
|
+
child_tools = ToolRegistry()
|
|
112
|
+
for tool_name in allowlist:
|
|
113
|
+
tool = parent_tools.get(tool_name)
|
|
114
|
+
if tool is None:
|
|
115
|
+
log.debug("skipping unknown tool in allowlist: %s", tool_name)
|
|
116
|
+
continue
|
|
117
|
+
child_tools.register(tool)
|
|
118
|
+
|
|
119
|
+
# Provider override (optional). Defaults to parent's active provider.
|
|
120
|
+
provider = orch.provider
|
|
121
|
+
provider_name_override = args.get("provider")
|
|
122
|
+
if provider_name_override:
|
|
123
|
+
if orch.providers is None or not orch.providers.has(provider_name_override):
|
|
124
|
+
return ToolResult.fail(f"provider '{provider_name_override}' is not registered")
|
|
125
|
+
provider = orch.providers.get(provider_name_override)
|
|
126
|
+
|
|
127
|
+
# Child policy engine is its own instance so a child in a stricter mode
|
|
128
|
+
# doesn't mutate the parent's mode. Same config, same denylist.
|
|
129
|
+
child_policy = PolicyEngine(orch.config)
|
|
130
|
+
child_policy.set_mode(orch.policy.mode)
|
|
131
|
+
|
|
132
|
+
# Lazy import to avoid the runtime→tools→runtime cycle.
|
|
133
|
+
from reidx.runtime.agent import Agent # noqa: PLC0415
|
|
134
|
+
|
|
135
|
+
child_agent = Agent(
|
|
136
|
+
provider,
|
|
137
|
+
child_tools,
|
|
138
|
+
child_policy,
|
|
139
|
+
base_system_prompt=system_prompt,
|
|
140
|
+
context_extras={}, # child cannot see the orchestrator: no nested spawn
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
parent_workspace = ctx.workspace_root
|
|
144
|
+
child_session = Session(
|
|
145
|
+
title=f"subagent:{name}",
|
|
146
|
+
workspace=parent_workspace,
|
|
147
|
+
provider=getattr(provider, "name", "unknown"),
|
|
148
|
+
model=args.get("model") or (orch.state.session.model if orch.state else ""),
|
|
149
|
+
permission_mode=orch.policy.mode,
|
|
150
|
+
)
|
|
151
|
+
child_state = RuntimeState(session=child_session)
|
|
152
|
+
|
|
153
|
+
max_steps = int(args.get("max_steps") or MAX_CHILD_STEPS)
|
|
154
|
+
|
|
155
|
+
subagents = orch.subagents
|
|
156
|
+
agent_id = subagents.start(name)
|
|
157
|
+
subagents.update(agent_id, last_action=f"provider={provider.name} model={child_session.model or 'default'}")
|
|
158
|
+
|
|
159
|
+
try:
|
|
160
|
+
final_text, tool_log = child_agent.run_turn(
|
|
161
|
+
child_state,
|
|
162
|
+
task,
|
|
163
|
+
writable_roots=[r.resolve() for r in orch.config.policy.additional_writable_roots],
|
|
164
|
+
approver=ctx.approver,
|
|
165
|
+
max_steps=max_steps,
|
|
166
|
+
)
|
|
167
|
+
except Exception as exc: # noqa: BLE001 - errors surface as tool-result failures
|
|
168
|
+
log.exception("subagent '%s' crashed", name)
|
|
169
|
+
subagents.finish(agent_id, status="error", error=str(exc))
|
|
170
|
+
return ToolResult.fail(f"subagent '{name}' crashed: {exc}")
|
|
171
|
+
|
|
172
|
+
subagents.update(agent_id, last_action=f"finished after {len(tool_log)} tool call(s)")
|
|
173
|
+
subagents.finish(agent_id, status="done")
|
|
174
|
+
|
|
175
|
+
# Return a compact summary; the panel already showed live progress.
|
|
176
|
+
header = f"[subagent:{name}] provider={provider.name} tools_used={len(tool_log)}"
|
|
177
|
+
return ToolResult.ok_(output=f"{header}\n\n{final_text}", subagent_id=agent_id, tools=tool_log)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def register_spawn_agent(reg: ToolRegistry) -> None:
|
|
181
|
+
reg.register(SpawnAgentTool())
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# Preserve Message import in case future callers want to inspect state.messages.
|
|
185
|
+
__all__ = ["SpawnAgentTool", "register_spawn_agent", "Message"]
|