agent-dispatch 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agent_dispatch/__init__.py +3 -0
- agent_dispatch/cache.py +91 -0
- agent_dispatch/cli.py +178 -0
- agent_dispatch/config.py +153 -0
- agent_dispatch/models.py +84 -0
- agent_dispatch/runner.py +331 -0
- agent_dispatch/server.py +710 -0
- agent_dispatch-0.1.0.dist-info/METADATA +353 -0
- agent_dispatch-0.1.0.dist-info/RECORD +12 -0
- agent_dispatch-0.1.0.dist-info/WHEEL +4 -0
- agent_dispatch-0.1.0.dist-info/entry_points.txt +2 -0
- agent_dispatch-0.1.0.dist-info/licenses/LICENSE +21 -0
agent_dispatch/server.py
ADDED
|
@@ -0,0 +1,710 @@
|
|
|
1
|
+
"""MCP server: exposes list_agents, dispatch, dispatch_session tools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import queue
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
from mcp.server.fastmcp import Context, FastMCP
|
|
12
|
+
|
|
13
|
+
from . import runner
|
|
14
|
+
from .cache import DispatchCache
|
|
15
|
+
from .config import auto_describe, load_config, save_config
|
|
16
|
+
from .models import AgentConfig, DispatchConfig, validate_agent_name
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
mcp = FastMCP(
|
|
21
|
+
"agent-dispatch",
|
|
22
|
+
instructions=(
|
|
23
|
+
"This server lets you delegate tasks to Claude Code agents in other project "
|
|
24
|
+
"directories. Each agent has its own MCP servers, CLAUDE.md, and tools.\n\n"
|
|
25
|
+
"WHEN TO DISPATCH: Use dispatch when a task needs tools, files, or context "
|
|
26
|
+
"from another project — database queries, container logs, API calls, reading "
|
|
27
|
+
"code you don't have access to. Don't dispatch for things you can do yourself.\n\n"
|
|
28
|
+
"HOW TO USE:\n"
|
|
29
|
+
"1. list_agents() — see who's available and what they can do\n"
|
|
30
|
+
"2. dispatch(agent, task) — delegate a specific task\n"
|
|
31
|
+
"3. Always pass caller= (your project name) and goal= (why you need this)\n"
|
|
32
|
+
"4. Be specific in the task — the agent doesn't see your conversation\n\n"
|
|
33
|
+
"MANAGING AGENTS: Use add_agent() to register a new project directory. "
|
|
34
|
+
"The description is auto-generated from the project's files (CLAUDE.md, "
|
|
35
|
+
"MCP servers, package files). You can also provide a custom description."
|
|
36
|
+
),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
_cache: DispatchCache | None = None
|
|
40
|
+
_semaphore: asyncio.Semaphore | None = None
|
|
41
|
+
_semaphore_limit: int = 0
|
|
42
|
+
|
|
43
|
+
_RESOLVED_MARKER = "[RESOLVED]"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _get_config() -> DispatchConfig:
|
|
47
|
+
"""Load config fresh each call so new agents are picked up immediately."""
|
|
48
|
+
return load_config()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _get_cache(config: DispatchConfig) -> DispatchCache | None:
|
|
52
|
+
"""Return the global cache instance, creating it on first call."""
|
|
53
|
+
global _cache # noqa: PLW0603
|
|
54
|
+
if not config.settings.cache.enabled:
|
|
55
|
+
return None
|
|
56
|
+
if _cache is None or _cache._ttl != config.settings.cache.ttl:
|
|
57
|
+
_cache = DispatchCache(ttl=config.settings.cache.ttl)
|
|
58
|
+
return _cache
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _get_semaphore(config: DispatchConfig) -> asyncio.Semaphore:
|
|
62
|
+
"""Return concurrency-limiting semaphore, recreated if limit changes."""
|
|
63
|
+
global _semaphore, _semaphore_limit # noqa: PLW0603
|
|
64
|
+
limit = config.settings.max_concurrency
|
|
65
|
+
if _semaphore is None or _semaphore_limit != limit:
|
|
66
|
+
_semaphore = asyncio.Semaphore(limit)
|
|
67
|
+
_semaphore_limit = limit
|
|
68
|
+
return _semaphore
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _validate_agent(config: DispatchConfig, name: str) -> str | None:
|
|
72
|
+
"""Return an error JSON string if the agent doesn't exist, else None."""
|
|
73
|
+
if name not in config.agents:
|
|
74
|
+
available = ", ".join(config.agents.keys()) or "(none configured)"
|
|
75
|
+
return json.dumps({"error": f"Unknown agent: {name!r}. Available: {available}"})
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
# MCP Tools
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@mcp.tool()
|
|
85
|
+
async def list_agents(ctx: Context) -> str:
|
|
86
|
+
"""List all configured agents with descriptions and health status.
|
|
87
|
+
|
|
88
|
+
Call this first to see which agents are available and what they can do.
|
|
89
|
+
Use the agent name in dispatch() or dispatch_session() calls.
|
|
90
|
+
"""
|
|
91
|
+
config = _get_config()
|
|
92
|
+
if not config.agents:
|
|
93
|
+
return json.dumps(
|
|
94
|
+
{"error": "No agents configured. Run: agent-dispatch add <name> <directory>"},
|
|
95
|
+
indent=2,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
agents = []
|
|
99
|
+
for name, agent in config.agents.items():
|
|
100
|
+
healthy = agent.directory.is_dir()
|
|
101
|
+
agents.append(
|
|
102
|
+
{
|
|
103
|
+
"name": name,
|
|
104
|
+
"directory": str(agent.directory),
|
|
105
|
+
"description": agent.description,
|
|
106
|
+
"healthy": healthy,
|
|
107
|
+
"has_claude_md": (agent.directory / "CLAUDE.md").exists() if healthy else False,
|
|
108
|
+
"has_mcp_config": (agent.directory / ".mcp.json").exists() if healthy else False,
|
|
109
|
+
}
|
|
110
|
+
)
|
|
111
|
+
await ctx.info(f"Found {len(agents)} configured agents")
|
|
112
|
+
return json.dumps(agents, indent=2)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@mcp.tool()
|
|
116
|
+
async def dispatch(
|
|
117
|
+
agent: str,
|
|
118
|
+
task: str,
|
|
119
|
+
context: str = "",
|
|
120
|
+
caller: str = "",
|
|
121
|
+
goal: str = "",
|
|
122
|
+
ctx: Context | None = None,
|
|
123
|
+
) -> str:
|
|
124
|
+
"""Delegate a task to an agent in another project directory.
|
|
125
|
+
|
|
126
|
+
The agent runs as a separate Claude Code session with its own MCP servers,
|
|
127
|
+
CLAUDE.md, and project context. Results are cached by default.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
agent: Name of the agent (from list_agents).
|
|
131
|
+
task: The task to perform. Be specific and self-contained.
|
|
132
|
+
context: Optional extra context — error messages, code snippets, etc.
|
|
133
|
+
caller: Who is dispatching (your project/role) — helps the agent
|
|
134
|
+
understand the request.
|
|
135
|
+
goal: The broader objective this task serves — the agent can make
|
|
136
|
+
better trade-offs when it knows *why*.
|
|
137
|
+
"""
|
|
138
|
+
config = _get_config()
|
|
139
|
+
if err := _validate_agent(config, agent):
|
|
140
|
+
return err
|
|
141
|
+
|
|
142
|
+
# Check cache
|
|
143
|
+
cache = _get_cache(config)
|
|
144
|
+
if cache:
|
|
145
|
+
cached = cache.get(agent, task, context or None)
|
|
146
|
+
if cached:
|
|
147
|
+
if ctx:
|
|
148
|
+
await ctx.info(f"Cache hit for {agent} — returning cached result")
|
|
149
|
+
cached_dict = json.loads(cached.model_dump_json(indent=2, exclude_none=True))
|
|
150
|
+
cached_dict["cached"] = True
|
|
151
|
+
return json.dumps(cached_dict, indent=2)
|
|
152
|
+
|
|
153
|
+
agent_config = config.agents[agent]
|
|
154
|
+
if ctx:
|
|
155
|
+
await ctx.info(f"Dispatching to {agent}: {task[:80]}...")
|
|
156
|
+
|
|
157
|
+
async with _get_semaphore(config):
|
|
158
|
+
result = await asyncio.to_thread(
|
|
159
|
+
runner.dispatch,
|
|
160
|
+
agent,
|
|
161
|
+
task,
|
|
162
|
+
agent_config,
|
|
163
|
+
config.settings,
|
|
164
|
+
context or None,
|
|
165
|
+
caller=caller or None,
|
|
166
|
+
goal=goal or None,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
# Populate cache
|
|
170
|
+
if cache:
|
|
171
|
+
cache.put(agent, task, result, context or None)
|
|
172
|
+
|
|
173
|
+
return result.model_dump_json(indent=2, exclude_none=True)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@mcp.tool()
|
|
177
|
+
async def dispatch_session(
|
|
178
|
+
agent: str,
|
|
179
|
+
task: str,
|
|
180
|
+
session_id: str = "",
|
|
181
|
+
context: str = "",
|
|
182
|
+
caller: str = "",
|
|
183
|
+
goal: str = "",
|
|
184
|
+
ctx: Context | None = None,
|
|
185
|
+
) -> str:
|
|
186
|
+
"""Multi-turn dispatch: continue a conversation with an agent.
|
|
187
|
+
|
|
188
|
+
First call without session_id starts a new session. Use the returned
|
|
189
|
+
session_id in subsequent calls to continue the conversation — the agent
|
|
190
|
+
retains full context from previous turns.
|
|
191
|
+
|
|
192
|
+
Session dispatches are never cached because each turn builds on the prior.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
agent: Name of the agent.
|
|
196
|
+
task: The task or follow-up message.
|
|
197
|
+
session_id: Session ID from a previous call (empty for new session).
|
|
198
|
+
context: Optional extra context.
|
|
199
|
+
caller: Who is dispatching.
|
|
200
|
+
goal: The broader objective.
|
|
201
|
+
"""
|
|
202
|
+
config = _get_config()
|
|
203
|
+
if err := _validate_agent(config, agent):
|
|
204
|
+
return err
|
|
205
|
+
|
|
206
|
+
agent_config = config.agents[agent]
|
|
207
|
+
if ctx:
|
|
208
|
+
turn = "new session" if not session_id else f"resuming {session_id[:12]}..."
|
|
209
|
+
await ctx.info(f"Dispatching to {agent} ({turn}): {task[:80]}...")
|
|
210
|
+
|
|
211
|
+
async with _get_semaphore(config):
|
|
212
|
+
result = await asyncio.to_thread(
|
|
213
|
+
runner.dispatch,
|
|
214
|
+
agent,
|
|
215
|
+
task,
|
|
216
|
+
agent_config,
|
|
217
|
+
config.settings,
|
|
218
|
+
context or None,
|
|
219
|
+
session_id or None,
|
|
220
|
+
caller=caller or None,
|
|
221
|
+
goal=goal or None,
|
|
222
|
+
)
|
|
223
|
+
return result.model_dump_json(indent=2, exclude_none=True)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@mcp.tool()
|
|
227
|
+
async def dispatch_parallel(
|
|
228
|
+
dispatches: str,
|
|
229
|
+
aggregate: str = "",
|
|
230
|
+
ctx: Context | None = None,
|
|
231
|
+
) -> str:
|
|
232
|
+
"""Run multiple dispatch tasks in parallel and return all results at once.
|
|
233
|
+
|
|
234
|
+
Much faster than sequential dispatch() calls when you need answers from
|
|
235
|
+
several agents — all subprocesses run concurrently.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
dispatches: JSON array of requests, each with "agent", "task", and
|
|
239
|
+
optional "context", "caller", "goal". Example:
|
|
240
|
+
[
|
|
241
|
+
{"agent": "infra", "task": "check pod logs for errors"},
|
|
242
|
+
{"agent": "db", "task": "are all migrations applied?"}
|
|
243
|
+
]
|
|
244
|
+
aggregate: Optional agent name. When set, after all dispatches
|
|
245
|
+
complete their results are sent to this agent for synthesis
|
|
246
|
+
into a single coherent answer.
|
|
247
|
+
"""
|
|
248
|
+
try:
|
|
249
|
+
items = json.loads(dispatches)
|
|
250
|
+
except json.JSONDecodeError as e:
|
|
251
|
+
return json.dumps({"error": f"Invalid JSON in dispatches: {e}"})
|
|
252
|
+
|
|
253
|
+
if not isinstance(items, list) or not items:
|
|
254
|
+
return json.dumps({"error": "dispatches must be a non-empty JSON array"})
|
|
255
|
+
|
|
256
|
+
config = _get_config()
|
|
257
|
+
cache = _get_cache(config)
|
|
258
|
+
|
|
259
|
+
# Validate structure and agents up front (including aggregator)
|
|
260
|
+
for i, item in enumerate(items):
|
|
261
|
+
if not isinstance(item, dict):
|
|
262
|
+
return json.dumps({"error": f"dispatches[{i}] must be an object, got {type(item).__name__}"})
|
|
263
|
+
if "agent" not in item or "task" not in item:
|
|
264
|
+
return json.dumps({"error": f"dispatches[{i}] must have 'agent' and 'task' keys"})
|
|
265
|
+
if err := _validate_agent(config, item["agent"]):
|
|
266
|
+
return err
|
|
267
|
+
if aggregate:
|
|
268
|
+
if err := _validate_agent(config, aggregate):
|
|
269
|
+
return err
|
|
270
|
+
|
|
271
|
+
if ctx:
|
|
272
|
+
names = ", ".join(item["agent"] for item in items)
|
|
273
|
+
await ctx.info(f"Dispatching in parallel to: {names}")
|
|
274
|
+
|
|
275
|
+
async def _run_one(item: dict) -> dict:
|
|
276
|
+
name = item["agent"]
|
|
277
|
+
task = item["task"]
|
|
278
|
+
item_context = item.get("context") or None
|
|
279
|
+
item_caller = item.get("caller") or None
|
|
280
|
+
item_goal = item.get("goal") or None
|
|
281
|
+
|
|
282
|
+
# Check cache
|
|
283
|
+
if cache:
|
|
284
|
+
cached = cache.get(name, task, item_context)
|
|
285
|
+
if cached:
|
|
286
|
+
d = json.loads(cached.model_dump_json(exclude_none=True))
|
|
287
|
+
d["cached"] = True
|
|
288
|
+
return d
|
|
289
|
+
|
|
290
|
+
agent_config = config.agents[name]
|
|
291
|
+
async with _get_semaphore(config):
|
|
292
|
+
result = await asyncio.to_thread(
|
|
293
|
+
runner.dispatch,
|
|
294
|
+
name,
|
|
295
|
+
task,
|
|
296
|
+
agent_config,
|
|
297
|
+
config.settings,
|
|
298
|
+
item_context,
|
|
299
|
+
caller=item_caller,
|
|
300
|
+
goal=item_goal,
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
if cache:
|
|
304
|
+
cache.put(name, task, result, item_context)
|
|
305
|
+
|
|
306
|
+
return json.loads(result.model_dump_json(exclude_none=True))
|
|
307
|
+
|
|
308
|
+
results = await asyncio.gather(*[_run_one(item) for item in items], return_exceptions=True)
|
|
309
|
+
|
|
310
|
+
output = []
|
|
311
|
+
for item, res in zip(items, results):
|
|
312
|
+
if isinstance(res, Exception):
|
|
313
|
+
output.append({
|
|
314
|
+
"agent": item["agent"],
|
|
315
|
+
"success": False,
|
|
316
|
+
"result": "",
|
|
317
|
+
"error": str(res),
|
|
318
|
+
})
|
|
319
|
+
else:
|
|
320
|
+
output.append(res)
|
|
321
|
+
|
|
322
|
+
# ---- Aggregation ----
|
|
323
|
+
if not aggregate:
|
|
324
|
+
return json.dumps(output, indent=2)
|
|
325
|
+
|
|
326
|
+
# Build a summary for the aggregator agent
|
|
327
|
+
parts = []
|
|
328
|
+
for item, res in zip(items, output):
|
|
329
|
+
status = "OK" if res.get("success") else "FAILED"
|
|
330
|
+
parts.append(f"## Agent: {item['agent']} [{status}]\n{res.get('result') or res.get('error', '')}")
|
|
331
|
+
summary = "\n\n".join(parts)
|
|
332
|
+
|
|
333
|
+
if ctx:
|
|
334
|
+
await ctx.info(f"Aggregating results via {aggregate}...")
|
|
335
|
+
|
|
336
|
+
agg_task = "Synthesize the results below into a single coherent answer. Highlight key findings, note any conflicts between agents, and provide actionable conclusions."
|
|
337
|
+
agg_config = config.agents[aggregate]
|
|
338
|
+
async with _get_semaphore(config):
|
|
339
|
+
agg_result = await asyncio.to_thread(
|
|
340
|
+
runner.dispatch,
|
|
341
|
+
aggregate,
|
|
342
|
+
agg_task,
|
|
343
|
+
agg_config,
|
|
344
|
+
config.settings,
|
|
345
|
+
summary,
|
|
346
|
+
caller="dispatch_parallel",
|
|
347
|
+
goal="aggregate parallel dispatch results",
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
return json.dumps(
|
|
351
|
+
{
|
|
352
|
+
"individual_results": output,
|
|
353
|
+
"aggregated": json.loads(agg_result.model_dump_json(exclude_none=True)),
|
|
354
|
+
},
|
|
355
|
+
indent=2,
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
@mcp.tool()
|
|
360
|
+
async def dispatch_stream(
|
|
361
|
+
agent: str,
|
|
362
|
+
task: str,
|
|
363
|
+
context: str = "",
|
|
364
|
+
caller: str = "",
|
|
365
|
+
goal: str = "",
|
|
366
|
+
ctx: Context | None = None,
|
|
367
|
+
) -> str:
|
|
368
|
+
"""Dispatch with streaming progress — see live updates as the agent works.
|
|
369
|
+
|
|
370
|
+
Same as dispatch() but shows intermediate progress via log messages.
|
|
371
|
+
Use this for long-running tasks where you want to monitor what the agent
|
|
372
|
+
is doing while it works.
|
|
373
|
+
|
|
374
|
+
Args:
|
|
375
|
+
agent: Name of the agent.
|
|
376
|
+
task: The task to perform.
|
|
377
|
+
context: Optional extra context.
|
|
378
|
+
caller: Who is dispatching.
|
|
379
|
+
goal: The broader objective.
|
|
380
|
+
"""
|
|
381
|
+
config = _get_config()
|
|
382
|
+
if err := _validate_agent(config, agent):
|
|
383
|
+
return err
|
|
384
|
+
|
|
385
|
+
agent_config = config.agents[agent]
|
|
386
|
+
if ctx:
|
|
387
|
+
await ctx.info(f"Dispatching (stream) to {agent}: {task[:80]}...")
|
|
388
|
+
|
|
389
|
+
progress_queue: queue.Queue[str] = queue.Queue()
|
|
390
|
+
|
|
391
|
+
def on_progress(msg: str) -> None:
|
|
392
|
+
progress_queue.put(msg)
|
|
393
|
+
|
|
394
|
+
async with _get_semaphore(config):
|
|
395
|
+
loop = asyncio.get_running_loop()
|
|
396
|
+
future = loop.run_in_executor(
|
|
397
|
+
None,
|
|
398
|
+
lambda: runner.dispatch_stream(
|
|
399
|
+
agent,
|
|
400
|
+
task,
|
|
401
|
+
agent_config,
|
|
402
|
+
config.settings,
|
|
403
|
+
context or None,
|
|
404
|
+
on_progress,
|
|
405
|
+
caller=caller or None,
|
|
406
|
+
goal=goal or None,
|
|
407
|
+
),
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
# Forward progress messages while the subprocess runs
|
|
411
|
+
while not future.done():
|
|
412
|
+
await asyncio.sleep(0.1)
|
|
413
|
+
while not progress_queue.empty():
|
|
414
|
+
msg = progress_queue.get_nowait()
|
|
415
|
+
if ctx:
|
|
416
|
+
await ctx.info(f"[{agent}] {msg[:300]}")
|
|
417
|
+
|
|
418
|
+
result = await asyncio.wrap_future(future)
|
|
419
|
+
|
|
420
|
+
# Drain any remaining messages
|
|
421
|
+
while not progress_queue.empty():
|
|
422
|
+
msg = progress_queue.get_nowait()
|
|
423
|
+
if ctx:
|
|
424
|
+
await ctx.info(f"[{agent}] {msg[:300]}")
|
|
425
|
+
|
|
426
|
+
return result.model_dump_json(indent=2, exclude_none=True)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
# ---------------------------------------------------------------------------
|
|
430
|
+
# Agent-to-agent dialogue
|
|
431
|
+
# ---------------------------------------------------------------------------
|
|
432
|
+
|
|
433
|
+
_DIALOGUE_INITIAL = (
|
|
434
|
+
"You are starting a collaborative dialogue with agent '{other}'.\n"
|
|
435
|
+
"Provide your analysis or ask questions. When you have a complete answer "
|
|
436
|
+
"and no further questions, end your response with {marker}.\n\n"
|
|
437
|
+
"Topic:\n{topic}"
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
_DIALOGUE_REPLY = (
|
|
441
|
+
"Agent '{other}' responds:\n\n{message}\n\n"
|
|
442
|
+
"Continue the discussion. If you have a complete answer and no further "
|
|
443
|
+
"questions, end your response with {marker}."
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
@mcp.tool()
|
|
448
|
+
async def dispatch_dialogue(
|
|
449
|
+
requester: str,
|
|
450
|
+
responder: str,
|
|
451
|
+
topic: str,
|
|
452
|
+
max_rounds: int = 3,
|
|
453
|
+
ctx: Context | None = None,
|
|
454
|
+
) -> str:
|
|
455
|
+
"""Two agents collaborate through multi-turn dialogue.
|
|
456
|
+
|
|
457
|
+
*requester* poses a problem/question, *responder* provides expertise.
|
|
458
|
+
They alternate turns until one signals completion with [RESOLVED] or
|
|
459
|
+
max_rounds is reached. Each agent maintains context via session IDs
|
|
460
|
+
so the conversation builds naturally.
|
|
461
|
+
|
|
462
|
+
Cost: up to 2 dispatches per round (one per agent).
|
|
463
|
+
|
|
464
|
+
Args:
|
|
465
|
+
requester: Agent with the problem/context.
|
|
466
|
+
responder: Agent with the expertise/tools to help.
|
|
467
|
+
topic: The problem or question to discuss.
|
|
468
|
+
max_rounds: Maximum back-and-forth rounds (default 3, max 10).
|
|
469
|
+
"""
|
|
470
|
+
config = _get_config()
|
|
471
|
+
for name in (requester, responder):
|
|
472
|
+
if err := _validate_agent(config, name):
|
|
473
|
+
return err
|
|
474
|
+
|
|
475
|
+
max_rounds = max(1, min(max_rounds, 10))
|
|
476
|
+
conversation: list[dict] = []
|
|
477
|
+
total_cost = 0.0
|
|
478
|
+
total_duration = 0
|
|
479
|
+
session_responder: str | None = None
|
|
480
|
+
session_requester: str | None = None
|
|
481
|
+
resolved = False
|
|
482
|
+
final_answer = ""
|
|
483
|
+
|
|
484
|
+
if ctx:
|
|
485
|
+
await ctx.info(
|
|
486
|
+
f"Starting dialogue: {requester} <-> {responder} (max {max_rounds} rounds)"
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
for round_num in range(1, max_rounds + 1):
|
|
490
|
+
# ---- Responder turn ----
|
|
491
|
+
if round_num == 1:
|
|
492
|
+
resp_task = _DIALOGUE_INITIAL.format(
|
|
493
|
+
other=requester, topic=topic, marker=_RESOLVED_MARKER
|
|
494
|
+
)
|
|
495
|
+
else:
|
|
496
|
+
resp_task = _DIALOGUE_REPLY.format(
|
|
497
|
+
other=requester,
|
|
498
|
+
message=conversation[-1]["message"],
|
|
499
|
+
marker=_RESOLVED_MARKER,
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
resp_config = config.agents[responder]
|
|
503
|
+
async with _get_semaphore(config):
|
|
504
|
+
resp_result = await asyncio.to_thread(
|
|
505
|
+
runner.dispatch,
|
|
506
|
+
responder,
|
|
507
|
+
resp_task,
|
|
508
|
+
resp_config,
|
|
509
|
+
config.settings,
|
|
510
|
+
session_id=session_responder,
|
|
511
|
+
caller=requester,
|
|
512
|
+
goal=topic[:200],
|
|
513
|
+
)
|
|
514
|
+
session_responder = resp_result.session_id
|
|
515
|
+
total_cost += resp_result.cost_usd or 0
|
|
516
|
+
total_duration += resp_result.duration_ms or 0
|
|
517
|
+
|
|
518
|
+
conversation.append({
|
|
519
|
+
"agent": responder,
|
|
520
|
+
"role": "responder",
|
|
521
|
+
"round": round_num,
|
|
522
|
+
"message": resp_result.result,
|
|
523
|
+
"cost_usd": resp_result.cost_usd,
|
|
524
|
+
})
|
|
525
|
+
|
|
526
|
+
if ctx:
|
|
527
|
+
await ctx.info(
|
|
528
|
+
f"[round {round_num}] {responder}: {resp_result.result[:120]}..."
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
if _RESOLVED_MARKER in resp_result.result or not resp_result.success:
|
|
532
|
+
resolved = _RESOLVED_MARKER in resp_result.result
|
|
533
|
+
final_answer = resp_result.result.replace(_RESOLVED_MARKER, "").strip()
|
|
534
|
+
break
|
|
535
|
+
|
|
536
|
+
# ---- Requester turn ----
|
|
537
|
+
req_task = _DIALOGUE_REPLY.format(
|
|
538
|
+
other=responder,
|
|
539
|
+
message=resp_result.result,
|
|
540
|
+
marker=_RESOLVED_MARKER,
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
req_config = config.agents[requester]
|
|
544
|
+
async with _get_semaphore(config):
|
|
545
|
+
req_result = await asyncio.to_thread(
|
|
546
|
+
runner.dispatch,
|
|
547
|
+
requester,
|
|
548
|
+
req_task,
|
|
549
|
+
req_config,
|
|
550
|
+
config.settings,
|
|
551
|
+
session_id=session_requester,
|
|
552
|
+
caller=responder,
|
|
553
|
+
goal=topic[:200],
|
|
554
|
+
)
|
|
555
|
+
session_requester = req_result.session_id
|
|
556
|
+
total_cost += req_result.cost_usd or 0
|
|
557
|
+
total_duration += req_result.duration_ms or 0
|
|
558
|
+
|
|
559
|
+
conversation.append({
|
|
560
|
+
"agent": requester,
|
|
561
|
+
"role": "requester",
|
|
562
|
+
"round": round_num,
|
|
563
|
+
"message": req_result.result,
|
|
564
|
+
"cost_usd": req_result.cost_usd,
|
|
565
|
+
})
|
|
566
|
+
|
|
567
|
+
if ctx:
|
|
568
|
+
await ctx.info(
|
|
569
|
+
f"[round {round_num}] {requester}: {req_result.result[:120]}..."
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
if _RESOLVED_MARKER in req_result.result or not req_result.success:
|
|
573
|
+
resolved = _RESOLVED_MARKER in req_result.result
|
|
574
|
+
final_answer = req_result.result.replace(_RESOLVED_MARKER, "").strip()
|
|
575
|
+
break
|
|
576
|
+
|
|
577
|
+
if not final_answer and conversation:
|
|
578
|
+
final_answer = conversation[-1]["message"]
|
|
579
|
+
|
|
580
|
+
return json.dumps(
|
|
581
|
+
{
|
|
582
|
+
"resolved": resolved,
|
|
583
|
+
"rounds": conversation[-1]["round"] if conversation else 0,
|
|
584
|
+
"total_cost_usd": round(total_cost, 4),
|
|
585
|
+
"total_duration_ms": total_duration,
|
|
586
|
+
"final_answer": final_answer,
|
|
587
|
+
"conversation": conversation,
|
|
588
|
+
},
|
|
589
|
+
indent=2,
|
|
590
|
+
)
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
# ---------------------------------------------------------------------------
|
|
594
|
+
# Agent management
|
|
595
|
+
# ---------------------------------------------------------------------------
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
@mcp.tool()
|
|
599
|
+
async def add_agent(
|
|
600
|
+
name: str,
|
|
601
|
+
directory: str,
|
|
602
|
+
description: str = "",
|
|
603
|
+
ctx: Context | None = None,
|
|
604
|
+
) -> str:
|
|
605
|
+
"""Register a project directory as a dispatchable agent.
|
|
606
|
+
|
|
607
|
+
The directory must exist. If no description is provided, one is
|
|
608
|
+
auto-generated from the project's files (CLAUDE.md, MCP servers,
|
|
609
|
+
package.json/pyproject.toml, stack indicators).
|
|
610
|
+
|
|
611
|
+
Args:
|
|
612
|
+
name: Agent name (letters, digits, hyphens, underscores; must start
|
|
613
|
+
with letter or digit).
|
|
614
|
+
directory: Absolute path to the project directory.
|
|
615
|
+
description: What this agent can do. Leave empty for auto-generation.
|
|
616
|
+
"""
|
|
617
|
+
try:
|
|
618
|
+
validate_agent_name(name)
|
|
619
|
+
except ValueError as e:
|
|
620
|
+
return json.dumps({"error": str(e)})
|
|
621
|
+
|
|
622
|
+
from pathlib import Path
|
|
623
|
+
|
|
624
|
+
dir_path = Path(directory).expanduser().resolve()
|
|
625
|
+
if not dir_path.is_dir():
|
|
626
|
+
return json.dumps({"error": f"Directory does not exist: {dir_path}"})
|
|
627
|
+
|
|
628
|
+
config = _get_config()
|
|
629
|
+
if name in config.agents:
|
|
630
|
+
return json.dumps({"error": f"Agent '{name}' already exists. Remove it first."})
|
|
631
|
+
|
|
632
|
+
desc = description or auto_describe(dir_path)
|
|
633
|
+
|
|
634
|
+
config.agents[name] = AgentConfig(directory=dir_path, description=desc)
|
|
635
|
+
save_config(config)
|
|
636
|
+
|
|
637
|
+
if ctx:
|
|
638
|
+
await ctx.info(f"Added agent '{name}' -> {dir_path}")
|
|
639
|
+
|
|
640
|
+
return json.dumps(
|
|
641
|
+
{
|
|
642
|
+
"added": name,
|
|
643
|
+
"directory": str(dir_path),
|
|
644
|
+
"description": desc,
|
|
645
|
+
},
|
|
646
|
+
indent=2,
|
|
647
|
+
)
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
@mcp.tool()
|
|
651
|
+
async def remove_agent(
|
|
652
|
+
name: str,
|
|
653
|
+
ctx: Context | None = None,
|
|
654
|
+
) -> str:
|
|
655
|
+
"""Remove an agent from the dispatch configuration.
|
|
656
|
+
|
|
657
|
+
Args:
|
|
658
|
+
name: Agent name to remove.
|
|
659
|
+
"""
|
|
660
|
+
config = _get_config()
|
|
661
|
+
if name not in config.agents:
|
|
662
|
+
available = ", ".join(config.agents.keys()) or "(none)"
|
|
663
|
+
return json.dumps({"error": f"Agent '{name}' not found. Available: {available}"})
|
|
664
|
+
|
|
665
|
+
del config.agents[name]
|
|
666
|
+
save_config(config)
|
|
667
|
+
|
|
668
|
+
if ctx:
|
|
669
|
+
await ctx.info(f"Removed agent '{name}'")
|
|
670
|
+
|
|
671
|
+
return json.dumps({"removed": name})
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
# ---------------------------------------------------------------------------
|
|
675
|
+
# Cache tools
|
|
676
|
+
# ---------------------------------------------------------------------------
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
@mcp.tool()
|
|
680
|
+
async def cache_stats(ctx: Context | None = None) -> str:
|
|
681
|
+
"""Show dispatch cache statistics: size, hit rate, TTL."""
|
|
682
|
+
config = _get_config()
|
|
683
|
+
cache = _get_cache(config)
|
|
684
|
+
if cache is None:
|
|
685
|
+
return json.dumps({"enabled": False, "message": "Cache is disabled in settings"})
|
|
686
|
+
cache.evict_expired()
|
|
687
|
+
return json.dumps(cache.stats(), indent=2)
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
@mcp.tool()
|
|
691
|
+
async def cache_clear(ctx: Context | None = None) -> str:
|
|
692
|
+
"""Clear all cached dispatch results."""
|
|
693
|
+
config = _get_config()
|
|
694
|
+
cache = _get_cache(config)
|
|
695
|
+
if cache is None:
|
|
696
|
+
return json.dumps({"enabled": False, "message": "Cache is disabled in settings"})
|
|
697
|
+
count = cache.clear()
|
|
698
|
+
if ctx:
|
|
699
|
+
await ctx.info(f"Cleared {count} cached entries")
|
|
700
|
+
return json.dumps({"cleared": count})
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def main() -> None:
|
|
704
|
+
"""Entry point for the MCP server."""
|
|
705
|
+
logging.basicConfig(
|
|
706
|
+
level=logging.INFO,
|
|
707
|
+
stream=sys.stderr,
|
|
708
|
+
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
709
|
+
)
|
|
710
|
+
mcp.run(transport="stdio")
|