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.
@@ -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
+ )