agent-dispatch 0.8.0__tar.gz → 0.9.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.8.0 → agent_dispatch-0.9.0}/AGENTS.md +6 -5
- {agent_dispatch-0.8.0 → agent_dispatch-0.9.0}/CHANGELOG.md +30 -0
- {agent_dispatch-0.8.0 → agent_dispatch-0.9.0}/PKG-INFO +36 -2
- {agent_dispatch-0.8.0 → agent_dispatch-0.9.0}/README.md +35 -1
- {agent_dispatch-0.8.0 → agent_dispatch-0.9.0}/agents.example.yaml +23 -0
- {agent_dispatch-0.8.0 → agent_dispatch-0.9.0}/pyproject.toml +1 -1
- {agent_dispatch-0.8.0 → agent_dispatch-0.9.0}/src/agent_dispatch/__init__.py +1 -1
- {agent_dispatch-0.8.0 → agent_dispatch-0.9.0}/src/agent_dispatch/cli.py +178 -1
- {agent_dispatch-0.8.0 → agent_dispatch-0.9.0}/src/agent_dispatch/config.py +9 -0
- {agent_dispatch-0.8.0 → agent_dispatch-0.9.0}/src/agent_dispatch/models.py +57 -2
- {agent_dispatch-0.8.0 → agent_dispatch-0.9.0}/src/agent_dispatch/server.py +199 -10
- {agent_dispatch-0.8.0 → agent_dispatch-0.9.0}/tests/test_cli.py +120 -0
- {agent_dispatch-0.8.0 → agent_dispatch-0.9.0}/tests/test_config.py +55 -1
- {agent_dispatch-0.8.0 → agent_dispatch-0.9.0}/tests/test_models.py +59 -1
- {agent_dispatch-0.8.0 → agent_dispatch-0.9.0}/tests/test_server.py +252 -0
- {agent_dispatch-0.8.0 → agent_dispatch-0.9.0}/.github/dependabot.yml +0 -0
- {agent_dispatch-0.8.0 → agent_dispatch-0.9.0}/.github/workflows/ci.yml +0 -0
- {agent_dispatch-0.8.0 → agent_dispatch-0.9.0}/.github/workflows/publish.yml +0 -0
- {agent_dispatch-0.8.0 → agent_dispatch-0.9.0}/.gitignore +0 -0
- {agent_dispatch-0.8.0 → agent_dispatch-0.9.0}/LICENSE +0 -0
- {agent_dispatch-0.8.0 → agent_dispatch-0.9.0}/SECURITY.md +0 -0
- {agent_dispatch-0.8.0 → agent_dispatch-0.9.0}/assets/mascot.png +0 -0
- {agent_dispatch-0.8.0 → agent_dispatch-0.9.0}/src/agent_dispatch/cache.py +0 -0
- {agent_dispatch-0.8.0 → agent_dispatch-0.9.0}/src/agent_dispatch/jobs.py +0 -0
- {agent_dispatch-0.8.0 → agent_dispatch-0.9.0}/src/agent_dispatch/runner.py +0 -0
- {agent_dispatch-0.8.0 → agent_dispatch-0.9.0}/tests/__init__.py +0 -0
- {agent_dispatch-0.8.0 → agent_dispatch-0.9.0}/tests/conftest.py +0 -0
- {agent_dispatch-0.8.0 → agent_dispatch-0.9.0}/tests/test_cache.py +0 -0
- {agent_dispatch-0.8.0 → agent_dispatch-0.9.0}/tests/test_jobs.py +0 -0
- {agent_dispatch-0.8.0 → agent_dispatch-0.9.0}/tests/test_runner.py +0 -0
|
@@ -11,9 +11,9 @@ MCP server + CLI that lets Claude Code agents delegate tasks to agents in other
|
|
|
11
11
|
| File | Role |
|
|
12
12
|
|------|------|
|
|
13
13
|
| `src/agent_dispatch/runner.py` | Sync subprocess wrapper around `claude -p` — the actual work |
|
|
14
|
-
| `src/agent_dispatch/server.py` | Async FastMCP interface (
|
|
15
|
-
| `src/agent_dispatch/cli.py` | Click CLI: `init`, `add`, `update`, `remove`, `list`, `describe`, `test`, `doctor`, `jobs`, `job`, `cancel`, `gc`, `serve` |
|
|
16
|
-
| `src/agent_dispatch/models.py` | Pydantic v2 models (`AgentConfig`, `Settings`, `DispatchResult`) |
|
|
14
|
+
| `src/agent_dispatch/server.py` | Async FastMCP interface (21 MCP tools), wraps runner in `asyncio.to_thread` + semaphore |
|
|
15
|
+
| `src/agent_dispatch/cli.py` | Click CLI: `init`, `add`, `update`, `remove`, `list`, `describe`, `test`, `doctor`, `jobs`, `job`, `cancel`, `gc`, `group` (add/list/inspect/update/remove), `serve` |
|
|
16
|
+
| `src/agent_dispatch/models.py` | Pydantic v2 models (`AgentConfig`, `DispatchGroup`/`GroupMember`, `Settings`, `DispatchResult`) |
|
|
17
17
|
| `src/agent_dispatch/config.py` | YAML config load/save + project auto-description |
|
|
18
18
|
| `src/agent_dispatch/cache.py` | Thread-safe in-memory TTL cache |
|
|
19
19
|
| `src/agent_dispatch/jobs.py` | Persistent per-job JSON files for async dispatch |
|
|
@@ -28,7 +28,7 @@ pip install -e ".[dev]"
|
|
|
28
28
|
|
|
29
29
|
```bash
|
|
30
30
|
ruff check src/ tests/
|
|
31
|
-
python3 -m pytest tests/ -v #
|
|
31
|
+
python3 -m pytest tests/ -v # 466 tests, ~2s — all subprocess calls are mocked
|
|
32
32
|
```
|
|
33
33
|
|
|
34
34
|
Tests must **never** invoke the real `claude` CLI. Runner tests mock `shutil.which` + `subprocess.run`/`Popen`; server tests mock `_get_config` + `runner.dispatch`.
|
|
@@ -37,6 +37,7 @@ Tests must **never** invoke the real `claude` CLI. Runner tests mock `shutil.whi
|
|
|
37
37
|
|
|
38
38
|
- `allowed_tools` / `disallowed_tools` are **tri-state**: `None` = inherit settings defaults, `[]` = explicitly no tools, `[...]` = exactly these. Check with `is not None`, never `or` — `[]` is falsy but semantically distinct.
|
|
39
39
|
- `denied_tools` non-empty + `is_error` ⇒ `error_type="permission"`, regardless of what the error text matches.
|
|
40
|
+
- **Groups**: a group's `shared_context` is folded into the `context` *string* before the cache/runner calls (`_merge_group_context` in server.py) — runner.py and cache.py are untouched, the cache key disambiguates groups for free, and `group=""` is byte-identical to a plain dispatch. Membership is validated up front (`_validate_group_member`, separate from the pure merge so `dispatch_parallel`'s all-or-nothing pre-check holds). `DispatchConfig` validates only group *keys*, never member existence — a hard cross-ref check would brick config load when a shared gateway agent is removed; dangling refs are flagged (`unknown:true`) at read time instead.
|
|
40
41
|
- On failure, callers read `DispatchResult.error` + `error_type` — `result` holds the raw agent output even on errors.
|
|
41
42
|
- `--session-id` and `--resume` conflict — never pass both to `claude`.
|
|
42
43
|
- Valid permission modes: `default`, `plan`, `bypassPermissions` (`models.py: KNOWN_PERMISSION_MODES`).
|
|
@@ -54,4 +55,4 @@ Python ≥ 3.10 · `from __future__ import annotations` everywhere · Pydantic v
|
|
|
54
55
|
|
|
55
56
|
## More detail
|
|
56
57
|
|
|
57
|
-
[README.md](README.md) documents every MCP tool with parameter tables, response shapes, and the error-recovery map — it doubles as the behavioral spec. The test suite (`tests/`,
|
|
58
|
+
[README.md](README.md) documents every MCP tool with parameter tables, response shapes, and the error-recovery map — it doubles as the behavioral spec. The test suite (`tests/`, 466 tests) encodes the exact expected behavior of every layer: when in doubt, read the tests for the module you're touching (`test_runner.py`, `test_server.py`, `test_cli.py`, ...).
|
|
@@ -7,6 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.9.0] - 2026-06-30
|
|
11
|
+
|
|
12
|
+
Coordinate a group of related projects from one session.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
- **Project groups.** A new `groups` mapping in the config bundles related
|
|
16
|
+
agents — code repos plus capability gateways like infra (Portainer) or
|
|
17
|
+
analytics (browser / Yandex Metrica) — into a cross-project working set.
|
|
18
|
+
Each group has an orchestrator-facing `description` (how to coordinate, never
|
|
19
|
+
sent to members) and a member-facing `shared_context` of facts (stack names,
|
|
20
|
+
ids, conventions). Members reference agents by name; membership is
|
|
21
|
+
many-to-many (a shared gateway can belong to several groups). A group is a
|
|
22
|
+
*descriptive layer*, not an execution engine — there is no router; the
|
|
23
|
+
orchestrating LLM coordinates with the existing dispatch tools.
|
|
24
|
+
- **`list_groups()` / `inspect_group(name)` MCP tools** — cheap, no-subprocess
|
|
25
|
+
readouts of groups, their briefs, and members (dangling member refs are
|
|
26
|
+
flagged, never crash). For a deep dive on a member, use `inspect_agent`.
|
|
27
|
+
- **`group=` on `dispatch` and per-item in `dispatch_parallel`** — when set,
|
|
28
|
+
the agent must be a member of the group and the group's `shared_context` is
|
|
29
|
+
auto-prepended to the call's `context`. Folded into the context string, so
|
|
30
|
+
the cache key disambiguates groups automatically and `group=""` is byte-for-
|
|
31
|
+
byte identical to a plain dispatch. Parallel validates membership up front
|
|
32
|
+
(one bad item rejects the whole call before any subprocess runs).
|
|
33
|
+
- **`agent-dispatch group` CLI** — `add` / `list` / `inspect` / `update` /
|
|
34
|
+
`remove` for managing groups, mirroring the agent commands.
|
|
35
|
+
|
|
36
|
+
### Changed
|
|
37
|
+
- `save_config` prunes an empty `groups` block and empty member lists so
|
|
38
|
+
group-less configs stay clean in YAML (same idiom as capabilities).
|
|
39
|
+
|
|
10
40
|
## [0.8.0] - 2026-06-17
|
|
11
41
|
|
|
12
42
|
Let agents declare what they are good at, so callers can pick the right one.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agent-dispatch
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.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
|
|
@@ -150,6 +150,37 @@ Cheap detailed lookup — reads the agent's files without spawning a `claude` se
|
|
|
150
150
|
|
|
151
151
|
Use this **before** `dispatch_async`/`dispatch` to confirm an agent has the tools and context for your task — much cheaper than a probe dispatch.
|
|
152
152
|
|
|
153
|
+
### Groups
|
|
154
|
+
|
|
155
|
+
A **group** bundles related agents into a cross-project working set — typically a few code repos plus capability gateways (an `infra` agent with a Portainer MCP, an `analytics` agent with a browser + Yandex Metrica). It lets one orchestrating session coordinate work that spans code, deploy, and verification.
|
|
156
|
+
|
|
157
|
+
A group is a **descriptive layer, not an execution engine** — there is no router and no state machine. You pick members by reading their hints and coordinate with the normal dispatch tools. Two text fields target two audiences:
|
|
158
|
+
|
|
159
|
+
- `description` — **orchestrator-facing**: how to coordinate the group (the order of steps, who to call for what). Surfaced by `list_groups`/`inspect_group`, **never** injected into a member's prompt.
|
|
160
|
+
- `shared_context` — **member-facing facts** (stack names, counter ids, conventions) that hold regardless of which member reads them. Auto-prepended to a member's `context` when you pass `group=`.
|
|
161
|
+
|
|
162
|
+
Members reference agents by name; membership is many-to-many (a shared gateway can belong to several groups). Manage groups with the `agent-dispatch group` CLI (`add`/`list`/`inspect`/`update`/`remove`) or by editing `agents.yaml`.
|
|
163
|
+
|
|
164
|
+
**`list_groups()`** — cheap, no-subprocess readout of every group: description, member count, and each member's `use_for` hint + health. A member whose agent was removed is flagged `"unknown": true` rather than crashing.
|
|
165
|
+
|
|
166
|
+
**`inspect_group(name)`** — one group's full brief: `description`, the complete `shared_context`, and the member list. For a deep dive on a specific member, call `inspect_agent(member)` — `inspect_group` deliberately stays a cheap membership readout.
|
|
167
|
+
|
|
168
|
+
**Using a group** — pass `group=` to `dispatch` (or per-item in `dispatch_parallel`). The agent must be a member; its group's `shared_context` rides along automatically:
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
# From the shop-web codebase, hand the deploy to the infra gateway.
|
|
172
|
+
# The "shop" group's facts (stack name, counter id) are auto-attached.
|
|
173
|
+
dispatch(
|
|
174
|
+
agent="infra",
|
|
175
|
+
task="Redeploy the shop-web container",
|
|
176
|
+
caller="shop-web",
|
|
177
|
+
goal="ship the checkout fix",
|
|
178
|
+
group="shop",
|
|
179
|
+
)
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
`group=""` (the default) is byte-for-byte identical to a plain dispatch — the shared facts are folded into the `context` string, so the result cache disambiguates groups automatically and group-less calls are unaffected.
|
|
183
|
+
|
|
153
184
|
### `dispatch`
|
|
154
185
|
|
|
155
186
|
One-shot task delegation. Results are cached — identical requests within TTL return instantly.
|
|
@@ -165,6 +196,7 @@ One-shot task delegation. Results are cached — identical requests within TTL r
|
|
|
165
196
|
| `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. |
|
|
166
197
|
| `summary_chars` | int | no | Max chars of result text to include in the ref response (default 500). |
|
|
167
198
|
| `timeout_seconds` | int | no | One-off timeout override for this call (0 = agent's configured timeout; clamped to 10–7200). No config edit needed for known-long tasks. |
|
|
199
|
+
| `group` | string | no | Group name (from `list_groups`). The agent must be a member; the group's `shared_context` (member-facing facts) is auto-prepended to `context`. Empty = plain dispatch. See [Groups](#groups). |
|
|
168
200
|
|
|
169
201
|
```python
|
|
170
202
|
# Call — recommended form (always include caller and goal)
|
|
@@ -274,7 +306,7 @@ Run multiple tasks concurrently. Much faster than sequential `dispatch` calls.
|
|
|
274
306
|
|
|
275
307
|
| Parameter | Type | Required | Description |
|
|
276
308
|
|-----------|------|----------|-------------|
|
|
277
|
-
| `dispatches` | string (JSON) | yes | JSON array of `{"agent", "task", "context?", "caller?", "goal?", "response_format?", "return_ref?", "summary_chars?", "timeout_seconds?"}` |
|
|
309
|
+
| `dispatches` | string (JSON) | yes | JSON array of `{"agent", "task", "context?", "caller?", "goal?", "response_format?", "return_ref?", "summary_chars?", "timeout_seconds?", "group?"}` (a per-item `group` validates membership up front and auto-injects its `shared_context`) |
|
|
278
310
|
| `aggregate` | string | no | Agent name to synthesize all results into one answer |
|
|
279
311
|
|
|
280
312
|
**Important:** `dispatches` is a JSON string, not a list.
|
|
@@ -456,6 +488,8 @@ Job state persists to disk at `~/.config/agent-dispatch/jobs/` (override with `A
|
|
|
456
488
|
| Check progress without blocking | `dispatch_status` |
|
|
457
489
|
| Known-long task, one-off | any dispatch tool with `timeout_seconds=...` |
|
|
458
490
|
| A dispatch timed out | `dispatch_session` with the `session_id` from the error |
|
|
491
|
+
| Coordinating a set of related projects | define a [group](#groups), then `dispatch(..., group=name)` |
|
|
492
|
+
| See which agents form a working set | `list_groups` / `inspect_group` |
|
|
459
493
|
|
|
460
494
|
## Error Recovery
|
|
461
495
|
|
|
@@ -120,6 +120,37 @@ Cheap detailed lookup — reads the agent's files without spawning a `claude` se
|
|
|
120
120
|
|
|
121
121
|
Use this **before** `dispatch_async`/`dispatch` to confirm an agent has the tools and context for your task — much cheaper than a probe dispatch.
|
|
122
122
|
|
|
123
|
+
### Groups
|
|
124
|
+
|
|
125
|
+
A **group** bundles related agents into a cross-project working set — typically a few code repos plus capability gateways (an `infra` agent with a Portainer MCP, an `analytics` agent with a browser + Yandex Metrica). It lets one orchestrating session coordinate work that spans code, deploy, and verification.
|
|
126
|
+
|
|
127
|
+
A group is a **descriptive layer, not an execution engine** — there is no router and no state machine. You pick members by reading their hints and coordinate with the normal dispatch tools. Two text fields target two audiences:
|
|
128
|
+
|
|
129
|
+
- `description` — **orchestrator-facing**: how to coordinate the group (the order of steps, who to call for what). Surfaced by `list_groups`/`inspect_group`, **never** injected into a member's prompt.
|
|
130
|
+
- `shared_context` — **member-facing facts** (stack names, counter ids, conventions) that hold regardless of which member reads them. Auto-prepended to a member's `context` when you pass `group=`.
|
|
131
|
+
|
|
132
|
+
Members reference agents by name; membership is many-to-many (a shared gateway can belong to several groups). Manage groups with the `agent-dispatch group` CLI (`add`/`list`/`inspect`/`update`/`remove`) or by editing `agents.yaml`.
|
|
133
|
+
|
|
134
|
+
**`list_groups()`** — cheap, no-subprocess readout of every group: description, member count, and each member's `use_for` hint + health. A member whose agent was removed is flagged `"unknown": true` rather than crashing.
|
|
135
|
+
|
|
136
|
+
**`inspect_group(name)`** — one group's full brief: `description`, the complete `shared_context`, and the member list. For a deep dive on a specific member, call `inspect_agent(member)` — `inspect_group` deliberately stays a cheap membership readout.
|
|
137
|
+
|
|
138
|
+
**Using a group** — pass `group=` to `dispatch` (or per-item in `dispatch_parallel`). The agent must be a member; its group's `shared_context` rides along automatically:
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
# From the shop-web codebase, hand the deploy to the infra gateway.
|
|
142
|
+
# The "shop" group's facts (stack name, counter id) are auto-attached.
|
|
143
|
+
dispatch(
|
|
144
|
+
agent="infra",
|
|
145
|
+
task="Redeploy the shop-web container",
|
|
146
|
+
caller="shop-web",
|
|
147
|
+
goal="ship the checkout fix",
|
|
148
|
+
group="shop",
|
|
149
|
+
)
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
`group=""` (the default) is byte-for-byte identical to a plain dispatch — the shared facts are folded into the `context` string, so the result cache disambiguates groups automatically and group-less calls are unaffected.
|
|
153
|
+
|
|
123
154
|
### `dispatch`
|
|
124
155
|
|
|
125
156
|
One-shot task delegation. Results are cached — identical requests within TTL return instantly.
|
|
@@ -135,6 +166,7 @@ One-shot task delegation. Results are cached — identical requests within TTL r
|
|
|
135
166
|
| `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. |
|
|
136
167
|
| `summary_chars` | int | no | Max chars of result text to include in the ref response (default 500). |
|
|
137
168
|
| `timeout_seconds` | int | no | One-off timeout override for this call (0 = agent's configured timeout; clamped to 10–7200). No config edit needed for known-long tasks. |
|
|
169
|
+
| `group` | string | no | Group name (from `list_groups`). The agent must be a member; the group's `shared_context` (member-facing facts) is auto-prepended to `context`. Empty = plain dispatch. See [Groups](#groups). |
|
|
138
170
|
|
|
139
171
|
```python
|
|
140
172
|
# Call — recommended form (always include caller and goal)
|
|
@@ -244,7 +276,7 @@ Run multiple tasks concurrently. Much faster than sequential `dispatch` calls.
|
|
|
244
276
|
|
|
245
277
|
| Parameter | Type | Required | Description |
|
|
246
278
|
|-----------|------|----------|-------------|
|
|
247
|
-
| `dispatches` | string (JSON) | yes | JSON array of `{"agent", "task", "context?", "caller?", "goal?", "response_format?", "return_ref?", "summary_chars?", "timeout_seconds?"}` |
|
|
279
|
+
| `dispatches` | string (JSON) | yes | JSON array of `{"agent", "task", "context?", "caller?", "goal?", "response_format?", "return_ref?", "summary_chars?", "timeout_seconds?", "group?"}` (a per-item `group` validates membership up front and auto-injects its `shared_context`) |
|
|
248
280
|
| `aggregate` | string | no | Agent name to synthesize all results into one answer |
|
|
249
281
|
|
|
250
282
|
**Important:** `dispatches` is a JSON string, not a list.
|
|
@@ -426,6 +458,8 @@ Job state persists to disk at `~/.config/agent-dispatch/jobs/` (override with `A
|
|
|
426
458
|
| Check progress without blocking | `dispatch_status` |
|
|
427
459
|
| Known-long task, one-off | any dispatch tool with `timeout_seconds=...` |
|
|
428
460
|
| A dispatch timed out | `dispatch_session` with the `session_id` from the error |
|
|
461
|
+
| Coordinating a set of related projects | define a [group](#groups), then `dispatch(..., group=name)` |
|
|
462
|
+
| See which agents form a working set | `list_groups` / `inspect_group` |
|
|
429
463
|
|
|
430
464
|
## Error Recovery
|
|
431
465
|
|
|
@@ -41,6 +41,29 @@ agents:
|
|
|
41
41
|
# description: "Frontend React app. Can modify components, run build, check TypeScript errors."
|
|
42
42
|
# timeout: 180
|
|
43
43
|
|
|
44
|
+
# Groups bundle related agents (code repos + capability gateways like infra)
|
|
45
|
+
# into a cross-project working set. A group is a *descriptive layer* — there is
|
|
46
|
+
# no router and no execution engine; the orchestrating session coordinates with
|
|
47
|
+
# the normal dispatch tools. `members` reference agents above (many-to-many: a
|
|
48
|
+
# shared gateway can belong to several groups). Manage via `agent-dispatch
|
|
49
|
+
# group add/list/inspect/update/remove`; read via the list_groups /
|
|
50
|
+
# inspect_group MCP tools; auto-attach the brief via dispatch(..., group="shop").
|
|
51
|
+
groups:
|
|
52
|
+
shop:
|
|
53
|
+
# description = ORCHESTRATOR-facing: how to coordinate the group.
|
|
54
|
+
# Surfaced by list_groups/inspect_group, never injected into a member.
|
|
55
|
+
description: "E-commerce team. After a code change: deploy via infra, then verify the checkout funnel via the analytics gateway before reporting done."
|
|
56
|
+
# shared_context = MEMBER-facing FACTS, auto-prepended to group dispatches.
|
|
57
|
+
shared_context: |
|
|
58
|
+
Production runs in Portainer stack "shop".
|
|
59
|
+
Conversion is tracked in Yandex Metrica counter 12345.
|
|
60
|
+
members:
|
|
61
|
+
- agent: backend
|
|
62
|
+
use_for: orders/payments endpoints, migrations
|
|
63
|
+
- agent: infra
|
|
64
|
+
use_for: deploy, restart, container logs
|
|
65
|
+
# - agent: analytics # a shared gateway agent could also live here
|
|
66
|
+
|
|
44
67
|
settings:
|
|
45
68
|
default_timeout: 300
|
|
46
69
|
max_dispatch_depth: 3 # recursion protection: A -> B -> A
|
|
@@ -15,7 +15,14 @@ from pydantic import ValidationError
|
|
|
15
15
|
|
|
16
16
|
from .config import auto_describe, config_path, load_config, save_config
|
|
17
17
|
from .jobs import JobStore, default_jobs_dir, is_valid_job_id
|
|
18
|
-
from .models import
|
|
18
|
+
from .models import (
|
|
19
|
+
AgentConfig,
|
|
20
|
+
DispatchConfig,
|
|
21
|
+
DispatchGroup,
|
|
22
|
+
GroupMember,
|
|
23
|
+
check_permission_mode,
|
|
24
|
+
validate_agent_name,
|
|
25
|
+
)
|
|
19
26
|
|
|
20
27
|
|
|
21
28
|
def _parse_csv(value: str | None) -> list[str] | None:
|
|
@@ -475,6 +482,176 @@ def describe(name: str) -> None:
|
|
|
475
482
|
pass
|
|
476
483
|
|
|
477
484
|
|
|
485
|
+
@cli.group("group")
|
|
486
|
+
def group_cmd() -> None:
|
|
487
|
+
"""Manage agent groups (cross-project working sets)."""
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
@group_cmd.command("add")
|
|
491
|
+
@click.argument("name")
|
|
492
|
+
@click.option(
|
|
493
|
+
"-d",
|
|
494
|
+
"--description",
|
|
495
|
+
default="",
|
|
496
|
+
help="Orchestrator-facing brief: how to coordinate the group (not sent to members).",
|
|
497
|
+
)
|
|
498
|
+
@click.option(
|
|
499
|
+
"--shared-context",
|
|
500
|
+
default="",
|
|
501
|
+
help="Member-facing facts (stack names, ids) auto-injected into group dispatches.",
|
|
502
|
+
)
|
|
503
|
+
@click.option(
|
|
504
|
+
"--member",
|
|
505
|
+
"members",
|
|
506
|
+
multiple=True,
|
|
507
|
+
help="Agent name to include (repeatable). Must already exist.",
|
|
508
|
+
)
|
|
509
|
+
def group_add(name: str, description: str, shared_context: str, members: tuple[str, ...]) -> None:
|
|
510
|
+
"""Create a group referencing existing agents."""
|
|
511
|
+
try:
|
|
512
|
+
validate_agent_name(name)
|
|
513
|
+
except ValueError as e:
|
|
514
|
+
click.echo(f"Error: {e}")
|
|
515
|
+
raise SystemExit(1) from None
|
|
516
|
+
|
|
517
|
+
config = _load_or_exit()
|
|
518
|
+
if name in config.groups:
|
|
519
|
+
click.echo(
|
|
520
|
+
f"Group '{name}' already exists. Use 'agent-dispatch group remove {name}' first."
|
|
521
|
+
)
|
|
522
|
+
raise SystemExit(1)
|
|
523
|
+
|
|
524
|
+
member_objs: list[GroupMember] = []
|
|
525
|
+
for agent_name in members:
|
|
526
|
+
if agent_name not in config.agents:
|
|
527
|
+
click.echo(
|
|
528
|
+
click.style(
|
|
529
|
+
f"Error: agent '{agent_name}' not found. "
|
|
530
|
+
"Add it first ('agent-dispatch add') or check the name.",
|
|
531
|
+
fg="red",
|
|
532
|
+
)
|
|
533
|
+
)
|
|
534
|
+
raise SystemExit(1)
|
|
535
|
+
member_objs.append(GroupMember(agent=agent_name))
|
|
536
|
+
|
|
537
|
+
config.groups[name] = DispatchGroup(
|
|
538
|
+
description=description,
|
|
539
|
+
shared_context=shared_context,
|
|
540
|
+
members=member_objs,
|
|
541
|
+
)
|
|
542
|
+
save_config(config)
|
|
543
|
+
click.echo(f"Added group '{name}' ({len(member_objs)} member(s)).")
|
|
544
|
+
if not member_objs:
|
|
545
|
+
click.echo(
|
|
546
|
+
click.style(
|
|
547
|
+
"Warning: group has no members yet. Recreate with --member, "
|
|
548
|
+
"or add agents + use_for hints by editing agents.yaml.",
|
|
549
|
+
fg="yellow",
|
|
550
|
+
)
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
@group_cmd.command("list")
|
|
555
|
+
def group_list() -> None:
|
|
556
|
+
"""List configured groups."""
|
|
557
|
+
config = _load_or_exit()
|
|
558
|
+
if not config.groups:
|
|
559
|
+
click.echo("No groups configured. Run: agent-dispatch group add <name> --member <agent>")
|
|
560
|
+
return
|
|
561
|
+
|
|
562
|
+
for name, grp in config.groups.items():
|
|
563
|
+
click.echo(f" {click.style(name, bold=True)} ({len(grp.members)} member(s))")
|
|
564
|
+
if grp.description:
|
|
565
|
+
click.echo(f" desc: {grp.description}")
|
|
566
|
+
rendered: list[str] = []
|
|
567
|
+
for m in grp.members:
|
|
568
|
+
if m.agent in config.agents:
|
|
569
|
+
rendered.append(m.agent)
|
|
570
|
+
else:
|
|
571
|
+
rendered.append(click.style(f"{m.agent}(unknown)", fg="red"))
|
|
572
|
+
if rendered:
|
|
573
|
+
click.echo(f" members: {', '.join(rendered)}")
|
|
574
|
+
click.echo(f" shared context: {'yes' if grp.shared_context.strip() else 'no'}")
|
|
575
|
+
click.echo()
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
@group_cmd.command("inspect")
|
|
579
|
+
@click.argument("name")
|
|
580
|
+
def group_inspect(name: str) -> None:
|
|
581
|
+
"""Show a group's brief, shared context, and members."""
|
|
582
|
+
config = _load_or_exit()
|
|
583
|
+
if name not in config.groups:
|
|
584
|
+
click.echo(f"Group '{name}' not found. Run 'agent-dispatch group list' to see groups.")
|
|
585
|
+
raise SystemExit(1)
|
|
586
|
+
|
|
587
|
+
grp = config.groups[name]
|
|
588
|
+
click.echo(click.style(name, bold=True))
|
|
589
|
+
if grp.description:
|
|
590
|
+
click.echo(f" description: {grp.description}")
|
|
591
|
+
if grp.shared_context:
|
|
592
|
+
click.echo(" shared_context:")
|
|
593
|
+
for line in grp.shared_context.splitlines():
|
|
594
|
+
click.echo(f" {line}")
|
|
595
|
+
click.echo(f" members ({len(grp.members)}):")
|
|
596
|
+
for m in grp.members:
|
|
597
|
+
marker = "" if m.agent in config.agents else click.style(" (unknown)", fg="red")
|
|
598
|
+
hint = f" — {m.use_for}" if m.use_for else ""
|
|
599
|
+
click.echo(f" - {m.agent}{marker}{hint}")
|
|
600
|
+
if not grp.members:
|
|
601
|
+
click.echo(
|
|
602
|
+
click.style(
|
|
603
|
+
" (no members — recreate with --member or edit agents.yaml)", fg="yellow"
|
|
604
|
+
)
|
|
605
|
+
)
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
@group_cmd.command("update")
|
|
609
|
+
@click.argument("name")
|
|
610
|
+
@click.option("-d", "--description", default=None, help="New description (pass '' to clear).")
|
|
611
|
+
@click.option("--shared-context", default=None, help="New shared context (pass '' to clear).")
|
|
612
|
+
def group_update(name: str, description: str | None, shared_context: str | None) -> None:
|
|
613
|
+
"""Update a group's description / shared_context.
|
|
614
|
+
|
|
615
|
+
These are plain text fields: a new value is stored literally (pass an empty
|
|
616
|
+
string to clear). Edit members by recreating the group or editing
|
|
617
|
+
agents.yaml.
|
|
618
|
+
"""
|
|
619
|
+
config = _load_or_exit()
|
|
620
|
+
if name not in config.groups:
|
|
621
|
+
click.echo(f"Group '{name}' not found. Run 'agent-dispatch group list' to see groups.")
|
|
622
|
+
raise SystemExit(1)
|
|
623
|
+
|
|
624
|
+
grp = config.groups[name]
|
|
625
|
+
updated: list[str] = []
|
|
626
|
+
if description is not None:
|
|
627
|
+
grp.description = description
|
|
628
|
+
updated.append("description")
|
|
629
|
+
if shared_context is not None:
|
|
630
|
+
grp.shared_context = shared_context
|
|
631
|
+
updated.append("shared_context")
|
|
632
|
+
|
|
633
|
+
if not updated:
|
|
634
|
+
click.echo("Nothing to update. Pass --description and/or --shared-context.")
|
|
635
|
+
raise SystemExit(1)
|
|
636
|
+
|
|
637
|
+
save_config(config)
|
|
638
|
+
click.echo(f"Updated group '{name}': {', '.join(updated)}")
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
@group_cmd.command("remove")
|
|
642
|
+
@click.argument("name")
|
|
643
|
+
def group_remove(name: str) -> None:
|
|
644
|
+
"""Remove a group (does not touch the underlying agents)."""
|
|
645
|
+
config = _load_or_exit()
|
|
646
|
+
if name not in config.groups:
|
|
647
|
+
click.echo(f"Group '{name}' not found.")
|
|
648
|
+
raise SystemExit(1)
|
|
649
|
+
|
|
650
|
+
del config.groups[name]
|
|
651
|
+
save_config(config)
|
|
652
|
+
click.echo(f"Removed group '{name}'.")
|
|
653
|
+
|
|
654
|
+
|
|
478
655
|
@cli.command()
|
|
479
656
|
def doctor() -> None:
|
|
480
657
|
"""Diagnose the agent-dispatch setup and surface common issues."""
|
|
@@ -54,6 +54,15 @@ def save_config(config: DispatchConfig, path: Path | None = None) -> None:
|
|
|
54
54
|
for key in ("capabilities", "risky_capabilities"):
|
|
55
55
|
if not agent_data.get(key):
|
|
56
56
|
agent_data.pop(key, None)
|
|
57
|
+
# `groups` also defaults to {} (not None), so exclude_none keeps it — prune
|
|
58
|
+
# the whole block when empty, and drop empty per-group member lists. Empty
|
|
59
|
+
# LISTS only, mirroring capabilities; empty strings (description /
|
|
60
|
+
# shared_context / use_for) are kept, like AgentConfig.description.
|
|
61
|
+
for group_data in data.get("groups", {}).values():
|
|
62
|
+
if not group_data.get("members"):
|
|
63
|
+
group_data.pop("members", None)
|
|
64
|
+
if not data.get("groups"):
|
|
65
|
+
data.pop("groups", None)
|
|
57
66
|
p.write_text(
|
|
58
67
|
yaml.dump(data, default_flow_style=False, allow_unicode=True, sort_keys=False),
|
|
59
68
|
encoding="utf-8",
|
|
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
from pathlib import Path
|
|
6
6
|
from typing import Any
|
|
7
7
|
|
|
8
|
-
from pydantic import BaseModel, Field, field_validator
|
|
8
|
+
from pydantic import BaseModel, Field, field_validator, model_validator
|
|
9
9
|
|
|
10
10
|
_AGENT_NAME_PATTERN = r"^[a-zA-Z0-9][a-zA-Z0-9_-]*$"
|
|
11
11
|
|
|
@@ -97,12 +97,67 @@ def validate_agent_name(name: str) -> str:
|
|
|
97
97
|
return name
|
|
98
98
|
|
|
99
99
|
|
|
100
|
+
class GroupMember(BaseModel):
|
|
101
|
+
"""One member of a dispatch group: a reference to an existing agent.
|
|
102
|
+
|
|
103
|
+
`agent` is the name of an agent in `DispatchConfig.agents`. `use_for` is a
|
|
104
|
+
short, group-contextual hint ("dispatch me when...") that helps the
|
|
105
|
+
orchestrating LLM route within the group. It is descriptive only — never
|
|
106
|
+
passed to the `claude` CLI.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
agent: str
|
|
110
|
+
use_for: str = ""
|
|
111
|
+
|
|
112
|
+
@field_validator("agent")
|
|
113
|
+
@classmethod
|
|
114
|
+
def _valid_agent(cls, v: str) -> str:
|
|
115
|
+
return validate_agent_name(v)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class DispatchGroup(BaseModel):
|
|
119
|
+
"""A named, descriptive group of agents for coordinated cross-project work.
|
|
120
|
+
|
|
121
|
+
A group is a *layer*, not an execution engine: there is no router and no
|
|
122
|
+
state machine — the orchestrating LLM coordinates using the normal dispatch
|
|
123
|
+
tools. The two text fields target two different audiences:
|
|
124
|
+
|
|
125
|
+
- `description` is ORCHESTRATOR-facing (how to coordinate the group, who to
|
|
126
|
+
call for what). It is surfaced by list_groups/inspect_group but is NEVER
|
|
127
|
+
injected into a member's prompt.
|
|
128
|
+
- `shared_context` is MEMBER-facing FACTS (stack names, counter ids,
|
|
129
|
+
conventions) that hold regardless of which member reads them. It is
|
|
130
|
+
auto-injected into dispatches made with `group=`.
|
|
131
|
+
|
|
132
|
+
`members` reference agents by name. Membership is many-to-many: a shared
|
|
133
|
+
gateway agent (e.g. infra, analytics) can belong to several groups.
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
description: str = ""
|
|
137
|
+
shared_context: str = ""
|
|
138
|
+
members: list[GroupMember] = Field(default_factory=list)
|
|
139
|
+
|
|
140
|
+
|
|
100
141
|
class DispatchConfig(BaseModel):
|
|
101
|
-
"""Top-level config: agents + settings."""
|
|
142
|
+
"""Top-level config: agents + groups + settings."""
|
|
102
143
|
|
|
103
144
|
agents: dict[str, AgentConfig] = Field(default_factory=dict)
|
|
145
|
+
groups: dict[str, DispatchGroup] = Field(default_factory=dict)
|
|
104
146
|
settings: Settings = Field(default_factory=Settings)
|
|
105
147
|
|
|
148
|
+
@model_validator(mode="after")
|
|
149
|
+
def _validate_group_names(self) -> DispatchConfig:
|
|
150
|
+
# Validate only the group KEYS (cheap, keeps prompt-label construction
|
|
151
|
+
# provably safe regardless of how the YAML was hand-authored).
|
|
152
|
+
# Deliberately does NOT check that each member's agent exists — gateway
|
|
153
|
+
# agents are shared and a hard cross-ref check would make removing one
|
|
154
|
+
# brick config load (every CLI command + MCP call dies in load_config).
|
|
155
|
+
# Dangling refs are flagged at read time (list_groups/inspect_group) and
|
|
156
|
+
# blocked at CLI mutation time instead.
|
|
157
|
+
for name in self.groups:
|
|
158
|
+
validate_agent_name(name)
|
|
159
|
+
return self
|
|
160
|
+
|
|
106
161
|
|
|
107
162
|
class DispatchResult(BaseModel):
|
|
108
163
|
"""Result of a dispatch call."""
|