@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.
Files changed (135) hide show
  1. package/README.md +724 -0
  2. package/bin/reidx.js +33 -0
  3. package/package.json +40 -0
  4. package/pyproject.toml +45 -0
  5. package/scripts/find-python.js +24 -0
  6. package/scripts/postinstall.js +35 -0
  7. package/src/reidx/__init__.py +3 -0
  8. package/src/reidx/__main__.py +9 -0
  9. package/src/reidx/__pycache__/__init__.cpython-312.pyc +0 -0
  10. package/src/reidx/app/__init__.py +3 -0
  11. package/src/reidx/app/__pycache__/__init__.cpython-312.pyc +0 -0
  12. package/src/reidx/app/__pycache__/commands.cpython-312.pyc +0 -0
  13. package/src/reidx/app/commands.py +221 -0
  14. package/src/reidx/automation/__init__.py +3 -0
  15. package/src/reidx/automation/__pycache__/__init__.cpython-312.pyc +0 -0
  16. package/src/reidx/automation/__pycache__/exec.cpython-312.pyc +0 -0
  17. package/src/reidx/automation/exec.py +35 -0
  18. package/src/reidx/config/__init__.py +10 -0
  19. package/src/reidx/config/__pycache__/__init__.cpython-312.pyc +0 -0
  20. package/src/reidx/config/__pycache__/loader.cpython-312.pyc +0 -0
  21. package/src/reidx/config/__pycache__/models.cpython-312.pyc +0 -0
  22. package/src/reidx/config/__pycache__/settings.cpython-312.pyc +0 -0
  23. package/src/reidx/config/loader.py +104 -0
  24. package/src/reidx/config/models.py +49 -0
  25. package/src/reidx/config/settings.py +127 -0
  26. package/src/reidx/deepreid/__init__.py +8 -0
  27. package/src/reidx/deepreid/__pycache__/__init__.cpython-312.pyc +0 -0
  28. package/src/reidx/deepreid/__pycache__/pipeline.cpython-312.pyc +0 -0
  29. package/src/reidx/deepreid/pipeline.py +221 -0
  30. package/src/reidx/diagnostics/__init__.py +0 -0
  31. package/src/reidx/diagnostics/__pycache__/__init__.cpython-312.pyc +0 -0
  32. package/src/reidx/diagnostics/__pycache__/logger.cpython-312.pyc +0 -0
  33. package/src/reidx/diagnostics/logger.py +56 -0
  34. package/src/reidx/goals/__init__.py +21 -0
  35. package/src/reidx/goals/__pycache__/__init__.cpython-312.pyc +0 -0
  36. package/src/reidx/goals/__pycache__/models.cpython-312.pyc +0 -0
  37. package/src/reidx/goals/__pycache__/store.cpython-312.pyc +0 -0
  38. package/src/reidx/goals/models.py +101 -0
  39. package/src/reidx/goals/store.py +233 -0
  40. package/src/reidx/integrations/__init__.py +3 -0
  41. package/src/reidx/integrations/mcp.py +70 -0
  42. package/src/reidx/nyx/__init__.py +3 -0
  43. package/src/reidx/nyx/__pycache__/__init__.cpython-312.pyc +0 -0
  44. package/src/reidx/nyx/__pycache__/persona.cpython-312.pyc +0 -0
  45. package/src/reidx/nyx/persona.py +35 -0
  46. package/src/reidx/policy/__init__.py +15 -0
  47. package/src/reidx/policy/__pycache__/__init__.cpython-312.pyc +0 -0
  48. package/src/reidx/policy/__pycache__/engine.cpython-312.pyc +0 -0
  49. package/src/reidx/policy/__pycache__/models.cpython-312.pyc +0 -0
  50. package/src/reidx/policy/engine.py +101 -0
  51. package/src/reidx/policy/models.py +35 -0
  52. package/src/reidx/provider/__init__.py +16 -0
  53. package/src/reidx/provider/__pycache__/__init__.cpython-312.pyc +0 -0
  54. package/src/reidx/provider/__pycache__/_http.cpython-312.pyc +0 -0
  55. package/src/reidx/provider/__pycache__/anthropic.cpython-312.pyc +0 -0
  56. package/src/reidx/provider/__pycache__/base.cpython-312.pyc +0 -0
  57. package/src/reidx/provider/__pycache__/ollama.cpython-312.pyc +0 -0
  58. package/src/reidx/provider/__pycache__/openai.cpython-312.pyc +0 -0
  59. package/src/reidx/provider/__pycache__/registry.cpython-312.pyc +0 -0
  60. package/src/reidx/provider/__pycache__/store.cpython-312.pyc +0 -0
  61. package/src/reidx/provider/__pycache__/stub.cpython-312.pyc +0 -0
  62. package/src/reidx/provider/_http.py +29 -0
  63. package/src/reidx/provider/anthropic.py +176 -0
  64. package/src/reidx/provider/base.py +50 -0
  65. package/src/reidx/provider/ollama.py +109 -0
  66. package/src/reidx/provider/openai.py +144 -0
  67. package/src/reidx/provider/registry.py +88 -0
  68. package/src/reidx/provider/store.py +141 -0
  69. package/src/reidx/provider/stub.py +60 -0
  70. package/src/reidx/provider_manager/__init__.py +40 -0
  71. package/src/reidx/provider_manager/__pycache__/__init__.cpython-312.pyc +0 -0
  72. package/src/reidx/provider_manager/__pycache__/catalog.cpython-312.pyc +0 -0
  73. package/src/reidx/provider_manager/__pycache__/database.cpython-312.pyc +0 -0
  74. package/src/reidx/provider_manager/__pycache__/keychain.cpython-312.pyc +0 -0
  75. package/src/reidx/provider_manager/__pycache__/palette.cpython-312.pyc +0 -0
  76. package/src/reidx/provider_manager/catalog.py +330 -0
  77. package/src/reidx/provider_manager/database.py +234 -0
  78. package/src/reidx/provider_manager/keychain.py +102 -0
  79. package/src/reidx/provider_manager/palette.py +831 -0
  80. package/src/reidx/runtime/__init__.py +5 -0
  81. package/src/reidx/runtime/__pycache__/__init__.cpython-312.pyc +0 -0
  82. package/src/reidx/runtime/__pycache__/agent.cpython-312.pyc +0 -0
  83. package/src/reidx/runtime/__pycache__/orchestrator.cpython-312.pyc +0 -0
  84. package/src/reidx/runtime/__pycache__/reasoning.cpython-312.pyc +0 -0
  85. package/src/reidx/runtime/__pycache__/state.cpython-312.pyc +0 -0
  86. package/src/reidx/runtime/__pycache__/subagent.cpython-312.pyc +0 -0
  87. package/src/reidx/runtime/agent.py +198 -0
  88. package/src/reidx/runtime/orchestrator.py +244 -0
  89. package/src/reidx/runtime/reasoning.py +77 -0
  90. package/src/reidx/runtime/state.py +32 -0
  91. package/src/reidx/runtime/subagent.py +174 -0
  92. package/src/reidx/session/__init__.py +4 -0
  93. package/src/reidx/session/__pycache__/__init__.cpython-312.pyc +0 -0
  94. package/src/reidx/session/__pycache__/models.cpython-312.pyc +0 -0
  95. package/src/reidx/session/__pycache__/store.cpython-312.pyc +0 -0
  96. package/src/reidx/session/models.py +43 -0
  97. package/src/reidx/session/store.py +101 -0
  98. package/src/reidx/tasks/__init__.py +4 -0
  99. package/src/reidx/tasks/__pycache__/__init__.cpython-312.pyc +0 -0
  100. package/src/reidx/tasks/__pycache__/models.cpython-312.pyc +0 -0
  101. package/src/reidx/tasks/__pycache__/store.cpython-312.pyc +0 -0
  102. package/src/reidx/tasks/models.py +44 -0
  103. package/src/reidx/tasks/store.py +86 -0
  104. package/src/reidx/tools/__init__.py +44 -0
  105. package/src/reidx/tools/__pycache__/__init__.cpython-312.pyc +0 -0
  106. package/src/reidx/tools/__pycache__/base.cpython-312.pyc +0 -0
  107. package/src/reidx/tools/__pycache__/file_tools.cpython-312.pyc +0 -0
  108. package/src/reidx/tools/__pycache__/registry.cpython-312.pyc +0 -0
  109. package/src/reidx/tools/__pycache__/shell_tool.cpython-312.pyc +0 -0
  110. package/src/reidx/tools/__pycache__/spawn_agent.cpython-312.pyc +0 -0
  111. package/src/reidx/tools/__pycache__/web_tools.cpython-312.pyc +0 -0
  112. package/src/reidx/tools/base.py +84 -0
  113. package/src/reidx/tools/file_tools.py +222 -0
  114. package/src/reidx/tools/registry.py +45 -0
  115. package/src/reidx/tools/shell_tool.py +75 -0
  116. package/src/reidx/tools/spawn_agent.py +185 -0
  117. package/src/reidx/tools/web_tools.py +216 -0
  118. package/src/reidx/ui/__init__.py +41 -0
  119. package/src/reidx/ui/__pycache__/__init__.cpython-312.pyc +0 -0
  120. package/src/reidx/ui/__pycache__/app.cpython-312.pyc +0 -0
  121. package/src/reidx/ui/__pycache__/commands.cpython-312.pyc +0 -0
  122. package/src/reidx/ui/__pycache__/render.cpython-312.pyc +0 -0
  123. package/src/reidx/ui/__pycache__/repl.cpython-312.pyc +0 -0
  124. package/src/reidx/ui/__pycache__/theme.cpython-312.pyc +0 -0
  125. package/src/reidx/ui/app.py +1343 -0
  126. package/src/reidx/ui/commands.py +663 -0
  127. package/src/reidx/ui/render.py +517 -0
  128. package/src/reidx/ui/repl.py +14 -0
  129. package/src/reidx/ui/theme.py +126 -0
  130. package/src/reidx/workflows/__init__.py +4 -0
  131. package/src/reidx/workflows/__pycache__/__init__.cpython-312.pyc +0 -0
  132. package/src/reidx/workflows/__pycache__/models.cpython-312.pyc +0 -0
  133. package/src/reidx/workflows/__pycache__/store.cpython-312.pyc +0 -0
  134. package/src/reidx/workflows/models.py +19 -0
  135. 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"]