subagent-fleet 0.0.1__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.
- subagent_fleet/__init__.py +3 -0
- subagent_fleet/cli.py +331 -0
- subagent_fleet/config.py +163 -0
- subagent_fleet/defaults.py +82 -0
- subagent_fleet/discovery.py +123 -0
- subagent_fleet/generators/__init__.py +5 -0
- subagent_fleet/generators/claude_agents.py +18 -0
- subagent_fleet/generators/common.py +40 -0
- subagent_fleet/generators/env_file.py +19 -0
- subagent_fleet/generators/litellm.py +13 -0
- subagent_fleet/plugins.py +218 -0
- subagent_fleet/skill_templates/subagent-fleet-bootstrap.md +75 -0
- subagent_fleet/skill_templates/subagent-fleet-operations.md +83 -0
- subagent_fleet/skill_templates/subagent-fleet-setup.md +76 -0
- subagent_fleet/skills.py +135 -0
- subagent_fleet/status.py +45 -0
- subagent_fleet/templates/claude_agent.md.j2 +9 -0
- subagent_fleet/templates/env.subagent-fleet.j2 +14 -0
- subagent_fleet/templates/litellm_config.yaml.j2 +23 -0
- subagent_fleet/warmup.py +67 -0
- subagent_fleet-0.0.1.dist-info/METADATA +620 -0
- subagent_fleet-0.0.1.dist-info/RECORD +25 -0
- subagent_fleet-0.0.1.dist-info/WHEEL +4 -0
- subagent_fleet-0.0.1.dist-info/entry_points.txt +2 -0
- subagent_fleet-0.0.1.dist-info/licenses/LICENSE +21 -0
subagent_fleet/cli.py
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
|
|
11
|
+
from subagent_fleet.config import ConfigError, FleetConfig, config_to_plain_dict, load_config
|
|
12
|
+
from subagent_fleet.defaults import STARTER_FLEET_YAML
|
|
13
|
+
from subagent_fleet.discovery import discovery_to_json, discover_fleet
|
|
14
|
+
from subagent_fleet.generators import generate_claude_agents, generate_env_file, generate_litellm_config
|
|
15
|
+
from subagent_fleet.plugins import PluginInstallError, install_plugin_marketplaces
|
|
16
|
+
from subagent_fleet.skills import (
|
|
17
|
+
ASSISTANT_TARGETS,
|
|
18
|
+
BUNDLED_SKILLS,
|
|
19
|
+
SkillInstallError,
|
|
20
|
+
install_skills,
|
|
21
|
+
)
|
|
22
|
+
from subagent_fleet.status import get_status, routes_to_json
|
|
23
|
+
from subagent_fleet.warmup import warmup_models
|
|
24
|
+
|
|
25
|
+
app = typer.Typer(help="Run Claude Code-style subagents across your local model fleet.", no_args_is_help=True)
|
|
26
|
+
skills_app = typer.Typer(help="Install assistant skills for using subagent-fleet.", no_args_is_help=True)
|
|
27
|
+
plugins_app = typer.Typer(help="Install assistant plugin marketplace bundles.", no_args_is_help=True)
|
|
28
|
+
app.add_typer(skills_app, name="skills")
|
|
29
|
+
app.add_typer(plugins_app, name="plugins")
|
|
30
|
+
console = Console()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _load_or_exit(path: Path) -> FleetConfig:
|
|
34
|
+
try:
|
|
35
|
+
return load_config(path)
|
|
36
|
+
except ConfigError as exc:
|
|
37
|
+
console.print(f"[red]Invalid {path}:[/red]\n\n{exc}")
|
|
38
|
+
raise typer.Exit(1) from exc
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@app.command()
|
|
42
|
+
def init(
|
|
43
|
+
output: Annotated[Path, typer.Option("--output", help="Path to write the starter fleet config.")] = Path("fleet.yaml"),
|
|
44
|
+
force: Annotated[bool, typer.Option("--force", help="Overwrite an existing config file.")] = False,
|
|
45
|
+
) -> None:
|
|
46
|
+
"""Create a starter fleet.yaml."""
|
|
47
|
+
if output.exists() and not force:
|
|
48
|
+
console.print(f"[red]{output} already exists. Use --force to overwrite.[/red]")
|
|
49
|
+
raise typer.Exit(1)
|
|
50
|
+
output.write_text(STARTER_FLEET_YAML)
|
|
51
|
+
console.print(f"Created {output}")
|
|
52
|
+
console.print("\nEdit it with your Ollama node endpoints, then run:\n")
|
|
53
|
+
console.print(" subagent-fleet discover")
|
|
54
|
+
console.print(" subagent-fleet generate")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@app.command()
|
|
58
|
+
def validate(config: Annotated[Path, typer.Option("--config", help="Path to fleet.yaml.")] = Path("fleet.yaml")) -> None:
|
|
59
|
+
"""Validate fleet.yaml."""
|
|
60
|
+
fleet = _load_or_exit(config)
|
|
61
|
+
console.print(f"{config} is valid.")
|
|
62
|
+
for warning in fleet.alias_warnings():
|
|
63
|
+
console.print(f"[yellow]Warning:[/yellow] {warning}")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@app.command()
|
|
67
|
+
def discover(
|
|
68
|
+
config: Annotated[Path, typer.Option("--config", help="Path to fleet.yaml.")] = Path("fleet.yaml"),
|
|
69
|
+
as_json: Annotated[bool, typer.Option("--json", help="Print machine-readable JSON.")] = False,
|
|
70
|
+
write: Annotated[bool, typer.Option("--write", help="Write .subagent-fleet/discovery.json.")] = False,
|
|
71
|
+
verbose: Annotated[bool, typer.Option("--verbose", "-v", help="Show connection errors for offline nodes.")] = False,
|
|
72
|
+
) -> None:
|
|
73
|
+
"""Discover models available on configured Ollama nodes."""
|
|
74
|
+
fleet = _load_or_exit(config)
|
|
75
|
+
results = discover_fleet(fleet)
|
|
76
|
+
payload = {"fleet": fleet.project.name, "nodes": discovery_to_json(results)}
|
|
77
|
+
|
|
78
|
+
if write:
|
|
79
|
+
discovery_path = Path(".subagent-fleet/discovery.json")
|
|
80
|
+
discovery_path.parent.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
discovery_path.write_text(json.dumps(payload, indent=2) + "\n")
|
|
82
|
+
|
|
83
|
+
if as_json:
|
|
84
|
+
console.print(json.dumps(payload, indent=2))
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
console.print(f"Fleet: {fleet.project.name}\n")
|
|
88
|
+
table = Table(show_header=True, header_style="bold")
|
|
89
|
+
table.add_column("Node")
|
|
90
|
+
table.add_column("Status")
|
|
91
|
+
table.add_column("Models")
|
|
92
|
+
if verbose:
|
|
93
|
+
table.add_column("Error")
|
|
94
|
+
for result in results:
|
|
95
|
+
status = "[green]online[/green]" if result.online else "[red]offline[/red]"
|
|
96
|
+
row = [result.name, status, ", ".join(result.models) if result.models else "-"]
|
|
97
|
+
if verbose:
|
|
98
|
+
row.append(result.error or "")
|
|
99
|
+
table.add_row(*row)
|
|
100
|
+
console.print(table)
|
|
101
|
+
if write:
|
|
102
|
+
console.print(f"\nWrote {discovery_path}")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@app.command()
|
|
106
|
+
def generate(
|
|
107
|
+
config: Annotated[Path, typer.Option("--config", help="Path to fleet.yaml.")] = Path("fleet.yaml"),
|
|
108
|
+
out: Annotated[Path, typer.Option("--out", help="Output root.")] = Path("."),
|
|
109
|
+
litellm_only: Annotated[bool, typer.Option("--litellm-only", help="Only generate LiteLLM config.")] = False,
|
|
110
|
+
claude_only: Annotated[bool, typer.Option("--claude-only", help="Only generate Claude agent files.")] = False,
|
|
111
|
+
force: Annotated[bool, typer.Option("--force", help="Overwrite generated files.")] = False,
|
|
112
|
+
) -> None:
|
|
113
|
+
"""Generate LiteLLM, Claude Code agent, and environment files."""
|
|
114
|
+
if litellm_only and claude_only:
|
|
115
|
+
console.print("[red]Use at most one of --litellm-only or --claude-only.[/red]")
|
|
116
|
+
raise typer.Exit(1)
|
|
117
|
+
|
|
118
|
+
fleet = _load_or_exit(config)
|
|
119
|
+
source = str(config)
|
|
120
|
+
generated: list[Path] = []
|
|
121
|
+
try:
|
|
122
|
+
if not claude_only:
|
|
123
|
+
generated.append(generate_litellm_config(fleet, out / "litellm_config.yaml", source=source, force=force))
|
|
124
|
+
if not litellm_only:
|
|
125
|
+
generated.extend(generate_claude_agents(fleet, out / ".claude" / "agents", source=source, force=force))
|
|
126
|
+
if not claude_only:
|
|
127
|
+
generated.append(generate_env_file(fleet, out / ".env.subagent-fleet", source=source, force=force))
|
|
128
|
+
except FileExistsError as exc:
|
|
129
|
+
console.print(f"[red]{exc}[/red]")
|
|
130
|
+
raise typer.Exit(1) from exc
|
|
131
|
+
|
|
132
|
+
console.print("Generated:")
|
|
133
|
+
for path in generated:
|
|
134
|
+
console.print(f" {path}")
|
|
135
|
+
if not claude_only:
|
|
136
|
+
console.print("\nStart LiteLLM with:")
|
|
137
|
+
console.print(f" litellm --config {out / 'litellm_config.yaml'} --host {fleet.project.gateway.host} --port {fleet.project.gateway.port}")
|
|
138
|
+
if not litellm_only and not claude_only:
|
|
139
|
+
console.print("\nThen run:")
|
|
140
|
+
console.print(" source .env.subagent-fleet")
|
|
141
|
+
console.print(" claude")
|
|
142
|
+
for warning in fleet.alias_warnings():
|
|
143
|
+
console.print(f"[yellow]Warning:[/yellow] {warning}")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@app.command()
|
|
147
|
+
def warmup(
|
|
148
|
+
config: Annotated[Path, typer.Option("--config", help="Path to fleet.yaml.")] = Path("fleet.yaml"),
|
|
149
|
+
model: Annotated[str | None, typer.Option("--model", help="Only warm this configured model name.")] = None,
|
|
150
|
+
agent: Annotated[str | None, typer.Option("--agent", help="Only warm the model used by this agent.")] = None,
|
|
151
|
+
) -> None:
|
|
152
|
+
"""Preload configured Ollama models."""
|
|
153
|
+
fleet = _load_or_exit(config)
|
|
154
|
+
try:
|
|
155
|
+
results = warmup_models(fleet, model_name=model, agent_name=agent)
|
|
156
|
+
except ValueError as exc:
|
|
157
|
+
console.print(f"[red]{exc}[/red]")
|
|
158
|
+
raise typer.Exit(1) from exc
|
|
159
|
+
|
|
160
|
+
console.print("Warming models:\n")
|
|
161
|
+
table = Table(show_header=True, header_style="bold")
|
|
162
|
+
table.add_column("Model")
|
|
163
|
+
table.add_column("Node")
|
|
164
|
+
table.add_column("Ollama Model")
|
|
165
|
+
table.add_column("Status")
|
|
166
|
+
for result in results:
|
|
167
|
+
table.add_row(
|
|
168
|
+
result.model_name,
|
|
169
|
+
result.node_name,
|
|
170
|
+
result.ollama_model,
|
|
171
|
+
"[green]ok[/green]" if result.ok else f"[red]failed[/red] {result.error}",
|
|
172
|
+
)
|
|
173
|
+
console.print(table)
|
|
174
|
+
if any(not result.ok for result in results):
|
|
175
|
+
raise typer.Exit(1)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@app.command()
|
|
179
|
+
def status(
|
|
180
|
+
config: Annotated[Path, typer.Option("--config", help="Path to fleet.yaml.")] = Path("fleet.yaml"),
|
|
181
|
+
as_json: Annotated[bool, typer.Option("--json", help="Print machine-readable JSON.")] = False,
|
|
182
|
+
) -> None:
|
|
183
|
+
"""Show node status and agent routing."""
|
|
184
|
+
fleet = _load_or_exit(config)
|
|
185
|
+
nodes, routes = get_status(fleet)
|
|
186
|
+
if as_json:
|
|
187
|
+
console.print(
|
|
188
|
+
json.dumps(
|
|
189
|
+
{
|
|
190
|
+
"fleet": fleet.project.name,
|
|
191
|
+
"nodes": discovery_to_json(nodes),
|
|
192
|
+
"routes": routes_to_json(routes),
|
|
193
|
+
},
|
|
194
|
+
indent=2,
|
|
195
|
+
)
|
|
196
|
+
)
|
|
197
|
+
return
|
|
198
|
+
|
|
199
|
+
console.print(f"Fleet: {fleet.project.name}\n")
|
|
200
|
+
table = Table(show_header=True, header_style="bold")
|
|
201
|
+
table.add_column("Node")
|
|
202
|
+
table.add_column("Status")
|
|
203
|
+
table.add_column("Endpoint")
|
|
204
|
+
table.add_column("Models")
|
|
205
|
+
for node in nodes:
|
|
206
|
+
table.add_row(
|
|
207
|
+
node.name,
|
|
208
|
+
"[green]online[/green]" if node.online else "[red]offline[/red]",
|
|
209
|
+
node.endpoint,
|
|
210
|
+
", ".join(node.models) if node.models else "-",
|
|
211
|
+
)
|
|
212
|
+
console.print(table)
|
|
213
|
+
_print_routes(routes)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@app.command()
|
|
217
|
+
def doctor(config: Annotated[Path, typer.Option("--config", help="Path to fleet.yaml.")] = Path("fleet.yaml")) -> None:
|
|
218
|
+
"""Validate config and print local-network security guidance."""
|
|
219
|
+
fleet = _load_or_exit(config)
|
|
220
|
+
console.print(f"{config} is valid.")
|
|
221
|
+
console.print("\nSecurity checks:")
|
|
222
|
+
console.print("- Do not expose Ollama directly to the public internet.")
|
|
223
|
+
console.print("- Do not expose LiteLLM without authentication.")
|
|
224
|
+
console.print("- Prefer LAN, firewall rules, Tailscale, or WireGuard.")
|
|
225
|
+
console.print(f"- Use a non-default {fleet.project.gateway.master_key_env} beyond local dev.")
|
|
226
|
+
console.print("\nOllama worker setup hint:")
|
|
227
|
+
console.print(' launchctl setenv OLLAMA_HOST "0.0.0.0:11434"')
|
|
228
|
+
console.print(' launchctl setenv OLLAMA_KEEP_ALIVE "-1"')
|
|
229
|
+
console.print(' launchctl setenv OLLAMA_NUM_PARALLEL "1"')
|
|
230
|
+
console.print(' launchctl setenv OLLAMA_MAX_LOADED_MODELS "1"')
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
@app.command()
|
|
234
|
+
def clean(out: Annotated[Path, typer.Option("--out", help="Output root to clean.")] = Path("."), force: bool = False) -> None:
|
|
235
|
+
"""Remove generated files from an output root."""
|
|
236
|
+
targets = [out / "litellm_config.yaml", out / ".env.subagent-fleet"]
|
|
237
|
+
targets.extend((out / ".claude" / "agents").glob("*.md") if (out / ".claude" / "agents").exists() else [])
|
|
238
|
+
if not force:
|
|
239
|
+
console.print("Files that would be removed:")
|
|
240
|
+
for target in targets:
|
|
241
|
+
console.print(f" {target}")
|
|
242
|
+
console.print("\nRun with --force to remove them.")
|
|
243
|
+
return
|
|
244
|
+
for target in targets:
|
|
245
|
+
target.unlink(missing_ok=True)
|
|
246
|
+
console.print("Removed generated files.")
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
@skills_app.command("list")
|
|
250
|
+
def list_skills() -> None:
|
|
251
|
+
"""List bundled skills and supported assistant targets."""
|
|
252
|
+
skill_table = Table(title="Bundled skills", show_header=True, header_style="bold")
|
|
253
|
+
skill_table.add_column("Skill")
|
|
254
|
+
skill_table.add_column("Description")
|
|
255
|
+
for skill in BUNDLED_SKILLS.values():
|
|
256
|
+
skill_table.add_row(skill.name, skill.description)
|
|
257
|
+
console.print(skill_table)
|
|
258
|
+
|
|
259
|
+
target_table = Table(title="Targets", show_header=True, header_style="bold")
|
|
260
|
+
target_table.add_column("Target")
|
|
261
|
+
target_table.add_column("Install Directory")
|
|
262
|
+
target_table.add_column("Description")
|
|
263
|
+
for target in ASSISTANT_TARGETS.values():
|
|
264
|
+
target_table.add_row(target.name, str(target.directory), target.description)
|
|
265
|
+
console.print(target_table)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
@skills_app.command("install")
|
|
269
|
+
def install_assistant_skills(
|
|
270
|
+
out: Annotated[Path, typer.Option("--out", help="Project root or output directory.")] = Path("."),
|
|
271
|
+
target: Annotated[
|
|
272
|
+
list[str] | None,
|
|
273
|
+
typer.Option("--target", "-t", help="Assistant target: all, claude-code, codex, opencode. Can be repeated or comma-separated."),
|
|
274
|
+
] = None,
|
|
275
|
+
skill: Annotated[
|
|
276
|
+
list[str] | None,
|
|
277
|
+
typer.Option("--skill", "-s", help="Bundled skill: all, subagent-fleet-setup, subagent-fleet-operations. Can be repeated or comma-separated."),
|
|
278
|
+
] = None,
|
|
279
|
+
force: Annotated[bool, typer.Option("--force", help="Overwrite existing installed skill files.")] = False,
|
|
280
|
+
) -> None:
|
|
281
|
+
"""Install bundled assistant skills for Claude Code, Codex, OpenCode, or all targets."""
|
|
282
|
+
try:
|
|
283
|
+
results = install_skills(output_root=out, targets=target or ["all"], skills=skill or ["all"], force=force)
|
|
284
|
+
except (FileExistsError, SkillInstallError) as exc:
|
|
285
|
+
console.print(f"[red]{exc}[/red]")
|
|
286
|
+
raise typer.Exit(1) from exc
|
|
287
|
+
|
|
288
|
+
console.print("Installed skills:")
|
|
289
|
+
for result in results:
|
|
290
|
+
console.print(f" {result.target}: {result.skill} -> {result.path}")
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
@plugins_app.command("install")
|
|
294
|
+
def install_assistant_plugins(
|
|
295
|
+
out: Annotated[Path, typer.Option("--out", help="Marketplace root or output directory.")] = Path("."),
|
|
296
|
+
target: Annotated[
|
|
297
|
+
list[str] | None,
|
|
298
|
+
typer.Option("--target", "-t", help="Plugin target: all, claude-code, codex. Can be repeated or comma-separated."),
|
|
299
|
+
] = None,
|
|
300
|
+
force: Annotated[bool, typer.Option("--force", help="Overwrite existing plugin marketplace files.")] = False,
|
|
301
|
+
) -> None:
|
|
302
|
+
"""Install Claude Code and Codex plugin marketplace bundles."""
|
|
303
|
+
try:
|
|
304
|
+
results = install_plugin_marketplaces(output_root=out, targets=target or ["all"], force=force)
|
|
305
|
+
except (FileExistsError, PluginInstallError) as exc:
|
|
306
|
+
console.print(f"[red]{exc}[/red]")
|
|
307
|
+
raise typer.Exit(1) from exc
|
|
308
|
+
|
|
309
|
+
console.print("Installed plugin marketplace files:")
|
|
310
|
+
for result in results:
|
|
311
|
+
console.print(f" {result.target}: {result.path}")
|
|
312
|
+
console.print("\nPlugin skills included:")
|
|
313
|
+
console.print(" subagent-fleet-bootstrap")
|
|
314
|
+
console.print(" subagent-fleet-setup")
|
|
315
|
+
console.print(" subagent-fleet-operations")
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _print_routes(routes: list[object]) -> None:
|
|
319
|
+
console.print("\nAgent routing:\n")
|
|
320
|
+
table = Table(show_header=True, header_style="bold")
|
|
321
|
+
table.add_column("Agent")
|
|
322
|
+
table.add_column("Node")
|
|
323
|
+
table.add_column("Ollama Model")
|
|
324
|
+
table.add_column("LiteLLM Alias")
|
|
325
|
+
for route in routes:
|
|
326
|
+
table.add_row(route.agent, route.node, route.ollama_model, route.litellm_alias)
|
|
327
|
+
console.print(table)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
if __name__ == "__main__":
|
|
331
|
+
app()
|
subagent_fleet/config.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
from pydantic import AnyHttpUrl, BaseModel, ConfigDict, Field, ValidationError, field_validator, model_validator
|
|
9
|
+
|
|
10
|
+
AGENT_NAME_RE = re.compile(r"^[a-z0-9_-]+$")
|
|
11
|
+
DEFAULT_AGENT_PROMPT = "You are a local subagent. Follow the agent description and return a concise, useful response."
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ConfigError(ValueError):
|
|
15
|
+
"""Raised when fleet.yaml cannot be loaded or validated."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class UniqueKeyLoader(yaml.SafeLoader):
|
|
19
|
+
"""YAML loader that rejects duplicate mapping keys."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _construct_mapping(loader: UniqueKeyLoader, node: yaml.MappingNode, deep: bool = False) -> dict[Any, Any]:
|
|
23
|
+
mapping: dict[Any, Any] = {}
|
|
24
|
+
for key_node, value_node in node.value:
|
|
25
|
+
key = loader.construct_object(key_node, deep=deep)
|
|
26
|
+
if key in mapping:
|
|
27
|
+
raise yaml.constructor.ConstructorError(
|
|
28
|
+
"while constructing a mapping",
|
|
29
|
+
node.start_mark,
|
|
30
|
+
f"found duplicate key: {key}",
|
|
31
|
+
key_node.start_mark,
|
|
32
|
+
)
|
|
33
|
+
mapping[key] = loader.construct_object(value_node, deep=deep)
|
|
34
|
+
return mapping
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
UniqueKeyLoader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _construct_mapping)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class GatewayConfig(BaseModel):
|
|
41
|
+
model_config = ConfigDict(extra="forbid")
|
|
42
|
+
|
|
43
|
+
provider: str = "litellm"
|
|
44
|
+
host: str = "127.0.0.1"
|
|
45
|
+
port: int = Field(default=4000, ge=1, le=65535)
|
|
46
|
+
master_key_env: str = "LITELLM_MASTER_KEY"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ProjectConfig(BaseModel):
|
|
50
|
+
model_config = ConfigDict(extra="forbid")
|
|
51
|
+
|
|
52
|
+
name: str
|
|
53
|
+
gateway: GatewayConfig = Field(default_factory=GatewayConfig)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class NodeConfig(BaseModel):
|
|
57
|
+
model_config = ConfigDict(extra="forbid")
|
|
58
|
+
|
|
59
|
+
endpoint: AnyHttpUrl
|
|
60
|
+
tags: list[str] = Field(default_factory=list)
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def endpoint_str(self) -> str:
|
|
64
|
+
return str(self.endpoint).rstrip("/")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class ModelConfig(BaseModel):
|
|
68
|
+
model_config = ConfigDict(extra="forbid")
|
|
69
|
+
|
|
70
|
+
node: str
|
|
71
|
+
ollama_model: str
|
|
72
|
+
litellm_alias: str
|
|
73
|
+
context: int = Field(default=8192, gt=0)
|
|
74
|
+
timeout: int = Field(default=300, gt=0)
|
|
75
|
+
max_parallel: int = Field(default=1, gt=0)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class AgentConfig(BaseModel):
|
|
79
|
+
model_config = ConfigDict(extra="forbid")
|
|
80
|
+
|
|
81
|
+
model: str
|
|
82
|
+
description: str
|
|
83
|
+
tools: list[str] = Field(default_factory=list)
|
|
84
|
+
prompt: str | None = None
|
|
85
|
+
|
|
86
|
+
@field_validator("prompt")
|
|
87
|
+
@classmethod
|
|
88
|
+
def default_prompt(cls, value: str | None) -> str:
|
|
89
|
+
if value is None or not value.strip():
|
|
90
|
+
return DEFAULT_AGENT_PROMPT
|
|
91
|
+
return value
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class FleetConfig(BaseModel):
|
|
95
|
+
model_config = ConfigDict(extra="forbid")
|
|
96
|
+
|
|
97
|
+
project: ProjectConfig
|
|
98
|
+
nodes: dict[str, NodeConfig]
|
|
99
|
+
models: dict[str, ModelConfig]
|
|
100
|
+
agents: dict[str, AgentConfig]
|
|
101
|
+
|
|
102
|
+
@model_validator(mode="after")
|
|
103
|
+
def validate_references(self) -> "FleetConfig":
|
|
104
|
+
for node_name in self.nodes:
|
|
105
|
+
if not node_name:
|
|
106
|
+
raise ValueError("node names must not be empty")
|
|
107
|
+
|
|
108
|
+
for model_name, model in self.models.items():
|
|
109
|
+
if model.node not in self.nodes:
|
|
110
|
+
raise ValueError(f"models.{model_name}.node references unknown node: {model.node}")
|
|
111
|
+
|
|
112
|
+
for agent_name, agent in self.agents.items():
|
|
113
|
+
if not AGENT_NAME_RE.fullmatch(agent_name):
|
|
114
|
+
raise ValueError(
|
|
115
|
+
f"agents.{agent_name} must be filesystem-safe: lowercase letters, numbers, hyphens, underscores"
|
|
116
|
+
)
|
|
117
|
+
if agent.model not in self.models:
|
|
118
|
+
raise ValueError(f"agents.{agent_name}.model references unknown model: {agent.model}")
|
|
119
|
+
|
|
120
|
+
return self
|
|
121
|
+
|
|
122
|
+
def alias_warnings(self) -> list[str]:
|
|
123
|
+
aliases: dict[str, set[str]] = {}
|
|
124
|
+
for model in self.models.values():
|
|
125
|
+
aliases.setdefault(model.litellm_alias, set()).add(model.ollama_model)
|
|
126
|
+
return [
|
|
127
|
+
f"alias {alias!r} is used for multiple Ollama models: {', '.join(sorted(models))}"
|
|
128
|
+
for alias, models in sorted(aliases.items())
|
|
129
|
+
if len(models) > 1
|
|
130
|
+
]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def load_config(path: Path | str) -> FleetConfig:
|
|
134
|
+
config_path = Path(path)
|
|
135
|
+
try:
|
|
136
|
+
raw = yaml.load(config_path.read_text(), Loader=UniqueKeyLoader) or {}
|
|
137
|
+
except FileNotFoundError as exc:
|
|
138
|
+
raise ConfigError(f"{config_path} does not exist") from exc
|
|
139
|
+
except yaml.YAMLError as exc:
|
|
140
|
+
raise ConfigError(f"{config_path} is not valid YAML: {exc}") from exc
|
|
141
|
+
|
|
142
|
+
if not isinstance(raw, dict):
|
|
143
|
+
raise ConfigError(f"{config_path} must contain a YAML mapping")
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
return FleetConfig.model_validate(raw)
|
|
147
|
+
except ValidationError as exc:
|
|
148
|
+
raise ConfigError(format_validation_error(exc)) from exc
|
|
149
|
+
except ValueError as exc:
|
|
150
|
+
raise ConfigError(str(exc)) from exc
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def format_validation_error(exc: ValidationError) -> str:
|
|
154
|
+
errors: list[str] = []
|
|
155
|
+
for error in exc.errors():
|
|
156
|
+
loc = ".".join(str(part) for part in error["loc"])
|
|
157
|
+
msg = error["msg"]
|
|
158
|
+
errors.append(f"{loc}: {msg}" if loc else msg)
|
|
159
|
+
return "\n".join(errors)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def config_to_plain_dict(config: FleetConfig) -> dict[str, Any]:
|
|
163
|
+
return config.model_dump(mode="json")
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
STARTER_FLEET_YAML = """project:
|
|
2
|
+
name: local-dev
|
|
3
|
+
gateway:
|
|
4
|
+
provider: litellm
|
|
5
|
+
host: 127.0.0.1
|
|
6
|
+
port: 4000
|
|
7
|
+
master_key_env: LITELLM_MASTER_KEY
|
|
8
|
+
|
|
9
|
+
nodes:
|
|
10
|
+
local-ollama:
|
|
11
|
+
endpoint: http://localhost:11434
|
|
12
|
+
tags:
|
|
13
|
+
- controller
|
|
14
|
+
- local
|
|
15
|
+
- fast
|
|
16
|
+
|
|
17
|
+
models:
|
|
18
|
+
heavy-coder:
|
|
19
|
+
node: local-ollama
|
|
20
|
+
ollama_model: qwen2.5-coder:32b
|
|
21
|
+
litellm_alias: claude-sonnet-local
|
|
22
|
+
context: 32768
|
|
23
|
+
timeout: 600
|
|
24
|
+
max_parallel: 1
|
|
25
|
+
|
|
26
|
+
small-coder:
|
|
27
|
+
node: local-ollama
|
|
28
|
+
ollama_model: qwen2.5-coder:7b
|
|
29
|
+
litellm_alias: claude-haiku-local
|
|
30
|
+
context: 8192
|
|
31
|
+
timeout: 300
|
|
32
|
+
max_parallel: 1
|
|
33
|
+
|
|
34
|
+
agents:
|
|
35
|
+
planner:
|
|
36
|
+
model: small-coder
|
|
37
|
+
description: Use for planning, file discovery, task decomposition, and summarization.
|
|
38
|
+
tools:
|
|
39
|
+
- Read
|
|
40
|
+
- Grep
|
|
41
|
+
- Glob
|
|
42
|
+
prompt: |
|
|
43
|
+
You are a fast local planning agent.
|
|
44
|
+
Do not edit files.
|
|
45
|
+
Return a concise response with:
|
|
46
|
+
- plan
|
|
47
|
+
- relevant files
|
|
48
|
+
- risks
|
|
49
|
+
- next recommended agent
|
|
50
|
+
|
|
51
|
+
implementer:
|
|
52
|
+
model: heavy-coder
|
|
53
|
+
description: Use for implementation, bug fixes, refactors, and patch creation.
|
|
54
|
+
tools:
|
|
55
|
+
- Read
|
|
56
|
+
- Grep
|
|
57
|
+
- Glob
|
|
58
|
+
- Edit
|
|
59
|
+
- MultiEdit
|
|
60
|
+
- Bash
|
|
61
|
+
prompt: |
|
|
62
|
+
You are a senior implementation agent.
|
|
63
|
+
Make minimal, correct changes.
|
|
64
|
+
Prefer small patches.
|
|
65
|
+
Run relevant checks when possible.
|
|
66
|
+
Explain what changed and why.
|
|
67
|
+
|
|
68
|
+
reviewer:
|
|
69
|
+
model: heavy-coder
|
|
70
|
+
description: Use after implementation to review diffs, tests, regressions, and maintainability.
|
|
71
|
+
tools:
|
|
72
|
+
- Read
|
|
73
|
+
- Grep
|
|
74
|
+
- Glob
|
|
75
|
+
- Bash
|
|
76
|
+
prompt: |
|
|
77
|
+
You are a strict code reviewer.
|
|
78
|
+
Focus on correctness, regressions, missing tests, security issues,
|
|
79
|
+
over-engineering, and maintainability.
|
|
80
|
+
Review the diff and test output.
|
|
81
|
+
Return only actionable issues.
|
|
82
|
+
"""
|