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/cache.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""TTL-based in-memory cache for dispatch results."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
from .models import DispatchResult
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DispatchCache:
|
|
17
|
+
"""Thread-safe TTL cache for dispatch results.
|
|
18
|
+
|
|
19
|
+
Keyed on (agent, task, context) — identical requests within the TTL
|
|
20
|
+
window return the cached result without spawning a new subprocess.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, ttl: int = 300) -> None:
|
|
24
|
+
self._ttl = ttl
|
|
25
|
+
self._store: dict[str, tuple[float, DispatchResult]] = {}
|
|
26
|
+
self._lock = threading.Lock()
|
|
27
|
+
self._hits = 0
|
|
28
|
+
self._misses = 0
|
|
29
|
+
|
|
30
|
+
@staticmethod
|
|
31
|
+
def _make_key(agent: str, task: str, context: str | None) -> str:
|
|
32
|
+
canonical = json.dumps(
|
|
33
|
+
{"agent": agent, "task": task, "context": context or ""},
|
|
34
|
+
sort_keys=True,
|
|
35
|
+
)
|
|
36
|
+
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
|
37
|
+
|
|
38
|
+
def get(self, agent: str, task: str, context: str | None = None) -> DispatchResult | None:
|
|
39
|
+
key = self._make_key(agent, task, context)
|
|
40
|
+
with self._lock:
|
|
41
|
+
entry = self._store.get(key)
|
|
42
|
+
if entry is None:
|
|
43
|
+
self._misses += 1
|
|
44
|
+
return None
|
|
45
|
+
ts, result = entry
|
|
46
|
+
if time.monotonic() - ts > self._ttl:
|
|
47
|
+
del self._store[key]
|
|
48
|
+
self._misses += 1
|
|
49
|
+
return None
|
|
50
|
+
self._hits += 1
|
|
51
|
+
return result
|
|
52
|
+
|
|
53
|
+
def put(
|
|
54
|
+
self,
|
|
55
|
+
agent: str,
|
|
56
|
+
task: str,
|
|
57
|
+
result: DispatchResult,
|
|
58
|
+
context: str | None = None,
|
|
59
|
+
) -> None:
|
|
60
|
+
if not result.success:
|
|
61
|
+
return # don't cache failures
|
|
62
|
+
key = self._make_key(agent, task, context)
|
|
63
|
+
with self._lock:
|
|
64
|
+
self._store[key] = (time.monotonic(), result)
|
|
65
|
+
|
|
66
|
+
def clear(self) -> int:
|
|
67
|
+
with self._lock:
|
|
68
|
+
count = len(self._store)
|
|
69
|
+
self._store.clear()
|
|
70
|
+
self._hits = 0
|
|
71
|
+
self._misses = 0
|
|
72
|
+
return count
|
|
73
|
+
|
|
74
|
+
def evict_expired(self) -> int:
|
|
75
|
+
now = time.monotonic()
|
|
76
|
+
with self._lock:
|
|
77
|
+
expired = [k for k, (ts, _) in self._store.items() if now - ts > self._ttl]
|
|
78
|
+
for k in expired:
|
|
79
|
+
del self._store[k]
|
|
80
|
+
return len(expired)
|
|
81
|
+
|
|
82
|
+
def stats(self) -> dict:
|
|
83
|
+
with self._lock:
|
|
84
|
+
total = self._hits + self._misses
|
|
85
|
+
return {
|
|
86
|
+
"size": len(self._store),
|
|
87
|
+
"hits": self._hits,
|
|
88
|
+
"misses": self._misses,
|
|
89
|
+
"hit_rate": round(self._hits / total, 3) if total else 0.0,
|
|
90
|
+
"ttl": self._ttl,
|
|
91
|
+
}
|
agent_dispatch/cli.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""CLI: init, add, remove, list, test, serve."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
|
|
12
|
+
from .config import auto_describe, config_path, load_config, save_config
|
|
13
|
+
from .models import AgentConfig, DispatchConfig, validate_agent_name
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@click.group()
|
|
17
|
+
@click.version_option(package_name="agent-dispatch")
|
|
18
|
+
def cli() -> None:
|
|
19
|
+
"""Delegate tasks between Claude Code agents across projects."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@cli.command()
|
|
23
|
+
def init() -> None:
|
|
24
|
+
"""Create config file and register MCP server with Claude Code."""
|
|
25
|
+
cp = config_path()
|
|
26
|
+
|
|
27
|
+
# Create config with example
|
|
28
|
+
if cp.exists():
|
|
29
|
+
click.echo(f"Config already exists: {cp}")
|
|
30
|
+
else:
|
|
31
|
+
cp.parent.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
example = DispatchConfig()
|
|
33
|
+
save_config(example, cp)
|
|
34
|
+
click.echo(f"Created config: {cp}")
|
|
35
|
+
|
|
36
|
+
# Register MCP server with Claude Code
|
|
37
|
+
if shutil.which("claude") is None:
|
|
38
|
+
click.echo("Warning: claude CLI not found. Register MCP server manually.")
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
agent_dispatch_cmd = shutil.which("agent-dispatch")
|
|
42
|
+
if agent_dispatch_cmd is None:
|
|
43
|
+
click.echo(
|
|
44
|
+
"Warning: agent-dispatch not found in PATH. "
|
|
45
|
+
"Run 'pip install -e .' first, then re-run 'agent-dispatch init'."
|
|
46
|
+
)
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
mcp_config = json.dumps({
|
|
50
|
+
"type": "stdio",
|
|
51
|
+
"command": agent_dispatch_cmd,
|
|
52
|
+
"args": ["serve"],
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
result = subprocess.run(
|
|
56
|
+
["claude", "mcp", "add-json", "agent-dispatch", mcp_config, "--scope", "user"],
|
|
57
|
+
capture_output=True,
|
|
58
|
+
text=True,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
if result.returncode == 0:
|
|
62
|
+
click.echo("Registered MCP server with Claude Code (user scope).")
|
|
63
|
+
click.echo("\nNext steps:")
|
|
64
|
+
click.echo(" agent-dispatch add <name> <directory> # add your first agent")
|
|
65
|
+
click.echo(" agent-dispatch list # verify agents")
|
|
66
|
+
click.echo(" agent-dispatch test <name> # test it works")
|
|
67
|
+
else:
|
|
68
|
+
click.echo(f"Failed to register MCP server: {result.stderr.strip()}")
|
|
69
|
+
click.echo("You can register manually in ~/.claude/settings.json:")
|
|
70
|
+
click.echo(f' "mcpServers": {{ "agent-dispatch": {mcp_config} }}')
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@cli.command()
|
|
74
|
+
@click.argument("name")
|
|
75
|
+
@click.argument("directory", type=click.Path(exists=True, file_okay=False, resolve_path=True))
|
|
76
|
+
@click.option("-d", "--description", default=None, help="Agent description. Auto-generated if omitted.")
|
|
77
|
+
@click.option("--timeout", default=300, help="Timeout in seconds (default: 300).")
|
|
78
|
+
@click.option("--model", default=None, help="Model override for this agent.")
|
|
79
|
+
def add(
|
|
80
|
+
name: str,
|
|
81
|
+
directory: str,
|
|
82
|
+
description: str | None,
|
|
83
|
+
timeout: int,
|
|
84
|
+
model: str | None,
|
|
85
|
+
) -> None:
|
|
86
|
+
"""Add an agent. Auto-generates description from project files if omitted."""
|
|
87
|
+
try:
|
|
88
|
+
validate_agent_name(name)
|
|
89
|
+
except ValueError as e:
|
|
90
|
+
click.echo(f"Error: {e}")
|
|
91
|
+
raise SystemExit(1)
|
|
92
|
+
|
|
93
|
+
config = load_config()
|
|
94
|
+
dir_path = Path(directory).resolve()
|
|
95
|
+
|
|
96
|
+
if name in config.agents:
|
|
97
|
+
click.echo(f"Agent '{name}' already exists. Use 'agent-dispatch remove {name}' first.")
|
|
98
|
+
raise SystemExit(1)
|
|
99
|
+
|
|
100
|
+
if description is None:
|
|
101
|
+
description = auto_describe(dir_path)
|
|
102
|
+
click.echo(f"Auto-generated description: {description}")
|
|
103
|
+
|
|
104
|
+
config.agents[name] = AgentConfig(
|
|
105
|
+
directory=dir_path,
|
|
106
|
+
description=description,
|
|
107
|
+
timeout=timeout,
|
|
108
|
+
model=model,
|
|
109
|
+
)
|
|
110
|
+
save_config(config)
|
|
111
|
+
click.echo(f"Added agent '{name}' -> {dir_path}")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@cli.command()
|
|
115
|
+
@click.argument("name")
|
|
116
|
+
def remove(name: str) -> None:
|
|
117
|
+
"""Remove an agent."""
|
|
118
|
+
config = load_config()
|
|
119
|
+
if name not in config.agents:
|
|
120
|
+
click.echo(f"Agent '{name}' not found.")
|
|
121
|
+
raise SystemExit(1)
|
|
122
|
+
|
|
123
|
+
del config.agents[name]
|
|
124
|
+
save_config(config)
|
|
125
|
+
click.echo(f"Removed agent '{name}'.")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@cli.command("list")
|
|
129
|
+
def list_agents() -> None:
|
|
130
|
+
"""List configured agents with health status."""
|
|
131
|
+
config = load_config()
|
|
132
|
+
if not config.agents:
|
|
133
|
+
click.echo("No agents configured. Run: agent-dispatch add <name> <directory>")
|
|
134
|
+
return
|
|
135
|
+
|
|
136
|
+
for name, agent in config.agents.items():
|
|
137
|
+
healthy = agent.directory.is_dir()
|
|
138
|
+
status = click.style("OK", fg="green") if healthy else click.style("NOT FOUND", fg="red")
|
|
139
|
+
click.echo(f" {name} [{status}]")
|
|
140
|
+
click.echo(f" dir: {agent.directory}")
|
|
141
|
+
click.echo(f" desc: {agent.description}")
|
|
142
|
+
click.echo()
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@cli.command()
|
|
146
|
+
@click.argument("name")
|
|
147
|
+
@click.argument("task", default="What project is this? Describe in one sentence.")
|
|
148
|
+
def test(name: str, task: str) -> None:
|
|
149
|
+
"""Test an agent by dispatching a task."""
|
|
150
|
+
config = load_config()
|
|
151
|
+
if name not in config.agents:
|
|
152
|
+
click.echo(f"Agent '{name}' not found. Run 'agent-dispatch list' to see agents.")
|
|
153
|
+
raise SystemExit(1)
|
|
154
|
+
|
|
155
|
+
agent = config.agents[name]
|
|
156
|
+
click.echo(f"Dispatching to '{name}' ({agent.directory})...")
|
|
157
|
+
click.echo(f"Task: {task}")
|
|
158
|
+
click.echo("---")
|
|
159
|
+
|
|
160
|
+
from .runner import dispatch
|
|
161
|
+
|
|
162
|
+
result = dispatch(name, task, agent, config.settings)
|
|
163
|
+
|
|
164
|
+
if result.success:
|
|
165
|
+
click.echo(result.result)
|
|
166
|
+
if result.cost_usd is not None:
|
|
167
|
+
click.echo(f"\n--- Cost: ${result.cost_usd:.4f} | Turns: {result.num_turns}")
|
|
168
|
+
else:
|
|
169
|
+
click.echo(click.style(f"Error: {result.error}", fg="red"))
|
|
170
|
+
raise SystemExit(1)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@cli.command()
|
|
174
|
+
def serve() -> None:
|
|
175
|
+
"""Start the MCP server (stdio transport)."""
|
|
176
|
+
from .server import main
|
|
177
|
+
|
|
178
|
+
main()
|
agent_dispatch/config.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Configuration loading and saving."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
from .models import DispatchConfig
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
DEFAULT_CONFIG_DIR = Path.home() / ".config" / "agent-dispatch"
|
|
17
|
+
DEFAULT_CONFIG_PATH = DEFAULT_CONFIG_DIR / "agents.yaml"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def config_path() -> Path:
|
|
21
|
+
"""Return config path, respecting AGENT_DISPATCH_CONFIG env var."""
|
|
22
|
+
return Path(os.environ.get("AGENT_DISPATCH_CONFIG", str(DEFAULT_CONFIG_PATH)))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def load_config(path: Path | None = None) -> DispatchConfig:
|
|
26
|
+
"""Load config from YAML file. Returns empty config if file missing."""
|
|
27
|
+
p = path or config_path()
|
|
28
|
+
if not p.exists():
|
|
29
|
+
return DispatchConfig()
|
|
30
|
+
raw = yaml.safe_load(p.read_text(encoding="utf-8"))
|
|
31
|
+
if raw is None:
|
|
32
|
+
return DispatchConfig()
|
|
33
|
+
return DispatchConfig.model_validate(raw)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def save_config(config: DispatchConfig, path: Path | None = None) -> None:
|
|
37
|
+
"""Save config to YAML file."""
|
|
38
|
+
p = path or config_path()
|
|
39
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
data = config.model_dump(mode="json", exclude_none=True)
|
|
41
|
+
p.write_text(
|
|
42
|
+
yaml.dump(data, default_flow_style=False, allow_unicode=True, sort_keys=False),
|
|
43
|
+
encoding="utf-8",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _collect_mcp_servers(directory: Path) -> list[str]:
|
|
48
|
+
"""Collect MCP server names from all known config locations."""
|
|
49
|
+
servers: list[str] = []
|
|
50
|
+
for path in (
|
|
51
|
+
directory / ".mcp.json",
|
|
52
|
+
directory / ".claude" / "settings.local.json",
|
|
53
|
+
):
|
|
54
|
+
if path.exists():
|
|
55
|
+
try:
|
|
56
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
57
|
+
servers.extend(data.get("mcpServers", {}).keys())
|
|
58
|
+
except (json.JSONDecodeError, KeyError):
|
|
59
|
+
pass
|
|
60
|
+
return list(dict.fromkeys(servers)) # deduplicate, preserve order
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def auto_describe(directory: Path) -> str:
|
|
64
|
+
"""Generate agent description by reading project files.
|
|
65
|
+
|
|
66
|
+
Produces a string like:
|
|
67
|
+
MCP server for cross-project agent delegation | MCP: portainer, postgres | Python, Docker
|
|
68
|
+
"""
|
|
69
|
+
parts: list[str] = []
|
|
70
|
+
|
|
71
|
+
# CLAUDE.md — first meaningful lines (up to 2 sentences)
|
|
72
|
+
claude_md = directory / "CLAUDE.md"
|
|
73
|
+
if claude_md.exists():
|
|
74
|
+
sentences: list[str] = []
|
|
75
|
+
for line in claude_md.read_text(encoding="utf-8").strip().splitlines()[:40]:
|
|
76
|
+
stripped = line.strip()
|
|
77
|
+
if stripped and not stripped.startswith("#") and not stripped.startswith("--"):
|
|
78
|
+
sentences.append(stripped)
|
|
79
|
+
if len(sentences) >= 2:
|
|
80
|
+
break
|
|
81
|
+
if sentences:
|
|
82
|
+
parts.append(" ".join(sentences))
|
|
83
|
+
|
|
84
|
+
# README.md — fallback if no CLAUDE.md description
|
|
85
|
+
if not parts:
|
|
86
|
+
readme = directory / "README.md"
|
|
87
|
+
if readme.exists():
|
|
88
|
+
for line in readme.read_text(encoding="utf-8").strip().splitlines()[:20]:
|
|
89
|
+
stripped = line.strip()
|
|
90
|
+
if (
|
|
91
|
+
stripped
|
|
92
|
+
and not stripped.startswith("#")
|
|
93
|
+
and not stripped.startswith("[")
|
|
94
|
+
and not stripped.startswith("!")
|
|
95
|
+
and len(stripped) > 20
|
|
96
|
+
):
|
|
97
|
+
parts.append(stripped)
|
|
98
|
+
break
|
|
99
|
+
|
|
100
|
+
# pyproject.toml — project description
|
|
101
|
+
pyproject = directory / "pyproject.toml"
|
|
102
|
+
if pyproject.exists():
|
|
103
|
+
for line in pyproject.read_text(encoding="utf-8").splitlines():
|
|
104
|
+
if line.strip().startswith("description"):
|
|
105
|
+
desc = line.split("=", 1)[1].strip().strip('"').strip("'")
|
|
106
|
+
if desc:
|
|
107
|
+
parts.append(desc)
|
|
108
|
+
break
|
|
109
|
+
|
|
110
|
+
# package.json — project description
|
|
111
|
+
pkg_json = directory / "package.json"
|
|
112
|
+
if pkg_json.exists():
|
|
113
|
+
try:
|
|
114
|
+
pkg = json.loads(pkg_json.read_text(encoding="utf-8"))
|
|
115
|
+
if pkg.get("description"):
|
|
116
|
+
parts.append(pkg["description"])
|
|
117
|
+
except (json.JSONDecodeError, KeyError):
|
|
118
|
+
pass
|
|
119
|
+
|
|
120
|
+
# MCP servers — critical for understanding what tools this agent has
|
|
121
|
+
servers = _collect_mcp_servers(directory)
|
|
122
|
+
if servers:
|
|
123
|
+
parts.append(f"MCP: {', '.join(servers)}")
|
|
124
|
+
|
|
125
|
+
# Stack indicators
|
|
126
|
+
indicators = []
|
|
127
|
+
if (directory / "Dockerfile").exists():
|
|
128
|
+
indicators.append("Docker")
|
|
129
|
+
if (directory / "docker-compose.yaml").exists() or (directory / "docker-compose.yml").exists():
|
|
130
|
+
indicators.append("Docker Compose")
|
|
131
|
+
if (directory / "Cargo.toml").exists():
|
|
132
|
+
indicators.append("Rust")
|
|
133
|
+
if (directory / "go.mod").exists():
|
|
134
|
+
indicators.append("Go")
|
|
135
|
+
if (directory / "requirements.txt").exists() or pyproject.exists():
|
|
136
|
+
indicators.append("Python")
|
|
137
|
+
if pkg_json.exists():
|
|
138
|
+
indicators.append("Node.js")
|
|
139
|
+
if indicators:
|
|
140
|
+
parts.append(f"Stack: {', '.join(indicators)}")
|
|
141
|
+
|
|
142
|
+
# Database indicators
|
|
143
|
+
db_indicators = []
|
|
144
|
+
if (directory / "prisma").is_dir() or (directory / "schema.prisma").exists():
|
|
145
|
+
db_indicators.append("Prisma")
|
|
146
|
+
if (directory / "alembic").is_dir() or (directory / "alembic.ini").exists():
|
|
147
|
+
db_indicators.append("Alembic")
|
|
148
|
+
if (directory / "migrations").is_dir():
|
|
149
|
+
db_indicators.append("migrations")
|
|
150
|
+
if db_indicators:
|
|
151
|
+
parts.append(f"DB: {', '.join(db_indicators)}")
|
|
152
|
+
|
|
153
|
+
return " | ".join(parts) if parts else f"Agent in {directory.name}"
|
agent_dispatch/models.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Data models for agent-dispatch."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field, field_validator
|
|
8
|
+
|
|
9
|
+
_AGENT_NAME_PATTERN = r"^[a-zA-Z0-9][a-zA-Z0-9_-]*$"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AgentConfig(BaseModel):
|
|
13
|
+
"""Configuration for a single agent."""
|
|
14
|
+
|
|
15
|
+
directory: Path
|
|
16
|
+
description: str = ""
|
|
17
|
+
timeout: int = 300
|
|
18
|
+
max_budget_usd: float | None = None
|
|
19
|
+
model: str | None = None
|
|
20
|
+
permission_mode: str | None = None
|
|
21
|
+
allowed_tools: list[str] = Field(default_factory=list)
|
|
22
|
+
disallowed_tools: list[str] = Field(default_factory=list)
|
|
23
|
+
|
|
24
|
+
@field_validator("directory", mode="before")
|
|
25
|
+
@classmethod
|
|
26
|
+
def expand_home(cls, v: str | Path) -> Path:
|
|
27
|
+
return Path(v).expanduser().resolve()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class CacheSettings(BaseModel):
|
|
31
|
+
"""Cache configuration."""
|
|
32
|
+
|
|
33
|
+
enabled: bool = True
|
|
34
|
+
ttl: int = Field(default=300, ge=0) # seconds; 0 effectively disables
|
|
35
|
+
|
|
36
|
+
@field_validator("ttl", mode="after")
|
|
37
|
+
@classmethod
|
|
38
|
+
def warn_zero_ttl(cls, v: int) -> int:
|
|
39
|
+
# ttl=0 is valid (entries expire immediately) but likely a mistake.
|
|
40
|
+
# Let it through — cache.put() will store, cache.get() will evict.
|
|
41
|
+
return v
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Settings(BaseModel):
|
|
45
|
+
"""Global settings for agent-dispatch."""
|
|
46
|
+
|
|
47
|
+
default_timeout: int = 300
|
|
48
|
+
default_max_budget_usd: float | None = None
|
|
49
|
+
max_dispatch_depth: int = Field(default=3, ge=1)
|
|
50
|
+
max_concurrency: int = Field(default=5, ge=1)
|
|
51
|
+
cache: CacheSettings = Field(default_factory=CacheSettings)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def validate_agent_name(name: str) -> str:
|
|
55
|
+
"""Validate agent name: alphanumeric, hyphens, underscores, no leading special chars."""
|
|
56
|
+
import re
|
|
57
|
+
|
|
58
|
+
if not re.match(_AGENT_NAME_PATTERN, name):
|
|
59
|
+
raise ValueError(
|
|
60
|
+
f"Invalid agent name: {name!r}. "
|
|
61
|
+
"Use only letters, digits, hyphens, and underscores. "
|
|
62
|
+
"Must start with a letter or digit."
|
|
63
|
+
)
|
|
64
|
+
return name
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class DispatchConfig(BaseModel):
|
|
68
|
+
"""Top-level config: agents + settings."""
|
|
69
|
+
|
|
70
|
+
agents: dict[str, AgentConfig] = Field(default_factory=dict)
|
|
71
|
+
settings: Settings = Field(default_factory=Settings)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class DispatchResult(BaseModel):
|
|
75
|
+
"""Result of a dispatch call."""
|
|
76
|
+
|
|
77
|
+
agent: str
|
|
78
|
+
success: bool
|
|
79
|
+
result: str
|
|
80
|
+
session_id: str | None = None
|
|
81
|
+
cost_usd: float | None = None
|
|
82
|
+
duration_ms: int | None = None
|
|
83
|
+
num_turns: int | None = None
|
|
84
|
+
error: str | None = None
|