agent-dispatch 0.2.1__tar.gz → 0.3.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/CHANGELOG.md +98 -0
- {agent_dispatch-0.2.1 → agent_dispatch-0.3.0}/PKG-INFO +7 -2
- {agent_dispatch-0.2.1 → agent_dispatch-0.3.0}/README.md +6 -1
- {agent_dispatch-0.2.1 → agent_dispatch-0.3.0}/pyproject.toml +1 -1
- {agent_dispatch-0.2.1 → agent_dispatch-0.3.0}/src/agent_dispatch/__init__.py +1 -1
- {agent_dispatch-0.2.1 → agent_dispatch-0.3.0}/src/agent_dispatch/cache.py +31 -7
- {agent_dispatch-0.2.1 → agent_dispatch-0.3.0}/src/agent_dispatch/cli.py +195 -8
- {agent_dispatch-0.2.1 → agent_dispatch-0.3.0}/src/agent_dispatch/server.py +27 -11
- {agent_dispatch-0.2.1 → agent_dispatch-0.3.0}/tests/test_cache.py +42 -0
- agent_dispatch-0.3.0/tests/test_cli.py +750 -0
- {agent_dispatch-0.2.1 → agent_dispatch-0.3.0}/tests/test_server.py +126 -0
- agent_dispatch-0.2.1/tests/test_cli.py +0 -381
- {agent_dispatch-0.2.1 → agent_dispatch-0.3.0}/.github/dependabot.yml +0 -0
- {agent_dispatch-0.2.1 → agent_dispatch-0.3.0}/.github/workflows/ci.yml +0 -0
- {agent_dispatch-0.2.1 → agent_dispatch-0.3.0}/.github/workflows/publish.yml +0 -0
- {agent_dispatch-0.2.1 → agent_dispatch-0.3.0}/.gitignore +0 -0
- {agent_dispatch-0.2.1 → agent_dispatch-0.3.0}/LICENSE +0 -0
- {agent_dispatch-0.2.1 → agent_dispatch-0.3.0}/SECURITY.md +0 -0
- {agent_dispatch-0.2.1 → agent_dispatch-0.3.0}/agents.example.yaml +0 -0
- {agent_dispatch-0.2.1 → agent_dispatch-0.3.0}/assets/mascot.png +0 -0
- {agent_dispatch-0.2.1 → agent_dispatch-0.3.0}/src/agent_dispatch/config.py +0 -0
- {agent_dispatch-0.2.1 → agent_dispatch-0.3.0}/src/agent_dispatch/models.py +0 -0
- {agent_dispatch-0.2.1 → agent_dispatch-0.3.0}/src/agent_dispatch/runner.py +0 -0
- {agent_dispatch-0.2.1 → agent_dispatch-0.3.0}/tests/__init__.py +0 -0
- {agent_dispatch-0.2.1 → agent_dispatch-0.3.0}/tests/conftest.py +0 -0
- {agent_dispatch-0.2.1 → agent_dispatch-0.3.0}/tests/test_config.py +0 -0
- {agent_dispatch-0.2.1 → agent_dispatch-0.3.0}/tests/test_models.py +0 -0
- {agent_dispatch-0.2.1 → agent_dispatch-0.3.0}/tests/test_runner.py +0 -0
|
@@ -0,0 +1,98 @@
|
|
|
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.3.0] - 2026-05-08
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- `agent-dispatch doctor` CLI command — diagnoses installation issues:
|
|
14
|
+
checks `claude` CLI on PATH, `agent-dispatch` on PATH, config validity,
|
|
15
|
+
MCP registration with Claude Code, and per-agent directory health.
|
|
16
|
+
Exits non-zero if any blocking issue is found.
|
|
17
|
+
- `agent-dispatch describe <name>` CLI command — show one agent's full
|
|
18
|
+
configuration: directory, description, timeout, model, budget, permission
|
|
19
|
+
mode, tri-state tool fields (`(inherit defaults)` vs `(none — explicit
|
|
20
|
+
override)` vs explicit list), and which project files would be inherited.
|
|
21
|
+
- `--stream` flag for `agent-dispatch test` — surfaces live progress
|
|
22
|
+
(assistant text + tool use) while the agent works, useful for long
|
|
23
|
+
tasks where you'd otherwise see nothing until completion.
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
- `list_agents` MCP tool no longer crashes the entire response when one
|
|
27
|
+
agent's directory is unreadable (`PermissionError`, network FS hiccup,
|
|
28
|
+
etc.). The bad agent now reports `healthy: "UNREADABLE"` and the rest
|
|
29
|
+
of the listing succeeds — matching the documented response shape.
|
|
30
|
+
- Dispatch cache key now includes `caller` and `goal`. Previously two
|
|
31
|
+
requests with the same `(agent, task, context)` but different framing
|
|
32
|
+
(e.g. `caller="frontend"` vs `caller="backend"`) would collide and the
|
|
33
|
+
second request would receive the cached response from the first — even
|
|
34
|
+
though the structured prompt sent to Claude is materially different.
|
|
35
|
+
|
|
36
|
+
## [0.2.2] - 2026-04-17
|
|
37
|
+
|
|
38
|
+
### Fixed
|
|
39
|
+
- `agent-dispatch list` now distinguishes `allowed_tools: None` (inherit
|
|
40
|
+
from settings defaults) from `allowed_tools: []` (explicitly no tools).
|
|
41
|
+
Previously both were rendered identically.
|
|
42
|
+
|
|
43
|
+
## [0.2.1] - 2026-04-17
|
|
44
|
+
|
|
45
|
+
### Fixed
|
|
46
|
+
- 13 bugs across the runner, server, CLI, config, and models:
|
|
47
|
+
- Runner: defensive coercion in `_classify_error` for non-string inputs;
|
|
48
|
+
fallback messages when `is_error=True` produces empty `result`;
|
|
49
|
+
correct error_type classification on plain-text stdout fallbacks;
|
|
50
|
+
orphan subprocess cleanup on stream exit paths.
|
|
51
|
+
- Server: up-front validation in `dispatch_parallel` (rejects bad items
|
|
52
|
+
before any dispatch runs); `dispatch_dialogue` surfaces per-turn errors;
|
|
53
|
+
`cache_stats` evicts expired entries before reporting.
|
|
54
|
+
- CLI: friendly error messages on malformed YAML / invalid schema;
|
|
55
|
+
`list` handles `OSError` from unreachable directories;
|
|
56
|
+
sentinel patterns for `update` to clear fields (`"none"` / `""`).
|
|
57
|
+
- Config: deduplication when collecting MCP servers from multiple paths.
|
|
58
|
+
- Models: tighter validation bounds (`ge=0`, `ge=1`).
|
|
59
|
+
|
|
60
|
+
## [0.2.0] - 2026-04-16
|
|
61
|
+
|
|
62
|
+
### Added
|
|
63
|
+
- Error classification — `DispatchResult.error_type` now reports
|
|
64
|
+
`permission`, `timeout`, `recursion`, `not_found`, or `cli_error`.
|
|
65
|
+
Permission errors include an actionable hint with suggested fixes.
|
|
66
|
+
- Permission management — agents and global settings support
|
|
67
|
+
`permission_mode`, `allowed_tools`, and `disallowed_tools`. Tool lists
|
|
68
|
+
use tri-state semantics: `None` inherits from defaults, `[]` overrides
|
|
69
|
+
to "no tools", a list specifies the allowed/disallowed set.
|
|
70
|
+
- `update_agent` MCP tool — modify an existing agent's configuration
|
|
71
|
+
without remove + re-add. CLI parity via `agent-dispatch update`.
|
|
72
|
+
- CLI tests for `init` and `test` commands.
|
|
73
|
+
|
|
74
|
+
## [0.1.0] - 2026-04-10
|
|
75
|
+
|
|
76
|
+
### Added
|
|
77
|
+
- Initial release.
|
|
78
|
+
- 11 MCP tools: `list_agents`, `add_agent`, `remove_agent`, `dispatch`,
|
|
79
|
+
`dispatch_session`, `dispatch_parallel` (with optional aggregation),
|
|
80
|
+
`dispatch_stream`, `dispatch_dialogue`, `cache_stats`, `cache_clear`.
|
|
81
|
+
- CLI: `init`, `add`, `remove`, `list`, `test`, `serve`.
|
|
82
|
+
- Recursion protection via `AGENT_DISPATCH_DEPTH` env var.
|
|
83
|
+
- In-memory TTL cache (thread-safe).
|
|
84
|
+
- Concurrency control via `asyncio.Semaphore` (default: 5 parallel
|
|
85
|
+
`claude -p` processes).
|
|
86
|
+
- Auto-description from `CLAUDE.md`, `README.md`, `pyproject.toml`,
|
|
87
|
+
`package.json`, `.mcp.json`, and stack/DB indicators.
|
|
88
|
+
- PyPI publishing via Trusted Publisher (OIDC).
|
|
89
|
+
- CI matrix on Python 3.10, 3.11, 3.12, 3.13.
|
|
90
|
+
- Dependabot for `pip` + `github-actions`, GitHub Actions pinned to
|
|
91
|
+
commit SHAs for supply-chain integrity.
|
|
92
|
+
|
|
93
|
+
[Unreleased]: https://github.com/ginkida/agent-dispatch/compare/v0.3.0...HEAD
|
|
94
|
+
[0.3.0]: https://github.com/ginkida/agent-dispatch/compare/v0.2.2...v0.3.0
|
|
95
|
+
[0.2.2]: https://github.com/ginkida/agent-dispatch/compare/v0.2.1...v0.2.2
|
|
96
|
+
[0.2.1]: https://github.com/ginkida/agent-dispatch/compare/v0.2.0...v0.2.1
|
|
97
|
+
[0.2.0]: https://github.com/ginkida/agent-dispatch/compare/v0.1.0...v0.2.0
|
|
98
|
+
[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.
|
|
3
|
+
Version: 0.3.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.
|
|
@@ -389,7 +392,9 @@ agent-dispatch MCP server
|
|
|
389
392
|
| `agent-dispatch update <name>` | Update agent config (permissions, timeout, model, etc.) |
|
|
390
393
|
| `agent-dispatch remove <name>` | Remove an agent |
|
|
391
394
|
| `agent-dispatch list` | List agents with health status and permissions |
|
|
392
|
-
| `agent-dispatch
|
|
395
|
+
| `agent-dispatch describe <name>` | Show full configuration for one agent (tri-state tools, project files) |
|
|
396
|
+
| `agent-dispatch test <name> [task] [--stream]` | Test an agent with a dispatch (`--stream` for live progress) |
|
|
397
|
+
| `agent-dispatch doctor` | Diagnose installation: claude CLI, MCP registration, agent health |
|
|
393
398
|
| `agent-dispatch serve` | Start MCP server (stdio, used by Claude Code) |
|
|
394
399
|
|
|
395
400
|
## 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.
|
|
@@ -359,7 +362,9 @@ agent-dispatch MCP server
|
|
|
359
362
|
| `agent-dispatch update <name>` | Update agent config (permissions, timeout, model, etc.) |
|
|
360
363
|
| `agent-dispatch remove <name>` | Remove an agent |
|
|
361
364
|
| `agent-dispatch list` | List agents with health status and permissions |
|
|
362
|
-
| `agent-dispatch
|
|
365
|
+
| `agent-dispatch describe <name>` | Show full configuration for one agent (tri-state tools, project files) |
|
|
366
|
+
| `agent-dispatch test <name> [task] [--stream]` | Test an agent with a dispatch (`--stream` for live progress) |
|
|
367
|
+
| `agent-dispatch doctor` | Diagnose installation: claude CLI, MCP registration, agent health |
|
|
363
368
|
| `agent-dispatch serve` | Start MCP server (stdio, used by Claude Code) |
|
|
364
369
|
|
|
365
370
|
## Requirements
|
|
@@ -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
|
|
20
|
-
window return the cached result without spawning a new subprocess.
|
|
19
|
+
Keyed on (agent, task, context, caller, goal) — identical requests within
|
|
20
|
+
the TTL window return the cached result without spawning a new subprocess.
|
|
21
|
+
`caller` and `goal` affect the prompt sent to the agent, so they must be
|
|
22
|
+
part of the key: otherwise two requests with different framing would
|
|
23
|
+
collide and return the wrong response.
|
|
21
24
|
"""
|
|
22
25
|
|
|
23
26
|
def __init__(self, ttl: int = 300) -> None:
|
|
@@ -28,15 +31,34 @@ class DispatchCache:
|
|
|
28
31
|
self._misses = 0
|
|
29
32
|
|
|
30
33
|
@staticmethod
|
|
31
|
-
def _make_key(
|
|
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
|
+
) -> str:
|
|
32
41
|
canonical = json.dumps(
|
|
33
|
-
{
|
|
42
|
+
{
|
|
43
|
+
"agent": agent,
|
|
44
|
+
"task": task,
|
|
45
|
+
"context": context or "",
|
|
46
|
+
"caller": caller or "",
|
|
47
|
+
"goal": goal or "",
|
|
48
|
+
},
|
|
34
49
|
sort_keys=True,
|
|
35
50
|
)
|
|
36
51
|
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
|
37
52
|
|
|
38
|
-
def get(
|
|
39
|
-
|
|
53
|
+
def get(
|
|
54
|
+
self,
|
|
55
|
+
agent: str,
|
|
56
|
+
task: str,
|
|
57
|
+
context: str | None = None,
|
|
58
|
+
caller: str | None = None,
|
|
59
|
+
goal: str | None = None,
|
|
60
|
+
) -> DispatchResult | None:
|
|
61
|
+
key = self._make_key(agent, task, context, caller, goal)
|
|
40
62
|
with self._lock:
|
|
41
63
|
entry = self._store.get(key)
|
|
42
64
|
if entry is None:
|
|
@@ -56,10 +78,12 @@ class DispatchCache:
|
|
|
56
78
|
task: str,
|
|
57
79
|
result: DispatchResult,
|
|
58
80
|
context: str | None = None,
|
|
81
|
+
caller: str | None = None,
|
|
82
|
+
goal: str | None = None,
|
|
59
83
|
) -> None:
|
|
60
84
|
if not result.success:
|
|
61
85
|
return # don't cache failures
|
|
62
|
-
key = self._make_key(agent, task, context)
|
|
86
|
+
key = self._make_key(agent, task, context, caller, goal)
|
|
63
87
|
with self._lock:
|
|
64
88
|
self._store[key] = (time.monotonic(), result)
|
|
65
89
|
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
"""CLI: init, add, remove, list, test, serve."""
|
|
1
|
+
"""CLI: init, add, remove, list, update, test, describe, doctor, serve."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
|
+
import re
|
|
6
7
|
import shutil
|
|
7
8
|
import subprocess
|
|
8
9
|
from pathlib import Path
|
|
@@ -202,10 +203,12 @@ def list_agents() -> None:
|
|
|
202
203
|
click.echo(f" config: {', '.join(extras)}")
|
|
203
204
|
if agent.permission_mode:
|
|
204
205
|
click.echo(f" permission_mode: {agent.permission_mode}")
|
|
205
|
-
if agent.allowed_tools:
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
206
|
+
if agent.allowed_tools is not None:
|
|
207
|
+
rendered = ", ".join(agent.allowed_tools) if agent.allowed_tools else "(none)"
|
|
208
|
+
click.echo(f" allowed_tools: {rendered}")
|
|
209
|
+
if agent.disallowed_tools is not None:
|
|
210
|
+
rendered = ", ".join(agent.disallowed_tools) if agent.disallowed_tools else "(none)"
|
|
211
|
+
click.echo(f" disallowed_tools: {rendered}")
|
|
209
212
|
click.echo()
|
|
210
213
|
|
|
211
214
|
|
|
@@ -293,7 +296,11 @@ def update(
|
|
|
293
296
|
@cli.command()
|
|
294
297
|
@click.argument("name")
|
|
295
298
|
@click.argument("task", default="What project is this? Describe in one sentence.")
|
|
296
|
-
|
|
299
|
+
@click.option(
|
|
300
|
+
"--stream", "stream", is_flag=True,
|
|
301
|
+
help="Show live progress (assistant text + tool use) while the agent works.",
|
|
302
|
+
)
|
|
303
|
+
def test(name: str, task: str, stream: bool) -> None:
|
|
297
304
|
"""Test an agent by dispatching a task."""
|
|
298
305
|
config = _load_or_exit()
|
|
299
306
|
if name not in config.agents:
|
|
@@ -305,9 +312,18 @@ def test(name: str, task: str) -> None:
|
|
|
305
312
|
click.echo(f"Task: {task}")
|
|
306
313
|
click.echo("---")
|
|
307
314
|
|
|
308
|
-
|
|
315
|
+
if stream:
|
|
316
|
+
from .runner import dispatch_stream
|
|
317
|
+
|
|
318
|
+
def _on_progress(msg: str) -> None:
|
|
319
|
+
click.echo(click.style(f" -> {msg}", fg="cyan"), err=True)
|
|
309
320
|
|
|
310
|
-
|
|
321
|
+
result = dispatch_stream(
|
|
322
|
+
name, task, agent, config.settings, on_progress=_on_progress,
|
|
323
|
+
)
|
|
324
|
+
else:
|
|
325
|
+
from .runner import dispatch
|
|
326
|
+
result = dispatch(name, task, agent, config.settings)
|
|
311
327
|
|
|
312
328
|
if result.success:
|
|
313
329
|
click.echo(result.result)
|
|
@@ -328,6 +344,177 @@ def test(name: str, task: str) -> None:
|
|
|
328
344
|
raise SystemExit(1)
|
|
329
345
|
|
|
330
346
|
|
|
347
|
+
@cli.command()
|
|
348
|
+
@click.argument("name")
|
|
349
|
+
def describe(name: str) -> None:
|
|
350
|
+
"""Show full configuration for a single agent."""
|
|
351
|
+
config = _load_or_exit()
|
|
352
|
+
if name not in config.agents:
|
|
353
|
+
click.echo(f"Agent '{name}' not found. Run 'agent-dispatch list' to see agents.")
|
|
354
|
+
raise SystemExit(1)
|
|
355
|
+
|
|
356
|
+
agent = config.agents[name]
|
|
357
|
+
try:
|
|
358
|
+
if agent.directory.is_dir():
|
|
359
|
+
status_label, status_color = "OK", "green"
|
|
360
|
+
else:
|
|
361
|
+
status_label, status_color = "NOT FOUND", "red"
|
|
362
|
+
except OSError:
|
|
363
|
+
status_label, status_color = "UNREADABLE", "red"
|
|
364
|
+
status = click.style(status_label, fg=status_color)
|
|
365
|
+
|
|
366
|
+
def _render_tools(tools: list[str] | None) -> str:
|
|
367
|
+
if tools is None:
|
|
368
|
+
return click.style("(inherit defaults)", fg="cyan")
|
|
369
|
+
if not tools:
|
|
370
|
+
return click.style("(none — explicit override)", fg="yellow")
|
|
371
|
+
return ", ".join(tools)
|
|
372
|
+
|
|
373
|
+
click.echo(f"{click.style(name, bold=True)} [{status}]")
|
|
374
|
+
click.echo(f" directory: {agent.directory}")
|
|
375
|
+
click.echo(f" description: {agent.description}")
|
|
376
|
+
click.echo(f" timeout: {agent.timeout}s")
|
|
377
|
+
if agent.model:
|
|
378
|
+
click.echo(f" model: {agent.model}")
|
|
379
|
+
if agent.max_budget_usd is not None:
|
|
380
|
+
click.echo(f" max_budget_usd: ${agent.max_budget_usd}")
|
|
381
|
+
if agent.permission_mode:
|
|
382
|
+
click.echo(f" permission_mode: {agent.permission_mode}")
|
|
383
|
+
click.echo(f" allowed_tools: {_render_tools(agent.allowed_tools)}")
|
|
384
|
+
click.echo(f" disallowed_tools: {_render_tools(agent.disallowed_tools)}")
|
|
385
|
+
|
|
386
|
+
# Surface project files used by auto_describe so the user can verify
|
|
387
|
+
# what context the dispatched agent will actually inherit.
|
|
388
|
+
try:
|
|
389
|
+
files: list[str] = []
|
|
390
|
+
for fname in ("CLAUDE.md", ".mcp.json", "README.md", "pyproject.toml", "package.json"):
|
|
391
|
+
if (agent.directory / fname).exists():
|
|
392
|
+
files.append(fname)
|
|
393
|
+
if files:
|
|
394
|
+
click.echo(f" project files: {', '.join(files)}")
|
|
395
|
+
except OSError:
|
|
396
|
+
pass
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
@cli.command()
|
|
400
|
+
def doctor() -> None:
|
|
401
|
+
"""Diagnose the agent-dispatch setup and surface common issues."""
|
|
402
|
+
counters = {"issues": 0, "warnings": 0}
|
|
403
|
+
|
|
404
|
+
def section(title: str) -> None:
|
|
405
|
+
click.echo(f"\n{click.style(title, bold=True)}")
|
|
406
|
+
|
|
407
|
+
def ok(msg: str) -> None:
|
|
408
|
+
click.echo(f" [{click.style('OK', fg='green')}] {msg}")
|
|
409
|
+
|
|
410
|
+
def warn(msg: str) -> None:
|
|
411
|
+
counters["warnings"] += 1
|
|
412
|
+
click.echo(f" [{click.style('WARN', fg='yellow')}] {msg}")
|
|
413
|
+
|
|
414
|
+
def fail(msg: str) -> None:
|
|
415
|
+
counters["issues"] += 1
|
|
416
|
+
click.echo(f" [{click.style('FAIL', fg='red')}] {msg}")
|
|
417
|
+
|
|
418
|
+
section("Environment")
|
|
419
|
+
claude_path = shutil.which("claude")
|
|
420
|
+
if claude_path:
|
|
421
|
+
ok(f"claude CLI: {claude_path}")
|
|
422
|
+
else:
|
|
423
|
+
fail("claude CLI not found on PATH")
|
|
424
|
+
click.echo(" Install: https://docs.anthropic.com/en/docs/claude-code")
|
|
425
|
+
|
|
426
|
+
ad_path = shutil.which("agent-dispatch")
|
|
427
|
+
if ad_path:
|
|
428
|
+
ok(f"agent-dispatch CLI: {ad_path}")
|
|
429
|
+
else:
|
|
430
|
+
warn(
|
|
431
|
+
"agent-dispatch not on PATH "
|
|
432
|
+
"(MCP server still works via absolute path)"
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
section("Config")
|
|
436
|
+
cp = config_path()
|
|
437
|
+
config: DispatchConfig | None = None
|
|
438
|
+
if not cp.exists():
|
|
439
|
+
warn(f"Config not found: {cp}")
|
|
440
|
+
click.echo(" Run: agent-dispatch init")
|
|
441
|
+
else:
|
|
442
|
+
try:
|
|
443
|
+
config = load_config()
|
|
444
|
+
n = len(config.agents)
|
|
445
|
+
suffix = "agent" if n == 1 else "agents"
|
|
446
|
+
ok(f"Config: {cp} ({n} {suffix})")
|
|
447
|
+
except ValidationError as e:
|
|
448
|
+
fail(f"Config schema invalid: {cp}")
|
|
449
|
+
click.echo(f" {e}")
|
|
450
|
+
except yaml.YAMLError as e:
|
|
451
|
+
fail(f"Config not valid YAML: {cp}")
|
|
452
|
+
click.echo(f" {e}")
|
|
453
|
+
|
|
454
|
+
section("MCP registration")
|
|
455
|
+
if claude_path is None:
|
|
456
|
+
warn("Skipped (claude CLI missing)")
|
|
457
|
+
else:
|
|
458
|
+
try:
|
|
459
|
+
result = subprocess.run(
|
|
460
|
+
[claude_path, "mcp", "list"],
|
|
461
|
+
capture_output=True, text=True, timeout=10,
|
|
462
|
+
)
|
|
463
|
+
# Match the server name at the start of any line — `claude mcp list`
|
|
464
|
+
# prints `<name>: <command> - <status>`, and we want to avoid false
|
|
465
|
+
# positives from "agent-dispatch" appearing in command paths.
|
|
466
|
+
entry_re = re.compile(r"^agent-dispatch[:\s]", re.MULTILINE)
|
|
467
|
+
if result.returncode == 0 and entry_re.search(result.stdout):
|
|
468
|
+
ok("agent-dispatch is registered with Claude Code")
|
|
469
|
+
else:
|
|
470
|
+
warn("agent-dispatch is not registered with Claude Code")
|
|
471
|
+
click.echo(" Run: agent-dispatch init")
|
|
472
|
+
except subprocess.TimeoutExpired:
|
|
473
|
+
warn("Could not check MCP registration: claude mcp list timed out")
|
|
474
|
+
except (FileNotFoundError, PermissionError, OSError) as e:
|
|
475
|
+
warn(f"Could not check MCP registration: {e}")
|
|
476
|
+
|
|
477
|
+
section("Agents")
|
|
478
|
+
if config is None:
|
|
479
|
+
warn("Skipped (config could not be loaded)")
|
|
480
|
+
elif not config.agents:
|
|
481
|
+
warn("No agents configured. Add one: agent-dispatch add <name> <directory>")
|
|
482
|
+
else:
|
|
483
|
+
for name, agent in config.agents.items():
|
|
484
|
+
try:
|
|
485
|
+
if agent.directory.is_dir():
|
|
486
|
+
extras: list[str] = []
|
|
487
|
+
if (agent.directory / "CLAUDE.md").exists():
|
|
488
|
+
extras.append("CLAUDE.md")
|
|
489
|
+
if (agent.directory / ".mcp.json").exists():
|
|
490
|
+
extras.append(".mcp.json")
|
|
491
|
+
suffix = f" [{', '.join(extras)}]" if extras else ""
|
|
492
|
+
ok(f"{name}: {agent.directory}{suffix}")
|
|
493
|
+
else:
|
|
494
|
+
fail(f"{name}: directory missing - {agent.directory}")
|
|
495
|
+
except OSError as e:
|
|
496
|
+
fail(f"{name}: directory unreadable - {e}")
|
|
497
|
+
|
|
498
|
+
section("Summary")
|
|
499
|
+
issues = counters["issues"]
|
|
500
|
+
warnings = counters["warnings"]
|
|
501
|
+
if issues == 0 and warnings == 0:
|
|
502
|
+
click.echo(click.style("All checks passed.", fg="green"))
|
|
503
|
+
else:
|
|
504
|
+
parts: list[str] = []
|
|
505
|
+
if issues:
|
|
506
|
+
parts.append(click.style(
|
|
507
|
+
f"{issues} issue{'s' if issues != 1 else ''}", fg="red",
|
|
508
|
+
))
|
|
509
|
+
if warnings:
|
|
510
|
+
parts.append(click.style(
|
|
511
|
+
f"{warnings} warning{'s' if warnings != 1 else ''}", fg="yellow",
|
|
512
|
+
))
|
|
513
|
+
click.echo(", ".join(parts))
|
|
514
|
+
if issues > 0:
|
|
515
|
+
raise SystemExit(1)
|
|
516
|
+
|
|
517
|
+
|
|
331
518
|
@cli.command()
|
|
332
519
|
def serve() -> None:
|
|
333
520
|
"""Start the MCP server (stdio transport)."""
|
|
@@ -102,20 +102,35 @@ async def list_agents(ctx: Context | None = None) -> str:
|
|
|
102
102
|
|
|
103
103
|
agents = []
|
|
104
104
|
for name, agent in config.agents.items():
|
|
105
|
-
|
|
105
|
+
# is_dir() can raise OSError (PermissionError, network FS hiccup, etc.).
|
|
106
|
+
# Fall back to "UNREADABLE" so a single broken agent doesn't crash the
|
|
107
|
+
# whole listing — per CLAUDE.md's documented response shape.
|
|
108
|
+
try:
|
|
109
|
+
healthy: bool | str = agent.directory.is_dir()
|
|
110
|
+
has_claude_md = (
|
|
111
|
+
(agent.directory / "CLAUDE.md").exists() if healthy else False
|
|
112
|
+
)
|
|
113
|
+
has_mcp_config = (
|
|
114
|
+
(agent.directory / ".mcp.json").exists() if healthy else False
|
|
115
|
+
)
|
|
116
|
+
except OSError:
|
|
117
|
+
healthy = "UNREADABLE"
|
|
118
|
+
has_claude_md = False
|
|
119
|
+
has_mcp_config = False
|
|
106
120
|
entry: dict = {
|
|
107
121
|
"name": name,
|
|
108
122
|
"directory": str(agent.directory),
|
|
109
123
|
"description": agent.description,
|
|
110
124
|
"healthy": healthy,
|
|
111
|
-
"has_claude_md":
|
|
112
|
-
"has_mcp_config":
|
|
125
|
+
"has_claude_md": has_claude_md,
|
|
126
|
+
"has_mcp_config": has_mcp_config,
|
|
113
127
|
}
|
|
114
128
|
if agent.permission_mode:
|
|
115
129
|
entry["permission_mode"] = agent.permission_mode
|
|
116
|
-
|
|
130
|
+
# Include when explicitly set (even []) to distinguish from inheriting defaults
|
|
131
|
+
if agent.allowed_tools is not None:
|
|
117
132
|
entry["allowed_tools"] = agent.allowed_tools
|
|
118
|
-
if agent.disallowed_tools:
|
|
133
|
+
if agent.disallowed_tools is not None:
|
|
119
134
|
entry["disallowed_tools"] = agent.disallowed_tools
|
|
120
135
|
agents.append(entry)
|
|
121
136
|
if ctx:
|
|
@@ -150,10 +165,11 @@ async def dispatch(
|
|
|
150
165
|
if err := _validate_agent(config, agent):
|
|
151
166
|
return err
|
|
152
167
|
|
|
153
|
-
# Check cache
|
|
168
|
+
# Check cache. caller/goal are part of the key because they change the
|
|
169
|
+
# prompt sent to Claude and therefore the response.
|
|
154
170
|
cache = _get_cache(config)
|
|
155
171
|
if cache:
|
|
156
|
-
cached = cache.get(agent, task, context or None)
|
|
172
|
+
cached = cache.get(agent, task, context or None, caller or None, goal or None)
|
|
157
173
|
if cached:
|
|
158
174
|
if ctx:
|
|
159
175
|
await ctx.info(f"Cache hit for {agent} — returning cached result")
|
|
@@ -179,7 +195,7 @@ async def dispatch(
|
|
|
179
195
|
|
|
180
196
|
# Populate cache
|
|
181
197
|
if cache:
|
|
182
|
-
cache.put(agent, task, result, context or None)
|
|
198
|
+
cache.put(agent, task, result, context or None, caller or None, goal or None)
|
|
183
199
|
|
|
184
200
|
return result.model_dump_json(indent=2, exclude_none=True)
|
|
185
201
|
|
|
@@ -290,9 +306,9 @@ async def dispatch_parallel(
|
|
|
290
306
|
item_caller = item.get("caller") or None
|
|
291
307
|
item_goal = item.get("goal") or None
|
|
292
308
|
|
|
293
|
-
# Check cache
|
|
309
|
+
# Check cache (caller/goal are part of the key — see dispatch())
|
|
294
310
|
if cache:
|
|
295
|
-
cached = cache.get(name, task, item_context)
|
|
311
|
+
cached = cache.get(name, task, item_context, item_caller, item_goal)
|
|
296
312
|
if cached:
|
|
297
313
|
d = json.loads(cached.model_dump_json(exclude_none=True))
|
|
298
314
|
d["cached"] = True
|
|
@@ -312,7 +328,7 @@ async def dispatch_parallel(
|
|
|
312
328
|
)
|
|
313
329
|
|
|
314
330
|
if cache:
|
|
315
|
-
cache.put(name, task, result, item_context)
|
|
331
|
+
cache.put(name, task, result, item_context, item_caller, item_goal)
|
|
316
332
|
|
|
317
333
|
return json.loads(result.model_dump_json(exclude_none=True))
|
|
318
334
|
|
|
@@ -53,6 +53,48 @@ class TestCacheBasics:
|
|
|
53
53
|
cache.put("a", "task", _fail_result())
|
|
54
54
|
assert cache.get("a", "task") is None
|
|
55
55
|
|
|
56
|
+
def test_caller_affects_key(self):
|
|
57
|
+
"""Different caller → different prompt → must not collide in cache."""
|
|
58
|
+
cache = DispatchCache(ttl=60)
|
|
59
|
+
cache.put("a", "task", _ok_result(text="for-frontend"), caller="frontend")
|
|
60
|
+
cache.put("a", "task", _ok_result(text="for-backend"), caller="backend")
|
|
61
|
+
assert cache.get("a", "task", caller="frontend").result == "for-frontend"
|
|
62
|
+
assert cache.get("a", "task", caller="backend").result == "for-backend"
|
|
63
|
+
# Different caller from what was stored → miss
|
|
64
|
+
assert cache.get("a", "task", caller="other") is None
|
|
65
|
+
|
|
66
|
+
def test_goal_affects_key(self):
|
|
67
|
+
"""Different goal → different prompt → must not collide in cache."""
|
|
68
|
+
cache = DispatchCache(ttl=60)
|
|
69
|
+
cache.put("a", "task", _ok_result(text="for-debug"), goal="debug crash")
|
|
70
|
+
cache.put("a", "task", _ok_result(text="for-perf"), goal="optimize perf")
|
|
71
|
+
assert cache.get("a", "task", goal="debug crash").result == "for-debug"
|
|
72
|
+
assert cache.get("a", "task", goal="optimize perf").result == "for-perf"
|
|
73
|
+
|
|
74
|
+
def test_caller_and_goal_combined(self):
|
|
75
|
+
cache = DispatchCache(ttl=60)
|
|
76
|
+
cache.put(
|
|
77
|
+
"a", "task", _ok_result(text="A"),
|
|
78
|
+
caller="frontend", goal="debug",
|
|
79
|
+
)
|
|
80
|
+
# Same caller, different goal → miss
|
|
81
|
+
assert cache.get("a", "task", caller="frontend", goal="optimize") is None
|
|
82
|
+
# Same goal, different caller → miss
|
|
83
|
+
assert cache.get("a", "task", caller="backend", goal="debug") is None
|
|
84
|
+
# Both match → hit
|
|
85
|
+
assert cache.get(
|
|
86
|
+
"a", "task", caller="frontend", goal="debug",
|
|
87
|
+
).result == "A"
|
|
88
|
+
|
|
89
|
+
def test_caller_none_vs_empty_string_collide(self):
|
|
90
|
+
"""caller=None and caller="" canonicalize the same — both mean
|
|
91
|
+
'no caller specified'. Document the behavior so future changes
|
|
92
|
+
don't silently break the contract."""
|
|
93
|
+
cache = DispatchCache(ttl=60)
|
|
94
|
+
cache.put("a", "task", _ok_result(text="anon"), caller=None)
|
|
95
|
+
assert cache.get("a", "task", caller="") is not None
|
|
96
|
+
assert cache.get("a", "task", caller=None) is not None
|
|
97
|
+
|
|
56
98
|
|
|
57
99
|
class TestCacheTTL:
|
|
58
100
|
def test_expired_entry_returns_none(self):
|