agent-dispatch 0.3.0__tar.gz → 0.4.0__tar.gz
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-0.3.0 → agent_dispatch-0.4.0}/CHANGELOG.md +64 -1
- {agent_dispatch-0.3.0 → agent_dispatch-0.4.0}/PKG-INFO +84 -2
- {agent_dispatch-0.3.0 → agent_dispatch-0.4.0}/README.md +83 -1
- {agent_dispatch-0.3.0 → agent_dispatch-0.4.0}/pyproject.toml +1 -1
- {agent_dispatch-0.3.0 → agent_dispatch-0.4.0}/src/agent_dispatch/__init__.py +1 -1
- {agent_dispatch-0.3.0 → agent_dispatch-0.4.0}/src/agent_dispatch/cache.py +11 -7
- {agent_dispatch-0.3.0 → agent_dispatch-0.4.0}/src/agent_dispatch/config.py +49 -26
- agent_dispatch-0.4.0/src/agent_dispatch/jobs.py +210 -0
- {agent_dispatch-0.3.0 → agent_dispatch-0.4.0}/src/agent_dispatch/models.py +4 -0
- {agent_dispatch-0.3.0 → agent_dispatch-0.4.0}/src/agent_dispatch/runner.py +78 -9
- {agent_dispatch-0.3.0 → agent_dispatch-0.4.0}/src/agent_dispatch/server.py +567 -14
- agent_dispatch-0.4.0/tests/test_jobs.py +206 -0
- {agent_dispatch-0.3.0 → agent_dispatch-0.4.0}/tests/test_runner.py +150 -0
- {agent_dispatch-0.3.0 → agent_dispatch-0.4.0}/tests/test_server.py +639 -2
- {agent_dispatch-0.3.0 → agent_dispatch-0.4.0}/.github/dependabot.yml +0 -0
- {agent_dispatch-0.3.0 → agent_dispatch-0.4.0}/.github/workflows/ci.yml +0 -0
- {agent_dispatch-0.3.0 → agent_dispatch-0.4.0}/.github/workflows/publish.yml +0 -0
- {agent_dispatch-0.3.0 → agent_dispatch-0.4.0}/.gitignore +0 -0
- {agent_dispatch-0.3.0 → agent_dispatch-0.4.0}/LICENSE +0 -0
- {agent_dispatch-0.3.0 → agent_dispatch-0.4.0}/SECURITY.md +0 -0
- {agent_dispatch-0.3.0 → agent_dispatch-0.4.0}/agents.example.yaml +0 -0
- {agent_dispatch-0.3.0 → agent_dispatch-0.4.0}/assets/mascot.png +0 -0
- {agent_dispatch-0.3.0 → agent_dispatch-0.4.0}/src/agent_dispatch/cli.py +0 -0
- {agent_dispatch-0.3.0 → agent_dispatch-0.4.0}/tests/__init__.py +0 -0
- {agent_dispatch-0.3.0 → agent_dispatch-0.4.0}/tests/conftest.py +0 -0
- {agent_dispatch-0.3.0 → agent_dispatch-0.4.0}/tests/test_cache.py +0 -0
- {agent_dispatch-0.3.0 → agent_dispatch-0.4.0}/tests/test_cli.py +0 -0
- {agent_dispatch-0.3.0 → agent_dispatch-0.4.0}/tests/test_config.py +0 -0
- {agent_dispatch-0.3.0 → agent_dispatch-0.4.0}/tests/test_models.py +0 -0
|
@@ -7,6 +7,68 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.4.0] - 2026-05-15
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Result references — `dispatch(..., return_ref=True)` and per-item in
|
|
14
|
+
`dispatch_parallel` now return a compact `{ref, agent, success, size,
|
|
15
|
+
summary, summary_chars, cost_usd, ...}` payload instead of the full
|
|
16
|
+
result text. The full DispatchResult is persisted to disk (reusing the
|
|
17
|
+
async JobStore) and can be loaded on demand via the new
|
|
18
|
+
`fetch_result(ref, max_chars=0)` MCP tool. Saves caller context when
|
|
19
|
+
the result is large; the JSON parsed_result (small by nature) is still
|
|
20
|
+
inlined alongside the ref. fetch_result also works on any
|
|
21
|
+
`dispatch_async` job_id — the storage is shared.
|
|
22
|
+
- `JobStore.create_completed(...)` — persists an already-finished
|
|
23
|
+
DispatchResult as a Job in terminal state. Used by ref mode; future
|
|
24
|
+
iterations can use it for result archival.
|
|
25
|
+
- Structured JSON response support — `dispatch`, `dispatch_session`,
|
|
26
|
+
`dispatch_async`, `dispatch_stream`, and per-item in `dispatch_parallel`
|
|
27
|
+
now accept `response_format="json"`. When set, the runner appends a clear
|
|
28
|
+
"respond with a single JSON value, no prose, no fences" footer to the
|
|
29
|
+
prompt and attempts to parse the agent's response (tolerating ```json
|
|
30
|
+
fences). The parsed value lands in a new `DispatchResult.parsed_result`
|
|
31
|
+
field — `None` when not requested or unparseable (soft mode: parse
|
|
32
|
+
failure does NOT mark the dispatch as failed). Cache key now includes
|
|
33
|
+
`response_format` so JSON and text requests for the same task don't
|
|
34
|
+
collide.
|
|
35
|
+
- `list_agents` MCP tool now surfaces `mcp_servers`, `stacks`, and `dbs`
|
|
36
|
+
per agent (when present) — the same structured data `auto_describe`
|
|
37
|
+
already collects from `.mcp.json`, `Dockerfile`, `pyproject.toml`,
|
|
38
|
+
`package.json`, `Cargo.toml`, `go.mod`, `prisma/`, `alembic.ini`, etc.
|
|
39
|
+
Calling agents no longer need to dispatch a probe just to learn what
|
|
40
|
+
tools the target has.
|
|
41
|
+
- New `inspect_agent(name, preview_lines=40)` MCP tool — cheap detailed
|
|
42
|
+
lookup without a `claude` subprocess. Returns the agent's full config
|
|
43
|
+
fields (timeout, model, budget, permission_mode, tool lists), detected
|
|
44
|
+
MCP/stacks/DBs, plus short previews of `CLAUDE.md` and `README.md` so
|
|
45
|
+
the caller can confirm capabilities before spending a real dispatch.
|
|
46
|
+
- `config.collect_mcp_servers()`, `config.detect_stacks()`, and
|
|
47
|
+
`config.detect_dbs()` are now public helpers (the previous private
|
|
48
|
+
`_collect_mcp_servers` remains as an alias for compatibility).
|
|
49
|
+
- Async dispatch with a `job_id` pattern — five new MCP tools let calling
|
|
50
|
+
agents fire-and-forget long-running tasks without blocking their own tool
|
|
51
|
+
slot:
|
|
52
|
+
- `dispatch_async(agent, task, ...)` — start a dispatch in the background,
|
|
53
|
+
returns `{job_id, status: "pending", agent}` immediately.
|
|
54
|
+
- `dispatch_status(job_id)` — read the current state of a job without
|
|
55
|
+
blocking (pending / running / done / failed) including the
|
|
56
|
+
`DispatchResult` once complete.
|
|
57
|
+
- `dispatch_wait(job_id, timeout_seconds=60)` — block until terminal or
|
|
58
|
+
until the timeout fires (capped at 3600s). Returns the same shape as
|
|
59
|
+
`dispatch_status` plus `timed_out_waiting: true` on timeout — the job
|
|
60
|
+
keeps running and the caller can poll/wait again.
|
|
61
|
+
- `dispatch_jobs(status?, limit=50)` — list recent jobs as summaries,
|
|
62
|
+
optionally filtered by status (most recent first).
|
|
63
|
+
- `dispatch_gc(max_age_days=7)` — purge terminal jobs older than the
|
|
64
|
+
threshold. Pending and running jobs are never touched.
|
|
65
|
+
- Job state persists to disk as one JSON file per job under
|
|
66
|
+
`~/.config/agent-dispatch/jobs/` (override via `AGENT_DISPATCH_JOBS_DIR`).
|
|
67
|
+
Atomic writes via `os.replace()` so partial files never appear, and jobs
|
|
68
|
+
survive across server restarts (existing terminal jobs remain queryable,
|
|
69
|
+
in-flight jobs are abandoned on restart — to be addressed in a future
|
|
70
|
+
iteration with PID tracking).
|
|
71
|
+
|
|
10
72
|
## [0.3.0] - 2026-05-08
|
|
11
73
|
|
|
12
74
|
### Added
|
|
@@ -90,7 +152,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
90
152
|
- Dependabot for `pip` + `github-actions`, GitHub Actions pinned to
|
|
91
153
|
commit SHAs for supply-chain integrity.
|
|
92
154
|
|
|
93
|
-
[Unreleased]: https://github.com/ginkida/agent-dispatch/compare/v0.
|
|
155
|
+
[Unreleased]: https://github.com/ginkida/agent-dispatch/compare/v0.4.0...HEAD
|
|
156
|
+
[0.4.0]: https://github.com/ginkida/agent-dispatch/compare/v0.3.0...v0.4.0
|
|
94
157
|
[0.3.0]: https://github.com/ginkida/agent-dispatch/compare/v0.2.2...v0.3.0
|
|
95
158
|
[0.2.2]: https://github.com/ginkida/agent-dispatch/compare/v0.2.1...v0.2.2
|
|
96
159
|
[0.2.1]: https://github.com/ginkida/agent-dispatch/compare/v0.2.0...v0.2.1
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agent-dispatch
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: MCP server that lets Claude Code agents delegate tasks to agents in other project directories
|
|
5
5
|
Project-URL: Homepage, https://github.com/ginkida/agent-dispatch
|
|
6
6
|
Project-URL: Repository, https://github.com/ginkida/agent-dispatch
|
|
@@ -85,7 +85,7 @@ Done. Every Claude Code session now has access to all dispatch tools.
|
|
|
85
85
|
Lists all configured agents. **Call this first** to see what's available.
|
|
86
86
|
|
|
87
87
|
```json
|
|
88
|
-
// Response (permission fields shown only when
|
|
88
|
+
// Response (capability + permission fields shown only when populated)
|
|
89
89
|
[
|
|
90
90
|
{
|
|
91
91
|
"name": "infra",
|
|
@@ -94,12 +94,28 @@ Lists all configured agents. **Call this first** to see what's available.
|
|
|
94
94
|
"healthy": true,
|
|
95
95
|
"has_claude_md": true,
|
|
96
96
|
"has_mcp_config": true,
|
|
97
|
+
"mcp_servers": ["portainer", "postgres"],
|
|
98
|
+
"stacks": ["Python", "Docker"],
|
|
99
|
+
"dbs": ["Alembic"],
|
|
97
100
|
"permission_mode": "bypassPermissions",
|
|
98
101
|
"allowed_tools": ["Bash", "Read", "Grep"]
|
|
99
102
|
}
|
|
100
103
|
]
|
|
101
104
|
```
|
|
102
105
|
|
|
106
|
+
`mcp_servers`, `stacks`, and `dbs` are detected from the agent's project files (`.mcp.json`, `Dockerfile`, `pyproject.toml`, `Cargo.toml`, `prisma/`, `alembic.ini`, etc.) so callers can pick the right agent without dispatching a probe.
|
|
107
|
+
|
|
108
|
+
### `inspect_agent`
|
|
109
|
+
|
|
110
|
+
Cheap detailed lookup — reads the agent's files without spawning a `claude` session. Returns the full config (timeout, model, budget, permission mode, allowed/disallowed tools), detected MCP/stacks/DBs, plus short previews of `CLAUDE.md` and `README.md` when present.
|
|
111
|
+
|
|
112
|
+
| Parameter | Type | Required | Description |
|
|
113
|
+
|-----------|------|----------|-------------|
|
|
114
|
+
| `name` | string | yes | Agent name from `list_agents` |
|
|
115
|
+
| `preview_lines` | int | no | Max lines of CLAUDE.md/README.md (default 40, max 200, 0 disables) |
|
|
116
|
+
|
|
117
|
+
Use this **before** `dispatch_async`/`dispatch` to confirm an agent has the tools and context for your task — much cheaper than a probe dispatch.
|
|
118
|
+
|
|
103
119
|
### `dispatch`
|
|
104
120
|
|
|
105
121
|
One-shot task delegation. Results are cached — identical requests within TTL return instantly.
|
|
@@ -111,6 +127,9 @@ One-shot task delegation. Results are cached — identical requests within TTL r
|
|
|
111
127
|
| `context` | string | no | Extra context: error messages, code snippets, stack traces |
|
|
112
128
|
| `caller` | string | no | Your project/role — helps the agent understand who's asking |
|
|
113
129
|
| `goal` | string | no | Broader objective — helps the agent make better trade-offs |
|
|
130
|
+
| `response_format` | string | no | `"json"` to request a single JSON value; the parsed result lands in `parsed_result`. Empty = free-form text. |
|
|
131
|
+
| `return_ref` | bool | no | When `true`, returns just a `ref` + summary preview instead of the full result text. Use `fetch_result(ref)` to load the full text on demand. |
|
|
132
|
+
| `summary_chars` | int | no | Max chars of result text to include in the ref response (default 500). |
|
|
114
133
|
|
|
115
134
|
```json
|
|
116
135
|
// Response (success)
|
|
@@ -136,6 +155,18 @@ One-shot task delegation. Results are cached — identical requests within TTL r
|
|
|
136
155
|
|
|
137
156
|
**`error_type` values:** `permission` (tool/action denied), `timeout`, `recursion` (dispatch depth exceeded), `not_found` (missing directory or CLI), `cli_error` (other failures). Permission errors include an actionable hint.
|
|
138
157
|
|
|
158
|
+
**Structured JSON output:** pass `response_format="json"` to ask the agent for a single JSON value. The runner appends an instruction footer ("respond with a single valid JSON value, no fences, no prose") and on success parses the response — the parsed value lands in `parsed_result`. The raw text is always in `result`. Parse failures leave `parsed_result=None` but don't fail the dispatch (soft mode).
|
|
159
|
+
|
|
160
|
+
```json
|
|
161
|
+
// Response with response_format="json"
|
|
162
|
+
{
|
|
163
|
+
"agent": "infra",
|
|
164
|
+
"success": true,
|
|
165
|
+
"result": "{\"errors\": 3, \"first_at\": \"14:02\"}",
|
|
166
|
+
"parsed_result": {"errors": 3, "first_at": "14:02"}
|
|
167
|
+
}
|
|
168
|
+
```
|
|
169
|
+
|
|
139
170
|
**Always pass `caller` and `goal`** — the dispatched agent sees a structured prompt:
|
|
140
171
|
|
|
141
172
|
```markdown
|
|
@@ -290,6 +321,55 @@ Remove an agent from config.
|
|
|
290
321
|
|
|
291
322
|
View cache hit rate and size, or clear all cached results.
|
|
292
323
|
|
|
324
|
+
### Result references — `return_ref` + `fetch_result`
|
|
325
|
+
|
|
326
|
+
For dispatches whose result text is large (audits, log dumps, code searches), passing the full text back inflates the calling agent's context. Use `return_ref=True` to get just a small reference instead:
|
|
327
|
+
|
|
328
|
+
```
|
|
329
|
+
dispatch(agent="infra", task="audit every container", return_ref=True, summary_chars=200)
|
|
330
|
+
-> {"ref": "8f3a...e1", "agent": "infra", "success": true,
|
|
331
|
+
"size": 14823, "summary_chars": 200,
|
|
332
|
+
"summary": "Inspected 32 containers. Found 3 OOM kills in the last hour:\n- worker-3...",
|
|
333
|
+
"cost_usd": 0.08, "duration_ms": 9200}
|
|
334
|
+
|
|
335
|
+
// Later, when you actually need to read the result:
|
|
336
|
+
fetch_result(ref="8f3a...e1") -> full DispatchResult JSON
|
|
337
|
+
fetch_result(ref="8f3a...e1", max_chars=2000) -> truncated, plus {"truncated": true, "full_size": 14823}
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
Refs reuse the same storage as `dispatch_async` jobs (under `~/.config/agent-dispatch/jobs/`), so any `job_id` returned by `dispatch_async` is also a valid `ref` for `fetch_result`. `parsed_result` (when `response_format="json"` is set) is small and is always inlined directly in the ref response — no second fetch needed.
|
|
341
|
+
|
|
342
|
+
### Async dispatch — `dispatch_async`, `dispatch_status`, `dispatch_wait`, `dispatch_jobs`, `dispatch_gc`
|
|
343
|
+
|
|
344
|
+
When a dispatched task is going to take a while, you don't want to block your own tool slot for minutes. Async dispatch returns a `job_id` immediately and lets you check back when you're ready.
|
|
345
|
+
|
|
346
|
+
```
|
|
347
|
+
// 1. fire and forget
|
|
348
|
+
dispatch_async(agent="infra", task="audit every container log for OOM kills today")
|
|
349
|
+
-> {"job_id": "8f3a...e1", "status": "pending", "agent": "infra"}
|
|
350
|
+
|
|
351
|
+
// 2. do other work, then check progress (non-blocking)
|
|
352
|
+
dispatch_status(job_id="8f3a...e1")
|
|
353
|
+
-> {"id": "8f3a...e1", "status": "running", "started_at": 1730000123.4, ...}
|
|
354
|
+
|
|
355
|
+
// 3. or block until done (with a timeout cap)
|
|
356
|
+
dispatch_wait(job_id="8f3a...e1", timeout_seconds=120)
|
|
357
|
+
-> {"id": "8f3a...e1", "status": "done", "result": {"agent": "infra", "success": true, ...}}
|
|
358
|
+
|
|
359
|
+
// If the timeout fires, the job keeps running:
|
|
360
|
+
-> {"id": "...", "status": "running", "timed_out_waiting": true}
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
`dispatch_jobs(status?)` lists recent jobs as summaries (filter by `pending` / `running` / `done` / `failed` / `cancelled`). `dispatch_gc(max_age_days=7)` purges terminal jobs older than the threshold — pending and running jobs are never deleted.
|
|
364
|
+
|
|
365
|
+
Job state persists to disk at `~/.config/agent-dispatch/jobs/` (override with `AGENT_DISPATCH_JOBS_DIR`). One JSON file per job, atomic writes — safe to read or `ls` while jobs are in flight.
|
|
366
|
+
|
|
367
|
+
| When to use async | When to use `dispatch` |
|
|
368
|
+
|-------------------|------------------------|
|
|
369
|
+
| Long task (minutes) — you want to keep working | Short task — you need the answer right now |
|
|
370
|
+
| Several long tasks you'll collect later | Several short tasks → `dispatch_parallel` |
|
|
371
|
+
| Don't care about caching (each call is a fresh job) | Cached by default — identical requests are free |
|
|
372
|
+
|
|
293
373
|
### Error Responses
|
|
294
374
|
|
|
295
375
|
All tools return errors as:
|
|
@@ -308,6 +388,8 @@ All tools return errors as:
|
|
|
308
388
|
| Long task, want to see progress | `dispatch_stream` |
|
|
309
389
|
| Two agents need to collaborate | `dispatch_dialogue` |
|
|
310
390
|
| Need a combined summary from multiple agents | `dispatch_parallel` with `aggregate` |
|
|
391
|
+
| Long task — don't block your tool slot | `dispatch_async` + `dispatch_wait` |
|
|
392
|
+
| Check progress without blocking | `dispatch_status` |
|
|
311
393
|
|
|
312
394
|
## Configuration
|
|
313
395
|
|
|
@@ -55,7 +55,7 @@ Done. Every Claude Code session now has access to all dispatch tools.
|
|
|
55
55
|
Lists all configured agents. **Call this first** to see what's available.
|
|
56
56
|
|
|
57
57
|
```json
|
|
58
|
-
// Response (permission fields shown only when
|
|
58
|
+
// Response (capability + permission fields shown only when populated)
|
|
59
59
|
[
|
|
60
60
|
{
|
|
61
61
|
"name": "infra",
|
|
@@ -64,12 +64,28 @@ Lists all configured agents. **Call this first** to see what's available.
|
|
|
64
64
|
"healthy": true,
|
|
65
65
|
"has_claude_md": true,
|
|
66
66
|
"has_mcp_config": true,
|
|
67
|
+
"mcp_servers": ["portainer", "postgres"],
|
|
68
|
+
"stacks": ["Python", "Docker"],
|
|
69
|
+
"dbs": ["Alembic"],
|
|
67
70
|
"permission_mode": "bypassPermissions",
|
|
68
71
|
"allowed_tools": ["Bash", "Read", "Grep"]
|
|
69
72
|
}
|
|
70
73
|
]
|
|
71
74
|
```
|
|
72
75
|
|
|
76
|
+
`mcp_servers`, `stacks`, and `dbs` are detected from the agent's project files (`.mcp.json`, `Dockerfile`, `pyproject.toml`, `Cargo.toml`, `prisma/`, `alembic.ini`, etc.) so callers can pick the right agent without dispatching a probe.
|
|
77
|
+
|
|
78
|
+
### `inspect_agent`
|
|
79
|
+
|
|
80
|
+
Cheap detailed lookup — reads the agent's files without spawning a `claude` session. Returns the full config (timeout, model, budget, permission mode, allowed/disallowed tools), detected MCP/stacks/DBs, plus short previews of `CLAUDE.md` and `README.md` when present.
|
|
81
|
+
|
|
82
|
+
| Parameter | Type | Required | Description |
|
|
83
|
+
|-----------|------|----------|-------------|
|
|
84
|
+
| `name` | string | yes | Agent name from `list_agents` |
|
|
85
|
+
| `preview_lines` | int | no | Max lines of CLAUDE.md/README.md (default 40, max 200, 0 disables) |
|
|
86
|
+
|
|
87
|
+
Use this **before** `dispatch_async`/`dispatch` to confirm an agent has the tools and context for your task — much cheaper than a probe dispatch.
|
|
88
|
+
|
|
73
89
|
### `dispatch`
|
|
74
90
|
|
|
75
91
|
One-shot task delegation. Results are cached — identical requests within TTL return instantly.
|
|
@@ -81,6 +97,9 @@ One-shot task delegation. Results are cached — identical requests within TTL r
|
|
|
81
97
|
| `context` | string | no | Extra context: error messages, code snippets, stack traces |
|
|
82
98
|
| `caller` | string | no | Your project/role — helps the agent understand who's asking |
|
|
83
99
|
| `goal` | string | no | Broader objective — helps the agent make better trade-offs |
|
|
100
|
+
| `response_format` | string | no | `"json"` to request a single JSON value; the parsed result lands in `parsed_result`. Empty = free-form text. |
|
|
101
|
+
| `return_ref` | bool | no | When `true`, returns just a `ref` + summary preview instead of the full result text. Use `fetch_result(ref)` to load the full text on demand. |
|
|
102
|
+
| `summary_chars` | int | no | Max chars of result text to include in the ref response (default 500). |
|
|
84
103
|
|
|
85
104
|
```json
|
|
86
105
|
// Response (success)
|
|
@@ -106,6 +125,18 @@ One-shot task delegation. Results are cached — identical requests within TTL r
|
|
|
106
125
|
|
|
107
126
|
**`error_type` values:** `permission` (tool/action denied), `timeout`, `recursion` (dispatch depth exceeded), `not_found` (missing directory or CLI), `cli_error` (other failures). Permission errors include an actionable hint.
|
|
108
127
|
|
|
128
|
+
**Structured JSON output:** pass `response_format="json"` to ask the agent for a single JSON value. The runner appends an instruction footer ("respond with a single valid JSON value, no fences, no prose") and on success parses the response — the parsed value lands in `parsed_result`. The raw text is always in `result`. Parse failures leave `parsed_result=None` but don't fail the dispatch (soft mode).
|
|
129
|
+
|
|
130
|
+
```json
|
|
131
|
+
// Response with response_format="json"
|
|
132
|
+
{
|
|
133
|
+
"agent": "infra",
|
|
134
|
+
"success": true,
|
|
135
|
+
"result": "{\"errors\": 3, \"first_at\": \"14:02\"}",
|
|
136
|
+
"parsed_result": {"errors": 3, "first_at": "14:02"}
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
109
140
|
**Always pass `caller` and `goal`** — the dispatched agent sees a structured prompt:
|
|
110
141
|
|
|
111
142
|
```markdown
|
|
@@ -260,6 +291,55 @@ Remove an agent from config.
|
|
|
260
291
|
|
|
261
292
|
View cache hit rate and size, or clear all cached results.
|
|
262
293
|
|
|
294
|
+
### Result references — `return_ref` + `fetch_result`
|
|
295
|
+
|
|
296
|
+
For dispatches whose result text is large (audits, log dumps, code searches), passing the full text back inflates the calling agent's context. Use `return_ref=True` to get just a small reference instead:
|
|
297
|
+
|
|
298
|
+
```
|
|
299
|
+
dispatch(agent="infra", task="audit every container", return_ref=True, summary_chars=200)
|
|
300
|
+
-> {"ref": "8f3a...e1", "agent": "infra", "success": true,
|
|
301
|
+
"size": 14823, "summary_chars": 200,
|
|
302
|
+
"summary": "Inspected 32 containers. Found 3 OOM kills in the last hour:\n- worker-3...",
|
|
303
|
+
"cost_usd": 0.08, "duration_ms": 9200}
|
|
304
|
+
|
|
305
|
+
// Later, when you actually need to read the result:
|
|
306
|
+
fetch_result(ref="8f3a...e1") -> full DispatchResult JSON
|
|
307
|
+
fetch_result(ref="8f3a...e1", max_chars=2000) -> truncated, plus {"truncated": true, "full_size": 14823}
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
Refs reuse the same storage as `dispatch_async` jobs (under `~/.config/agent-dispatch/jobs/`), so any `job_id` returned by `dispatch_async` is also a valid `ref` for `fetch_result`. `parsed_result` (when `response_format="json"` is set) is small and is always inlined directly in the ref response — no second fetch needed.
|
|
311
|
+
|
|
312
|
+
### Async dispatch — `dispatch_async`, `dispatch_status`, `dispatch_wait`, `dispatch_jobs`, `dispatch_gc`
|
|
313
|
+
|
|
314
|
+
When a dispatched task is going to take a while, you don't want to block your own tool slot for minutes. Async dispatch returns a `job_id` immediately and lets you check back when you're ready.
|
|
315
|
+
|
|
316
|
+
```
|
|
317
|
+
// 1. fire and forget
|
|
318
|
+
dispatch_async(agent="infra", task="audit every container log for OOM kills today")
|
|
319
|
+
-> {"job_id": "8f3a...e1", "status": "pending", "agent": "infra"}
|
|
320
|
+
|
|
321
|
+
// 2. do other work, then check progress (non-blocking)
|
|
322
|
+
dispatch_status(job_id="8f3a...e1")
|
|
323
|
+
-> {"id": "8f3a...e1", "status": "running", "started_at": 1730000123.4, ...}
|
|
324
|
+
|
|
325
|
+
// 3. or block until done (with a timeout cap)
|
|
326
|
+
dispatch_wait(job_id="8f3a...e1", timeout_seconds=120)
|
|
327
|
+
-> {"id": "8f3a...e1", "status": "done", "result": {"agent": "infra", "success": true, ...}}
|
|
328
|
+
|
|
329
|
+
// If the timeout fires, the job keeps running:
|
|
330
|
+
-> {"id": "...", "status": "running", "timed_out_waiting": true}
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
`dispatch_jobs(status?)` lists recent jobs as summaries (filter by `pending` / `running` / `done` / `failed` / `cancelled`). `dispatch_gc(max_age_days=7)` purges terminal jobs older than the threshold — pending and running jobs are never deleted.
|
|
334
|
+
|
|
335
|
+
Job state persists to disk at `~/.config/agent-dispatch/jobs/` (override with `AGENT_DISPATCH_JOBS_DIR`). One JSON file per job, atomic writes — safe to read or `ls` while jobs are in flight.
|
|
336
|
+
|
|
337
|
+
| When to use async | When to use `dispatch` |
|
|
338
|
+
|-------------------|------------------------|
|
|
339
|
+
| Long task (minutes) — you want to keep working | Short task — you need the answer right now |
|
|
340
|
+
| Several long tasks you'll collect later | Several short tasks → `dispatch_parallel` |
|
|
341
|
+
| Don't care about caching (each call is a fresh job) | Cached by default — identical requests are free |
|
|
342
|
+
|
|
263
343
|
### Error Responses
|
|
264
344
|
|
|
265
345
|
All tools return errors as:
|
|
@@ -278,6 +358,8 @@ All tools return errors as:
|
|
|
278
358
|
| Long task, want to see progress | `dispatch_stream` |
|
|
279
359
|
| Two agents need to collaborate | `dispatch_dialogue` |
|
|
280
360
|
| Need a combined summary from multiple agents | `dispatch_parallel` with `aggregate` |
|
|
361
|
+
| Long task — don't block your tool slot | `dispatch_async` + `dispatch_wait` |
|
|
362
|
+
| Check progress without blocking | `dispatch_status` |
|
|
281
363
|
|
|
282
364
|
## Configuration
|
|
283
365
|
|
|
@@ -16,11 +16,11 @@ logger = logging.getLogger(__name__)
|
|
|
16
16
|
class DispatchCache:
|
|
17
17
|
"""Thread-safe TTL cache for dispatch results.
|
|
18
18
|
|
|
19
|
-
Keyed on (agent, task, context, caller, goal) — identical
|
|
20
|
-
the TTL window return the cached result without spawning a
|
|
21
|
-
|
|
22
|
-
part of the key: otherwise two
|
|
23
|
-
collide and return the wrong response.
|
|
19
|
+
Keyed on (agent, task, context, caller, goal, response_format) — identical
|
|
20
|
+
requests within the TTL window return the cached result without spawning a
|
|
21
|
+
new subprocess. ``caller``/``goal``/``response_format`` all affect the
|
|
22
|
+
prompt sent to the agent, so they must be part of the key: otherwise two
|
|
23
|
+
requests with different framing would collide and return the wrong response.
|
|
24
24
|
"""
|
|
25
25
|
|
|
26
26
|
def __init__(self, ttl: int = 300) -> None:
|
|
@@ -37,6 +37,7 @@ class DispatchCache:
|
|
|
37
37
|
context: str | None,
|
|
38
38
|
caller: str | None = None,
|
|
39
39
|
goal: str | None = None,
|
|
40
|
+
response_format: str | None = None,
|
|
40
41
|
) -> str:
|
|
41
42
|
canonical = json.dumps(
|
|
42
43
|
{
|
|
@@ -45,6 +46,7 @@ class DispatchCache:
|
|
|
45
46
|
"context": context or "",
|
|
46
47
|
"caller": caller or "",
|
|
47
48
|
"goal": goal or "",
|
|
49
|
+
"response_format": response_format or "",
|
|
48
50
|
},
|
|
49
51
|
sort_keys=True,
|
|
50
52
|
)
|
|
@@ -57,8 +59,9 @@ class DispatchCache:
|
|
|
57
59
|
context: str | None = None,
|
|
58
60
|
caller: str | None = None,
|
|
59
61
|
goal: str | None = None,
|
|
62
|
+
response_format: str | None = None,
|
|
60
63
|
) -> DispatchResult | None:
|
|
61
|
-
key = self._make_key(agent, task, context, caller, goal)
|
|
64
|
+
key = self._make_key(agent, task, context, caller, goal, response_format)
|
|
62
65
|
with self._lock:
|
|
63
66
|
entry = self._store.get(key)
|
|
64
67
|
if entry is None:
|
|
@@ -80,10 +83,11 @@ class DispatchCache:
|
|
|
80
83
|
context: str | None = None,
|
|
81
84
|
caller: str | None = None,
|
|
82
85
|
goal: str | None = None,
|
|
86
|
+
response_format: str | None = None,
|
|
83
87
|
) -> None:
|
|
84
88
|
if not result.success:
|
|
85
89
|
return # don't cache failures
|
|
86
|
-
key = self._make_key(agent, task, context, caller, goal)
|
|
90
|
+
key = self._make_key(agent, task, context, caller, goal, response_format)
|
|
87
91
|
with self._lock:
|
|
88
92
|
self._store[key] = (time.monotonic(), result)
|
|
89
93
|
|
|
@@ -60,6 +60,47 @@ def _collect_mcp_servers(directory: Path) -> list[str]:
|
|
|
60
60
|
return list(dict.fromkeys(servers)) # deduplicate, preserve order
|
|
61
61
|
|
|
62
62
|
|
|
63
|
+
# Public alias — callers outside config.py should use this name.
|
|
64
|
+
collect_mcp_servers = _collect_mcp_servers
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def detect_stacks(directory: Path) -> list[str]:
|
|
68
|
+
"""Detect language/runtime stacks present in a project directory.
|
|
69
|
+
|
|
70
|
+
Returns a deduplicated list of indicators like ["Python", "Docker"].
|
|
71
|
+
Used by auto_describe() and by the MCP list_agents tool to surface
|
|
72
|
+
capabilities cheaply (no claude subprocess needed).
|
|
73
|
+
"""
|
|
74
|
+
indicators: list[str] = []
|
|
75
|
+
if (directory / "Dockerfile").exists():
|
|
76
|
+
indicators.append("Docker")
|
|
77
|
+
if (directory / "docker-compose.yaml").exists() or (
|
|
78
|
+
directory / "docker-compose.yml"
|
|
79
|
+
).exists():
|
|
80
|
+
indicators.append("Docker Compose")
|
|
81
|
+
if (directory / "Cargo.toml").exists():
|
|
82
|
+
indicators.append("Rust")
|
|
83
|
+
if (directory / "go.mod").exists():
|
|
84
|
+
indicators.append("Go")
|
|
85
|
+
if (directory / "requirements.txt").exists() or (directory / "pyproject.toml").exists():
|
|
86
|
+
indicators.append("Python")
|
|
87
|
+
if (directory / "package.json").exists():
|
|
88
|
+
indicators.append("Node.js")
|
|
89
|
+
return indicators
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def detect_dbs(directory: Path) -> list[str]:
|
|
93
|
+
"""Detect database-related artifacts: Prisma, Alembic, generic migrations dir."""
|
|
94
|
+
indicators: list[str] = []
|
|
95
|
+
if (directory / "prisma").is_dir() or (directory / "schema.prisma").exists():
|
|
96
|
+
indicators.append("Prisma")
|
|
97
|
+
if (directory / "alembic").is_dir() or (directory / "alembic.ini").exists():
|
|
98
|
+
indicators.append("Alembic")
|
|
99
|
+
if (directory / "migrations").is_dir():
|
|
100
|
+
indicators.append("migrations")
|
|
101
|
+
return indicators
|
|
102
|
+
|
|
103
|
+
|
|
63
104
|
def auto_describe(directory: Path) -> str:
|
|
64
105
|
"""Generate agent description by reading project files.
|
|
65
106
|
|
|
@@ -122,32 +163,14 @@ def auto_describe(directory: Path) -> str:
|
|
|
122
163
|
if servers:
|
|
123
164
|
parts.append(f"MCP: {', '.join(servers)}")
|
|
124
165
|
|
|
125
|
-
# Stack indicators
|
|
126
|
-
|
|
127
|
-
if
|
|
128
|
-
|
|
129
|
-
if (directory / "docker-compose.yaml").exists() or (directory / "docker-compose.yml").exists():
|
|
130
|
-
indicators.append("Docker Compose")
|
|
131
|
-
if (directory / "Cargo.toml").exists():
|
|
132
|
-
indicators.append("Rust")
|
|
133
|
-
if (directory / "go.mod").exists():
|
|
134
|
-
indicators.append("Go")
|
|
135
|
-
if (directory / "requirements.txt").exists() or pyproject.exists():
|
|
136
|
-
indicators.append("Python")
|
|
137
|
-
if pkg_json.exists():
|
|
138
|
-
indicators.append("Node.js")
|
|
139
|
-
if indicators:
|
|
140
|
-
parts.append(f"Stack: {', '.join(indicators)}")
|
|
166
|
+
# Stack indicators (Python/Node/Rust/Go/Docker)
|
|
167
|
+
stacks = detect_stacks(directory)
|
|
168
|
+
if stacks:
|
|
169
|
+
parts.append(f"Stack: {', '.join(stacks)}")
|
|
141
170
|
|
|
142
|
-
# Database indicators
|
|
143
|
-
|
|
144
|
-
if
|
|
145
|
-
|
|
146
|
-
if (directory / "alembic").is_dir() or (directory / "alembic.ini").exists():
|
|
147
|
-
db_indicators.append("Alembic")
|
|
148
|
-
if (directory / "migrations").is_dir():
|
|
149
|
-
db_indicators.append("migrations")
|
|
150
|
-
if db_indicators:
|
|
151
|
-
parts.append(f"DB: {', '.join(db_indicators)}")
|
|
171
|
+
# Database indicators (Prisma/Alembic/migrations)
|
|
172
|
+
dbs = detect_dbs(directory)
|
|
173
|
+
if dbs:
|
|
174
|
+
parts.append(f"DB: {', '.join(dbs)}")
|
|
152
175
|
|
|
153
176
|
return " | ".join(parts) if parts else f"Agent in {directory.name}"
|