agent-dispatch 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.
- agent_dispatch/__init__.py +3 -0
- agent_dispatch/cache.py +91 -0
- agent_dispatch/cli.py +178 -0
- agent_dispatch/config.py +153 -0
- agent_dispatch/models.py +84 -0
- agent_dispatch/runner.py +331 -0
- agent_dispatch/server.py +710 -0
- agent_dispatch-0.1.0.dist-info/METADATA +353 -0
- agent_dispatch-0.1.0.dist-info/RECORD +12 -0
- agent_dispatch-0.1.0.dist-info/WHEEL +4 -0
- agent_dispatch-0.1.0.dist-info/entry_points.txt +2 -0
- agent_dispatch-0.1.0.dist-info/licenses/LICENSE +21 -0
agent_dispatch/runner.py
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"""Core dispatch logic: run claude -p in agent directories."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import threading
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
|
|
13
|
+
from .models import AgentConfig, DispatchResult, Settings
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
_DEPTH_ENV_VAR = "AGENT_DISPATCH_DEPTH"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _current_depth() -> int:
|
|
21
|
+
raw = os.environ.get(_DEPTH_ENV_VAR, "0")
|
|
22
|
+
try:
|
|
23
|
+
return int(raw)
|
|
24
|
+
except ValueError:
|
|
25
|
+
logger.warning("Invalid %s value: %r, treating as 0", _DEPTH_ENV_VAR, raw)
|
|
26
|
+
return 0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _check_recursion(max_depth: int) -> None:
|
|
30
|
+
depth = _current_depth()
|
|
31
|
+
if depth >= max_depth:
|
|
32
|
+
raise RecursionError(
|
|
33
|
+
f"Dispatch depth {depth} >= max {max_depth}. "
|
|
34
|
+
"An agent is trying to dispatch to another agent that dispatches back. "
|
|
35
|
+
"Increase settings.max_dispatch_depth if this is intentional."
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _find_claude() -> str:
|
|
40
|
+
path = shutil.which("claude")
|
|
41
|
+
if path is None:
|
|
42
|
+
raise FileNotFoundError(
|
|
43
|
+
"Claude CLI not found in PATH. "
|
|
44
|
+
"Install: https://docs.anthropic.com/en/docs/claude-code"
|
|
45
|
+
)
|
|
46
|
+
return path
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _build_command(
|
|
50
|
+
claude_path: str,
|
|
51
|
+
task: str,
|
|
52
|
+
agent: AgentConfig,
|
|
53
|
+
settings: Settings,
|
|
54
|
+
session_id: str | None = None,
|
|
55
|
+
) -> list[str]:
|
|
56
|
+
cmd = [claude_path, "-p", task, "--output-format", "json"]
|
|
57
|
+
|
|
58
|
+
if session_id:
|
|
59
|
+
cmd.extend(["--resume", session_id])
|
|
60
|
+
|
|
61
|
+
budget = agent.max_budget_usd or settings.default_max_budget_usd
|
|
62
|
+
if budget:
|
|
63
|
+
cmd.extend(["--max-budget-usd", str(budget)])
|
|
64
|
+
|
|
65
|
+
if agent.model:
|
|
66
|
+
cmd.extend(["--model", agent.model])
|
|
67
|
+
|
|
68
|
+
if agent.permission_mode:
|
|
69
|
+
cmd.extend(["--permission-mode", agent.permission_mode])
|
|
70
|
+
|
|
71
|
+
for tool in agent.allowed_tools:
|
|
72
|
+
cmd.extend(["--allowedTools", tool])
|
|
73
|
+
|
|
74
|
+
for tool in agent.disallowed_tools:
|
|
75
|
+
cmd.extend(["--disallowedTools", tool])
|
|
76
|
+
|
|
77
|
+
return cmd
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _build_prompt(
|
|
81
|
+
task: str,
|
|
82
|
+
context: str | None = None,
|
|
83
|
+
caller: str | None = None,
|
|
84
|
+
goal: str | None = None,
|
|
85
|
+
) -> str:
|
|
86
|
+
"""Build a structured prompt for the dispatched agent.
|
|
87
|
+
|
|
88
|
+
When *caller* or *goal* are provided the prompt uses a structured
|
|
89
|
+
multi-section format so the dispatched agent understands who asked,
|
|
90
|
+
why, and what broader objective it serves. Without metadata the
|
|
91
|
+
output is identical to the original simple format (backward compat).
|
|
92
|
+
"""
|
|
93
|
+
if not caller and not goal:
|
|
94
|
+
if context:
|
|
95
|
+
return f"Context:\n{context}\n\nTask:\n{task}"
|
|
96
|
+
return task
|
|
97
|
+
|
|
98
|
+
parts: list[str] = []
|
|
99
|
+
if goal:
|
|
100
|
+
parts.append(f"## Goal\n{goal}")
|
|
101
|
+
if caller:
|
|
102
|
+
parts.append(f"## Dispatched by\n{caller}")
|
|
103
|
+
if context:
|
|
104
|
+
parts.append(f"## Context\n{context}")
|
|
105
|
+
parts.append(f"## Task\n{task}")
|
|
106
|
+
return "\n\n".join(parts)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def dispatch(
|
|
110
|
+
agent_name: str,
|
|
111
|
+
task: str,
|
|
112
|
+
agent: AgentConfig,
|
|
113
|
+
settings: Settings,
|
|
114
|
+
context: str | None = None,
|
|
115
|
+
session_id: str | None = None,
|
|
116
|
+
*,
|
|
117
|
+
caller: str | None = None,
|
|
118
|
+
goal: str | None = None,
|
|
119
|
+
) -> DispatchResult:
|
|
120
|
+
"""Run a task via claude -p in the agent's directory."""
|
|
121
|
+
try:
|
|
122
|
+
_check_recursion(settings.max_dispatch_depth)
|
|
123
|
+
except RecursionError as e:
|
|
124
|
+
return DispatchResult(agent=agent_name, success=False, result="", error=str(e))
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
claude_path = _find_claude()
|
|
128
|
+
except FileNotFoundError as e:
|
|
129
|
+
return DispatchResult(agent=agent_name, success=False, result="", error=str(e))
|
|
130
|
+
|
|
131
|
+
if not agent.directory.is_dir():
|
|
132
|
+
return DispatchResult(
|
|
133
|
+
agent=agent_name,
|
|
134
|
+
success=False,
|
|
135
|
+
result="",
|
|
136
|
+
error=f"Directory does not exist: {agent.directory}",
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
full_task = _build_prompt(task, context, caller, goal)
|
|
140
|
+
|
|
141
|
+
cmd = _build_command(claude_path, full_task, agent, settings, session_id)
|
|
142
|
+
timeout = agent.timeout or settings.default_timeout
|
|
143
|
+
|
|
144
|
+
# Propagate depth for recursion protection
|
|
145
|
+
env = os.environ.copy()
|
|
146
|
+
env[_DEPTH_ENV_VAR] = str(_current_depth() + 1)
|
|
147
|
+
|
|
148
|
+
logger.info("Dispatching to %s (timeout=%ds)", agent_name, timeout)
|
|
149
|
+
logger.debug("Task: %s", task[:200])
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
proc = subprocess.run(
|
|
153
|
+
cmd,
|
|
154
|
+
cwd=str(agent.directory),
|
|
155
|
+
capture_output=True,
|
|
156
|
+
text=True,
|
|
157
|
+
timeout=timeout,
|
|
158
|
+
env=env,
|
|
159
|
+
)
|
|
160
|
+
except subprocess.TimeoutExpired:
|
|
161
|
+
return DispatchResult(
|
|
162
|
+
agent=agent_name,
|
|
163
|
+
success=False,
|
|
164
|
+
result="",
|
|
165
|
+
error=f"Agent '{agent_name}' timed out after {timeout}s. "
|
|
166
|
+
"Increase timeout in agents.yaml if the task needs more time.",
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
if proc.returncode != 0 and not proc.stdout.strip():
|
|
170
|
+
return DispatchResult(
|
|
171
|
+
agent=agent_name,
|
|
172
|
+
success=False,
|
|
173
|
+
result="",
|
|
174
|
+
error=proc.stderr.strip() or f"claude exited with code {proc.returncode}",
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
# Parse JSON output
|
|
178
|
+
try:
|
|
179
|
+
data = json.loads(proc.stdout)
|
|
180
|
+
except json.JSONDecodeError:
|
|
181
|
+
# Fallback: treat stdout as plain text
|
|
182
|
+
return DispatchResult(
|
|
183
|
+
agent=agent_name,
|
|
184
|
+
success=proc.returncode == 0,
|
|
185
|
+
result=proc.stdout.strip(),
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
is_error = data.get("is_error", False)
|
|
189
|
+
return DispatchResult(
|
|
190
|
+
agent=agent_name,
|
|
191
|
+
success=not is_error,
|
|
192
|
+
result=data.get("result", ""),
|
|
193
|
+
session_id=data.get("session_id"),
|
|
194
|
+
cost_usd=data.get("total_cost_usd"),
|
|
195
|
+
duration_ms=data.get("duration_ms"),
|
|
196
|
+
num_turns=data.get("num_turns"),
|
|
197
|
+
error=data.get("result") if is_error else None,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def dispatch_stream(
|
|
202
|
+
agent_name: str,
|
|
203
|
+
task: str,
|
|
204
|
+
agent: AgentConfig,
|
|
205
|
+
settings: Settings,
|
|
206
|
+
context: str | None = None,
|
|
207
|
+
on_progress: Callable[[str], None] | None = None,
|
|
208
|
+
*,
|
|
209
|
+
caller: str | None = None,
|
|
210
|
+
goal: str | None = None,
|
|
211
|
+
) -> DispatchResult:
|
|
212
|
+
"""Run a task via claude -p with streaming output and progress callbacks.
|
|
213
|
+
|
|
214
|
+
Uses --output-format stream-json to read intermediate results while the
|
|
215
|
+
agent works. Each assistant message and tool-use event is forwarded to
|
|
216
|
+
*on_progress* so callers can surface live updates.
|
|
217
|
+
"""
|
|
218
|
+
try:
|
|
219
|
+
_check_recursion(settings.max_dispatch_depth)
|
|
220
|
+
except RecursionError as e:
|
|
221
|
+
return DispatchResult(agent=agent_name, success=False, result="", error=str(e))
|
|
222
|
+
|
|
223
|
+
try:
|
|
224
|
+
claude_path = _find_claude()
|
|
225
|
+
except FileNotFoundError as e:
|
|
226
|
+
return DispatchResult(agent=agent_name, success=False, result="", error=str(e))
|
|
227
|
+
|
|
228
|
+
if not agent.directory.is_dir():
|
|
229
|
+
return DispatchResult(
|
|
230
|
+
agent=agent_name,
|
|
231
|
+
success=False,
|
|
232
|
+
result="",
|
|
233
|
+
error=f"Directory does not exist: {agent.directory}",
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
full_task = _build_prompt(task, context, caller, goal)
|
|
237
|
+
|
|
238
|
+
cmd = _build_command(claude_path, full_task, agent, settings)
|
|
239
|
+
# Switch from json to stream-json
|
|
240
|
+
fmt_idx = cmd.index("--output-format")
|
|
241
|
+
cmd[fmt_idx + 1] = "stream-json"
|
|
242
|
+
|
|
243
|
+
timeout = agent.timeout or settings.default_timeout
|
|
244
|
+
env = os.environ.copy()
|
|
245
|
+
env[_DEPTH_ENV_VAR] = str(_current_depth() + 1)
|
|
246
|
+
|
|
247
|
+
logger.info("Dispatching (stream) to %s (timeout=%ds)", agent_name, timeout)
|
|
248
|
+
|
|
249
|
+
try:
|
|
250
|
+
proc = subprocess.Popen(
|
|
251
|
+
cmd,
|
|
252
|
+
cwd=str(agent.directory),
|
|
253
|
+
stdout=subprocess.PIPE,
|
|
254
|
+
stderr=subprocess.PIPE,
|
|
255
|
+
text=True,
|
|
256
|
+
env=env,
|
|
257
|
+
)
|
|
258
|
+
except OSError as e:
|
|
259
|
+
return DispatchResult(agent=agent_name, success=False, result="", error=str(e))
|
|
260
|
+
|
|
261
|
+
# Kill the process if it exceeds the timeout
|
|
262
|
+
timed_out = threading.Event()
|
|
263
|
+
|
|
264
|
+
def _kill() -> None:
|
|
265
|
+
timed_out.set()
|
|
266
|
+
proc.kill()
|
|
267
|
+
|
|
268
|
+
timer = threading.Timer(timeout, _kill)
|
|
269
|
+
timer.start()
|
|
270
|
+
|
|
271
|
+
result_data: dict | None = None
|
|
272
|
+
try:
|
|
273
|
+
for line in proc.stdout: # type: ignore[union-attr]
|
|
274
|
+
line = line.strip()
|
|
275
|
+
if not line:
|
|
276
|
+
continue
|
|
277
|
+
try:
|
|
278
|
+
data = json.loads(line)
|
|
279
|
+
except json.JSONDecodeError:
|
|
280
|
+
continue
|
|
281
|
+
|
|
282
|
+
msg_type = data.get("type")
|
|
283
|
+
if msg_type == "result":
|
|
284
|
+
result_data = data
|
|
285
|
+
elif msg_type == "assistant" and on_progress:
|
|
286
|
+
content = data.get("message", {}).get("content", [])
|
|
287
|
+
if isinstance(content, list):
|
|
288
|
+
for block in content:
|
|
289
|
+
if block.get("type") == "text" and block.get("text"):
|
|
290
|
+
on_progress(block["text"][:500])
|
|
291
|
+
elif block.get("type") == "tool_use":
|
|
292
|
+
on_progress(f"Using tool: {block.get('name', '?')}")
|
|
293
|
+
|
|
294
|
+
proc.wait()
|
|
295
|
+
finally:
|
|
296
|
+
timer.cancel()
|
|
297
|
+
# Ensure the process is not left orphaned on any exit path
|
|
298
|
+
if proc.poll() is None:
|
|
299
|
+
proc.kill()
|
|
300
|
+
proc.wait()
|
|
301
|
+
|
|
302
|
+
if timed_out.is_set():
|
|
303
|
+
return DispatchResult(
|
|
304
|
+
agent=agent_name,
|
|
305
|
+
success=False,
|
|
306
|
+
result="",
|
|
307
|
+
error=f"Agent '{agent_name}' timed out after {timeout}s. "
|
|
308
|
+
"Increase timeout in agents.yaml if the task needs more time.",
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
if result_data:
|
|
312
|
+
is_error = result_data.get("is_error", False)
|
|
313
|
+
return DispatchResult(
|
|
314
|
+
agent=agent_name,
|
|
315
|
+
success=not is_error,
|
|
316
|
+
result=result_data.get("result", ""),
|
|
317
|
+
session_id=result_data.get("session_id"),
|
|
318
|
+
cost_usd=result_data.get("total_cost_usd"),
|
|
319
|
+
duration_ms=result_data.get("duration_ms"),
|
|
320
|
+
num_turns=result_data.get("num_turns"),
|
|
321
|
+
error=result_data.get("result") if is_error else None,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
# Fallback: no result line received
|
|
325
|
+
stderr = proc.stderr.read() if proc.stderr else ""
|
|
326
|
+
return DispatchResult(
|
|
327
|
+
agent=agent_name,
|
|
328
|
+
success=False,
|
|
329
|
+
result="",
|
|
330
|
+
error=stderr.strip() or f"No result received (exit code {proc.returncode})",
|
|
331
|
+
)
|