agent-dispatch 0.2.2__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.
Files changed (29) hide show
  1. agent_dispatch-0.4.0/CHANGELOG.md +161 -0
  2. {agent_dispatch-0.2.2 → agent_dispatch-0.4.0}/PKG-INFO +90 -3
  3. {agent_dispatch-0.2.2 → agent_dispatch-0.4.0}/README.md +89 -2
  4. {agent_dispatch-0.2.2 → agent_dispatch-0.4.0}/pyproject.toml +1 -1
  5. {agent_dispatch-0.2.2 → agent_dispatch-0.4.0}/src/agent_dispatch/__init__.py +1 -1
  6. {agent_dispatch-0.2.2 → agent_dispatch-0.4.0}/src/agent_dispatch/cache.py +35 -7
  7. {agent_dispatch-0.2.2 → agent_dispatch-0.4.0}/src/agent_dispatch/cli.py +189 -4
  8. {agent_dispatch-0.2.2 → agent_dispatch-0.4.0}/src/agent_dispatch/config.py +49 -26
  9. agent_dispatch-0.4.0/src/agent_dispatch/jobs.py +210 -0
  10. {agent_dispatch-0.2.2 → agent_dispatch-0.4.0}/src/agent_dispatch/models.py +4 -0
  11. {agent_dispatch-0.2.2 → agent_dispatch-0.4.0}/src/agent_dispatch/runner.py +78 -9
  12. {agent_dispatch-0.2.2 → agent_dispatch-0.4.0}/src/agent_dispatch/server.py +584 -16
  13. {agent_dispatch-0.2.2 → agent_dispatch-0.4.0}/tests/test_cache.py +42 -0
  14. {agent_dispatch-0.2.2 → agent_dispatch-0.4.0}/tests/test_cli.py +335 -0
  15. agent_dispatch-0.4.0/tests/test_jobs.py +206 -0
  16. {agent_dispatch-0.2.2 → agent_dispatch-0.4.0}/tests/test_runner.py +150 -0
  17. {agent_dispatch-0.2.2 → agent_dispatch-0.4.0}/tests/test_server.py +744 -2
  18. {agent_dispatch-0.2.2 → agent_dispatch-0.4.0}/.github/dependabot.yml +0 -0
  19. {agent_dispatch-0.2.2 → agent_dispatch-0.4.0}/.github/workflows/ci.yml +0 -0
  20. {agent_dispatch-0.2.2 → agent_dispatch-0.4.0}/.github/workflows/publish.yml +0 -0
  21. {agent_dispatch-0.2.2 → agent_dispatch-0.4.0}/.gitignore +0 -0
  22. {agent_dispatch-0.2.2 → agent_dispatch-0.4.0}/LICENSE +0 -0
  23. {agent_dispatch-0.2.2 → agent_dispatch-0.4.0}/SECURITY.md +0 -0
  24. {agent_dispatch-0.2.2 → agent_dispatch-0.4.0}/agents.example.yaml +0 -0
  25. {agent_dispatch-0.2.2 → agent_dispatch-0.4.0}/assets/mascot.png +0 -0
  26. {agent_dispatch-0.2.2 → agent_dispatch-0.4.0}/tests/__init__.py +0 -0
  27. {agent_dispatch-0.2.2 → agent_dispatch-0.4.0}/tests/conftest.py +0 -0
  28. {agent_dispatch-0.2.2 → agent_dispatch-0.4.0}/tests/test_config.py +0 -0
  29. {agent_dispatch-0.2.2 → agent_dispatch-0.4.0}/tests/test_models.py +0 -0
@@ -0,0 +1,161 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
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
+
72
+ ## [0.3.0] - 2026-05-08
73
+
74
+ ### Added
75
+ - `agent-dispatch doctor` CLI command — diagnoses installation issues:
76
+ checks `claude` CLI on PATH, `agent-dispatch` on PATH, config validity,
77
+ MCP registration with Claude Code, and per-agent directory health.
78
+ Exits non-zero if any blocking issue is found.
79
+ - `agent-dispatch describe <name>` CLI command — show one agent's full
80
+ configuration: directory, description, timeout, model, budget, permission
81
+ mode, tri-state tool fields (`(inherit defaults)` vs `(none — explicit
82
+ override)` vs explicit list), and which project files would be inherited.
83
+ - `--stream` flag for `agent-dispatch test` — surfaces live progress
84
+ (assistant text + tool use) while the agent works, useful for long
85
+ tasks where you'd otherwise see nothing until completion.
86
+
87
+ ### Fixed
88
+ - `list_agents` MCP tool no longer crashes the entire response when one
89
+ agent's directory is unreadable (`PermissionError`, network FS hiccup,
90
+ etc.). The bad agent now reports `healthy: "UNREADABLE"` and the rest
91
+ of the listing succeeds — matching the documented response shape.
92
+ - Dispatch cache key now includes `caller` and `goal`. Previously two
93
+ requests with the same `(agent, task, context)` but different framing
94
+ (e.g. `caller="frontend"` vs `caller="backend"`) would collide and the
95
+ second request would receive the cached response from the first — even
96
+ though the structured prompt sent to Claude is materially different.
97
+
98
+ ## [0.2.2] - 2026-04-17
99
+
100
+ ### Fixed
101
+ - `agent-dispatch list` now distinguishes `allowed_tools: None` (inherit
102
+ from settings defaults) from `allowed_tools: []` (explicitly no tools).
103
+ Previously both were rendered identically.
104
+
105
+ ## [0.2.1] - 2026-04-17
106
+
107
+ ### Fixed
108
+ - 13 bugs across the runner, server, CLI, config, and models:
109
+ - Runner: defensive coercion in `_classify_error` for non-string inputs;
110
+ fallback messages when `is_error=True` produces empty `result`;
111
+ correct error_type classification on plain-text stdout fallbacks;
112
+ orphan subprocess cleanup on stream exit paths.
113
+ - Server: up-front validation in `dispatch_parallel` (rejects bad items
114
+ before any dispatch runs); `dispatch_dialogue` surfaces per-turn errors;
115
+ `cache_stats` evicts expired entries before reporting.
116
+ - CLI: friendly error messages on malformed YAML / invalid schema;
117
+ `list` handles `OSError` from unreachable directories;
118
+ sentinel patterns for `update` to clear fields (`"none"` / `""`).
119
+ - Config: deduplication when collecting MCP servers from multiple paths.
120
+ - Models: tighter validation bounds (`ge=0`, `ge=1`).
121
+
122
+ ## [0.2.0] - 2026-04-16
123
+
124
+ ### Added
125
+ - Error classification — `DispatchResult.error_type` now reports
126
+ `permission`, `timeout`, `recursion`, `not_found`, or `cli_error`.
127
+ Permission errors include an actionable hint with suggested fixes.
128
+ - Permission management — agents and global settings support
129
+ `permission_mode`, `allowed_tools`, and `disallowed_tools`. Tool lists
130
+ use tri-state semantics: `None` inherits from defaults, `[]` overrides
131
+ to "no tools", a list specifies the allowed/disallowed set.
132
+ - `update_agent` MCP tool — modify an existing agent's configuration
133
+ without remove + re-add. CLI parity via `agent-dispatch update`.
134
+ - CLI tests for `init` and `test` commands.
135
+
136
+ ## [0.1.0] - 2026-04-10
137
+
138
+ ### Added
139
+ - Initial release.
140
+ - 11 MCP tools: `list_agents`, `add_agent`, `remove_agent`, `dispatch`,
141
+ `dispatch_session`, `dispatch_parallel` (with optional aggregation),
142
+ `dispatch_stream`, `dispatch_dialogue`, `cache_stats`, `cache_clear`.
143
+ - CLI: `init`, `add`, `remove`, `list`, `test`, `serve`.
144
+ - Recursion protection via `AGENT_DISPATCH_DEPTH` env var.
145
+ - In-memory TTL cache (thread-safe).
146
+ - Concurrency control via `asyncio.Semaphore` (default: 5 parallel
147
+ `claude -p` processes).
148
+ - Auto-description from `CLAUDE.md`, `README.md`, `pyproject.toml`,
149
+ `package.json`, `.mcp.json`, and stack/DB indicators.
150
+ - PyPI publishing via Trusted Publisher (OIDC).
151
+ - CI matrix on Python 3.10, 3.11, 3.12, 3.13.
152
+ - Dependabot for `pip` + `github-actions`, GitHub Actions pinned to
153
+ commit SHAs for supply-chain integrity.
154
+
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
157
+ [0.3.0]: https://github.com/ginkida/agent-dispatch/compare/v0.2.2...v0.3.0
158
+ [0.2.2]: https://github.com/ginkida/agent-dispatch/compare/v0.2.1...v0.2.2
159
+ [0.2.1]: https://github.com/ginkida/agent-dispatch/compare/v0.2.0...v0.2.1
160
+ [0.2.0]: https://github.com/ginkida/agent-dispatch/compare/v0.1.0...v0.2.0
161
+ [0.1.0]: https://github.com/ginkida/agent-dispatch/releases/tag/v0.1.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-dispatch
3
- Version: 0.2.2
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
@@ -62,6 +62,9 @@ agent-dispatch test infra
62
62
 
63
63
  # If agents hit permission errors, grant tool access:
64
64
  agent-dispatch update infra --permission-mode bypassPermissions
65
+
66
+ # If something doesn't work, run the diagnostic:
67
+ agent-dispatch doctor
65
68
  ```
66
69
 
67
70
  Done. Every Claude Code session now has access to all dispatch tools.
@@ -82,7 +85,7 @@ Done. Every Claude Code session now has access to all dispatch tools.
82
85
  Lists all configured agents. **Call this first** to see what's available.
83
86
 
84
87
  ```json
85
- // Response (permission fields shown only when configured)
88
+ // Response (capability + permission fields shown only when populated)
86
89
  [
87
90
  {
88
91
  "name": "infra",
@@ -91,12 +94,28 @@ Lists all configured agents. **Call this first** to see what's available.
91
94
  "healthy": true,
92
95
  "has_claude_md": true,
93
96
  "has_mcp_config": true,
97
+ "mcp_servers": ["portainer", "postgres"],
98
+ "stacks": ["Python", "Docker"],
99
+ "dbs": ["Alembic"],
94
100
  "permission_mode": "bypassPermissions",
95
101
  "allowed_tools": ["Bash", "Read", "Grep"]
96
102
  }
97
103
  ]
98
104
  ```
99
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
+
100
119
  ### `dispatch`
101
120
 
102
121
  One-shot task delegation. Results are cached — identical requests within TTL return instantly.
@@ -108,6 +127,9 @@ One-shot task delegation. Results are cached — identical requests within TTL r
108
127
  | `context` | string | no | Extra context: error messages, code snippets, stack traces |
109
128
  | `caller` | string | no | Your project/role — helps the agent understand who's asking |
110
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). |
111
133
 
112
134
  ```json
113
135
  // Response (success)
@@ -133,6 +155,18 @@ One-shot task delegation. Results are cached — identical requests within TTL r
133
155
 
134
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.
135
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
+
136
170
  **Always pass `caller` and `goal`** — the dispatched agent sees a structured prompt:
137
171
 
138
172
  ```markdown
@@ -287,6 +321,55 @@ Remove an agent from config.
287
321
 
288
322
  View cache hit rate and size, or clear all cached results.
289
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
+
290
373
  ### Error Responses
291
374
 
292
375
  All tools return errors as:
@@ -305,6 +388,8 @@ All tools return errors as:
305
388
  | Long task, want to see progress | `dispatch_stream` |
306
389
  | Two agents need to collaborate | `dispatch_dialogue` |
307
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` |
308
393
 
309
394
  ## Configuration
310
395
 
@@ -389,7 +474,9 @@ agent-dispatch MCP server
389
474
  | `agent-dispatch update <name>` | Update agent config (permissions, timeout, model, etc.) |
390
475
  | `agent-dispatch remove <name>` | Remove an agent |
391
476
  | `agent-dispatch list` | List agents with health status and permissions |
392
- | `agent-dispatch test <name> [task]` | Test an agent with a dispatch |
477
+ | `agent-dispatch describe <name>` | Show full configuration for one agent (tri-state tools, project files) |
478
+ | `agent-dispatch test <name> [task] [--stream]` | Test an agent with a dispatch (`--stream` for live progress) |
479
+ | `agent-dispatch doctor` | Diagnose installation: claude CLI, MCP registration, agent health |
393
480
  | `agent-dispatch serve` | Start MCP server (stdio, used by Claude Code) |
394
481
 
395
482
  ## Requirements
@@ -32,6 +32,9 @@ agent-dispatch test infra
32
32
 
33
33
  # If agents hit permission errors, grant tool access:
34
34
  agent-dispatch update infra --permission-mode bypassPermissions
35
+
36
+ # If something doesn't work, run the diagnostic:
37
+ agent-dispatch doctor
35
38
  ```
36
39
 
37
40
  Done. Every Claude Code session now has access to all dispatch tools.
@@ -52,7 +55,7 @@ Done. Every Claude Code session now has access to all dispatch tools.
52
55
  Lists all configured agents. **Call this first** to see what's available.
53
56
 
54
57
  ```json
55
- // Response (permission fields shown only when configured)
58
+ // Response (capability + permission fields shown only when populated)
56
59
  [
57
60
  {
58
61
  "name": "infra",
@@ -61,12 +64,28 @@ Lists all configured agents. **Call this first** to see what's available.
61
64
  "healthy": true,
62
65
  "has_claude_md": true,
63
66
  "has_mcp_config": true,
67
+ "mcp_servers": ["portainer", "postgres"],
68
+ "stacks": ["Python", "Docker"],
69
+ "dbs": ["Alembic"],
64
70
  "permission_mode": "bypassPermissions",
65
71
  "allowed_tools": ["Bash", "Read", "Grep"]
66
72
  }
67
73
  ]
68
74
  ```
69
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
+
70
89
  ### `dispatch`
71
90
 
72
91
  One-shot task delegation. Results are cached — identical requests within TTL return instantly.
@@ -78,6 +97,9 @@ One-shot task delegation. Results are cached — identical requests within TTL r
78
97
  | `context` | string | no | Extra context: error messages, code snippets, stack traces |
79
98
  | `caller` | string | no | Your project/role — helps the agent understand who's asking |
80
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). |
81
103
 
82
104
  ```json
83
105
  // Response (success)
@@ -103,6 +125,18 @@ One-shot task delegation. Results are cached — identical requests within TTL r
103
125
 
104
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.
105
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
+
106
140
  **Always pass `caller` and `goal`** — the dispatched agent sees a structured prompt:
107
141
 
108
142
  ```markdown
@@ -257,6 +291,55 @@ Remove an agent from config.
257
291
 
258
292
  View cache hit rate and size, or clear all cached results.
259
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
+
260
343
  ### Error Responses
261
344
 
262
345
  All tools return errors as:
@@ -275,6 +358,8 @@ All tools return errors as:
275
358
  | Long task, want to see progress | `dispatch_stream` |
276
359
  | Two agents need to collaborate | `dispatch_dialogue` |
277
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` |
278
363
 
279
364
  ## Configuration
280
365
 
@@ -359,7 +444,9 @@ agent-dispatch MCP server
359
444
  | `agent-dispatch update <name>` | Update agent config (permissions, timeout, model, etc.) |
360
445
  | `agent-dispatch remove <name>` | Remove an agent |
361
446
  | `agent-dispatch list` | List agents with health status and permissions |
362
- | `agent-dispatch test <name> [task]` | Test an agent with a dispatch |
447
+ | `agent-dispatch describe <name>` | Show full configuration for one agent (tri-state tools, project files) |
448
+ | `agent-dispatch test <name> [task] [--stream]` | Test an agent with a dispatch (`--stream` for live progress) |
449
+ | `agent-dispatch doctor` | Diagnose installation: claude CLI, MCP registration, agent health |
363
450
  | `agent-dispatch serve` | Start MCP server (stdio, used by Claude Code) |
364
451
 
365
452
  ## Requirements
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agent-dispatch"
3
- version = "0.2.2"
3
+ version = "0.4.0"
4
4
  description = "MCP server that lets Claude Code agents delegate tasks to agents in other project directories"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,3 +1,3 @@
1
1
  """agent-dispatch: Delegate tasks between Claude Code agents across projects."""
2
2
 
3
- __version__ = "0.2.2"
3
+ __version__ = "0.4.0"
@@ -16,8 +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) — identical requests within the TTL
20
- window return the cached result without spawning a new subprocess.
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.
21
24
  """
22
25
 
23
26
  def __init__(self, ttl: int = 300) -> None:
@@ -28,15 +31,37 @@ class DispatchCache:
28
31
  self._misses = 0
29
32
 
30
33
  @staticmethod
31
- def _make_key(agent: str, task: str, context: str | None) -> str:
34
+ def _make_key(
35
+ agent: str,
36
+ task: str,
37
+ context: str | None,
38
+ caller: str | None = None,
39
+ goal: str | None = None,
40
+ response_format: str | None = None,
41
+ ) -> str:
32
42
  canonical = json.dumps(
33
- {"agent": agent, "task": task, "context": context or ""},
43
+ {
44
+ "agent": agent,
45
+ "task": task,
46
+ "context": context or "",
47
+ "caller": caller or "",
48
+ "goal": goal or "",
49
+ "response_format": response_format or "",
50
+ },
34
51
  sort_keys=True,
35
52
  )
36
53
  return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
37
54
 
38
- def get(self, agent: str, task: str, context: str | None = None) -> DispatchResult | None:
39
- key = self._make_key(agent, task, context)
55
+ def get(
56
+ self,
57
+ agent: str,
58
+ task: str,
59
+ context: str | None = None,
60
+ caller: str | None = None,
61
+ goal: str | None = None,
62
+ response_format: str | None = None,
63
+ ) -> DispatchResult | None:
64
+ key = self._make_key(agent, task, context, caller, goal, response_format)
40
65
  with self._lock:
41
66
  entry = self._store.get(key)
42
67
  if entry is None:
@@ -56,10 +81,13 @@ class DispatchCache:
56
81
  task: str,
57
82
  result: DispatchResult,
58
83
  context: str | None = None,
84
+ caller: str | None = None,
85
+ goal: str | None = None,
86
+ response_format: str | None = None,
59
87
  ) -> None:
60
88
  if not result.success:
61
89
  return # don't cache failures
62
- key = self._make_key(agent, task, context)
90
+ key = self._make_key(agent, task, context, caller, goal, response_format)
63
91
  with self._lock:
64
92
  self._store[key] = (time.monotonic(), result)
65
93