agroute 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.
- agroute/__init__.py +9 -0
- agroute/__main__.py +3 -0
- agroute/cli.py +249 -0
- agroute/config.py +272 -0
- agroute/data/default_config.toml +103 -0
- agroute/files.py +23 -0
- agroute/py.typed +0 -0
- agroute/router.py +539 -0
- agroute/state.py +137 -0
- agroute/usage.py +513 -0
- agroute/validation.py +100 -0
- agroute-0.1.0.dist-info/METADATA +129 -0
- agroute-0.1.0.dist-info/RECORD +17 -0
- agroute-0.1.0.dist-info/WHEEL +5 -0
- agroute-0.1.0.dist-info/entry_points.txt +2 -0
- agroute-0.1.0.dist-info/licenses/LICENSE +21 -0
- agroute-0.1.0.dist-info/top_level.txt +1 -0
agroute/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Configurable local agent router with native usage-limit filtering and howhard integration."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
from .config import load_config
|
|
6
|
+
from .router import Router
|
|
7
|
+
from .validation import AgRouteError
|
|
8
|
+
|
|
9
|
+
__all__ = ["AgRouteError", "Router", "load_config", "__version__"]
|
agroute/__main__.py
ADDED
agroute/cli.py
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""Command-line interface for agroute."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from . import __version__
|
|
9
|
+
from .files import atomic_write
|
|
10
|
+
from .router import Router
|
|
11
|
+
from .validation import AgRouteError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _add_common_flags(parser):
|
|
15
|
+
parser.add_argument("--config", default=None, help="Path to agroute.toml configuration file")
|
|
16
|
+
parser.add_argument("--state-dir", default=None, help="Explicit state directory override")
|
|
17
|
+
parser.add_argument(
|
|
18
|
+
"--no-user-config",
|
|
19
|
+
action="store_true",
|
|
20
|
+
help="Ignore user-level ~/.config/agroute/config.toml",
|
|
21
|
+
)
|
|
22
|
+
parser.add_argument("--now", default=None, help="Evaluation timestamp override (ISO-8601 UTC)")
|
|
23
|
+
parser.add_argument("--json", action="store_true", help="Emit structured JSON output")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _build_parser():
|
|
27
|
+
parser = argparse.ArgumentParser(
|
|
28
|
+
prog="agroute",
|
|
29
|
+
description="Configurable local agent router across Codex, Claude Code, and Antigravity.",
|
|
30
|
+
)
|
|
31
|
+
parser.add_argument("--version", action="version", version=f"agroute {__version__}")
|
|
32
|
+
_add_common_flags(parser)
|
|
33
|
+
|
|
34
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
35
|
+
|
|
36
|
+
# paths
|
|
37
|
+
p_paths = subparsers.add_parser("paths", help="Show resolved capture and state roots")
|
|
38
|
+
_add_common_flags(p_paths)
|
|
39
|
+
|
|
40
|
+
# usage
|
|
41
|
+
p_usage = subparsers.add_parser("usage", help="Inspect or update native agent quota and rate-limit state")
|
|
42
|
+
_add_common_flags(p_usage)
|
|
43
|
+
usage_sub = p_usage.add_subparsers(dest="usage_command")
|
|
44
|
+
|
|
45
|
+
p_u_show = usage_sub.add_parser("show", help="Show effective quota and eligibility per agent")
|
|
46
|
+
p_u_show.add_argument("--agent", default=None, help="Filter to a single agent (codex|claude|agy)")
|
|
47
|
+
_add_common_flags(p_u_show)
|
|
48
|
+
|
|
49
|
+
p_u_set = usage_sub.add_parser("set", help="Explicitly set quota window percentages or cooldowns")
|
|
50
|
+
p_u_set.add_argument("--agent", required=True, help="Target agent (codex|claude|agy)")
|
|
51
|
+
p_u_set.add_argument("--session-used-pct", type=float, default=None, help="5h session used percentage (0-100)")
|
|
52
|
+
p_u_set.add_argument("--session-reset-at", default=None, help="5h session reset timestamp (ISO-8601 or epoch)")
|
|
53
|
+
p_u_set.add_argument("--weekly-used-pct", type=float, default=None, help="7d weekly used percentage (0-100)")
|
|
54
|
+
p_u_set.add_argument("--weekly-reset-at", default=None, help="7d weekly reset timestamp (ISO-8601 or epoch)")
|
|
55
|
+
p_u_set.add_argument("--tier", default=None, help="Model tier name (e.g. opus, sonnet, pro, flash)")
|
|
56
|
+
p_u_set.add_argument("--tier-used-pct", type=float, default=None, help="Tier used percentage (0-100)")
|
|
57
|
+
p_u_set.add_argument("--tier-reset-at", default=None, help="Tier reset timestamp (ISO-8601 or epoch)")
|
|
58
|
+
rl_group = p_u_set.add_mutually_exclusive_group()
|
|
59
|
+
rl_group.add_argument("--rate-limited", dest="rate_limited", action="store_true", default=None)
|
|
60
|
+
rl_group.add_argument("--no-rate-limited", dest="rate_limited", action="store_false")
|
|
61
|
+
p_u_set.add_argument("--cooldown-until", default=None, help="Rate-limit cooldown expiry timestamp")
|
|
62
|
+
_add_common_flags(p_u_set)
|
|
63
|
+
|
|
64
|
+
p_u_ingest = usage_sub.add_parser("ingest", help="Parse native usage/status output for an agent")
|
|
65
|
+
p_u_ingest.add_argument("--agent", required=True, help="Target agent (codex|claude|agy)")
|
|
66
|
+
p_u_ingest.add_argument("--file", default=None, help="Path to captured native status/event output")
|
|
67
|
+
p_u_ingest.add_argument("--text", default=None, help="Inline native status/event text")
|
|
68
|
+
_add_common_flags(p_u_ingest)
|
|
69
|
+
|
|
70
|
+
p_u_probe = usage_sub.add_parser("probe", help="Run configured read-only usage_probe_cmd")
|
|
71
|
+
p_u_probe.add_argument("--agent", default=None, help="Target agent (codex|claude|agy)")
|
|
72
|
+
_add_common_flags(p_u_probe)
|
|
73
|
+
|
|
74
|
+
p_u_clear = usage_sub.add_parser("clear", help="Clear stored quota state for an agent or all agents")
|
|
75
|
+
p_u_clear.add_argument("--agent", default=None, help="Target agent (codex|claude|agy)")
|
|
76
|
+
_add_common_flags(p_u_clear)
|
|
77
|
+
|
|
78
|
+
# candidates
|
|
79
|
+
p_cands = subparsers.add_parser("candidates", help="Emit filtered howhard-compatible candidates")
|
|
80
|
+
p_cands.add_argument("--host", default=None, help="Current host agent (codex|claude|agy)")
|
|
81
|
+
p_cands.add_argument("--include-agents", default=None, help="Comma-separated subset of agents to consider")
|
|
82
|
+
p_cands.add_argument("--output", default=None, help="Write the howhard candidate JSON array to this file")
|
|
83
|
+
_add_common_flags(p_cands)
|
|
84
|
+
|
|
85
|
+
# inventory
|
|
86
|
+
p_inv = subparsers.add_parser("inventory", help="Emit howhard.toml [inventories] block for howhard 2.1+")
|
|
87
|
+
p_inv.add_argument("--host", default=None, help="Current host agent (codex|claude|agy)")
|
|
88
|
+
p_inv.add_argument("--name", default="router", help="Inventory name in howhard.toml (default: router)")
|
|
89
|
+
p_inv.add_argument("--include-agents", default=None, help="Comma-separated subset of agents to consider")
|
|
90
|
+
p_inv.add_argument("--format", choices=("toml", "json"), default="toml", help="Output format")
|
|
91
|
+
p_inv.add_argument("--output", default=None, help="Write inventory output to this file")
|
|
92
|
+
_add_common_flags(p_inv)
|
|
93
|
+
|
|
94
|
+
# plan
|
|
95
|
+
p_plan = subparsers.add_parser(
|
|
96
|
+
"plan", help="Resolve a howhard decision JSON into a native or CLI execution plan"
|
|
97
|
+
)
|
|
98
|
+
p_plan.add_argument("--host", default=None, help="Current host agent (codex|claude|agy)")
|
|
99
|
+
p_plan.add_argument("--decision", required=True, help="Path to howhard decision JSON file")
|
|
100
|
+
p_plan.add_argument("--prompt", default=None, help="Authorized task prompt text")
|
|
101
|
+
p_plan.add_argument("--prompt-file", default=None, help="File containing authorized task prompt text")
|
|
102
|
+
p_plan.add_argument("--role", default="Task Worker", help="Worker role/description for native subagents")
|
|
103
|
+
p_plan.add_argument("--subagent-type", default=None, help="Host-specific subagent type override")
|
|
104
|
+
p_plan.add_argument("--workspace", default="inherit", help="Antigravity subagent workspace mode")
|
|
105
|
+
p_plan.add_argument("--include-agents", default=None, help="Comma-separated subset of agents to consider")
|
|
106
|
+
p_plan.add_argument("--output", default=None, help="Write resolved execution plan JSON to this file")
|
|
107
|
+
_add_common_flags(p_plan)
|
|
108
|
+
|
|
109
|
+
return parser
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _parse_include_agents(raw):
|
|
113
|
+
if not raw:
|
|
114
|
+
return None
|
|
115
|
+
return [part.strip() for part in raw.split(",") if part.strip()]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _emit(payload, *, as_json=True):
|
|
119
|
+
if isinstance(payload, str) and not as_json:
|
|
120
|
+
sys.stdout.write(payload if payload.endswith("\n") else payload + "\n")
|
|
121
|
+
else:
|
|
122
|
+
sys.stdout.write(json.dumps(payload, indent=2, sort_keys=False) + "\n")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def main(argv=None):
|
|
126
|
+
parser = _build_parser()
|
|
127
|
+
args = parser.parse_args(argv)
|
|
128
|
+
if not args.command:
|
|
129
|
+
parser.print_help(sys.stderr)
|
|
130
|
+
return 2
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
router = Router(
|
|
134
|
+
args.config,
|
|
135
|
+
state_directory=args.state_dir,
|
|
136
|
+
include_user_config=not args.no_user_config,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
if args.command == "paths":
|
|
140
|
+
_emit(router.paths(), as_json=True)
|
|
141
|
+
return 0
|
|
142
|
+
|
|
143
|
+
if args.command == "usage":
|
|
144
|
+
sub = args.usage_command or "show"
|
|
145
|
+
if sub == "show":
|
|
146
|
+
_emit(router.usage_show(getattr(args, "agent", None), now=args.now), as_json=True)
|
|
147
|
+
return 0
|
|
148
|
+
if sub == "set":
|
|
149
|
+
res = router.usage_set(
|
|
150
|
+
args.agent,
|
|
151
|
+
session_used_pct=args.session_used_pct,
|
|
152
|
+
session_reset_at=args.session_reset_at,
|
|
153
|
+
weekly_used_pct=args.weekly_used_pct,
|
|
154
|
+
weekly_reset_at=args.weekly_reset_at,
|
|
155
|
+
rate_limited=args.rate_limited,
|
|
156
|
+
cooldown_until=args.cooldown_until,
|
|
157
|
+
tier=args.tier,
|
|
158
|
+
tier_used_pct=args.tier_used_pct,
|
|
159
|
+
tier_reset_at=args.tier_reset_at,
|
|
160
|
+
now=args.now,
|
|
161
|
+
)
|
|
162
|
+
_emit(res, as_json=True)
|
|
163
|
+
return 0
|
|
164
|
+
if sub == "ingest":
|
|
165
|
+
if args.file and args.text:
|
|
166
|
+
raise AgRouteError("usage ingest: specify either --file or --text, not both")
|
|
167
|
+
if args.file:
|
|
168
|
+
try:
|
|
169
|
+
raw_text = Path(args.file).read_text(encoding="utf-8")
|
|
170
|
+
except OSError as exc:
|
|
171
|
+
raise AgRouteError(f"usage ingest: unable to read {args.file}: {exc}") from exc
|
|
172
|
+
elif args.text is not None:
|
|
173
|
+
raw_text = args.text
|
|
174
|
+
else:
|
|
175
|
+
raw_text = sys.stdin.read()
|
|
176
|
+
res = router.usage_ingest(args.agent, raw_text, now=args.now)
|
|
177
|
+
_emit(res, as_json=True)
|
|
178
|
+
return 0
|
|
179
|
+
if sub == "probe":
|
|
180
|
+
res = router.usage_probe(args.agent, now=args.now)
|
|
181
|
+
_emit(res, as_json=True)
|
|
182
|
+
return 0
|
|
183
|
+
if sub == "clear":
|
|
184
|
+
res = router.usage_clear(args.agent)
|
|
185
|
+
_emit(res, as_json=True)
|
|
186
|
+
return 0
|
|
187
|
+
|
|
188
|
+
if args.command == "candidates":
|
|
189
|
+
include = _parse_include_agents(args.include_agents)
|
|
190
|
+
res = router.candidates(args.host, include_agents=include, now=args.now)
|
|
191
|
+
if args.output:
|
|
192
|
+
atomic_write(args.output, json.dumps(res["candidates"], indent=2) + "\n")
|
|
193
|
+
_emit(res, as_json=True)
|
|
194
|
+
return 0
|
|
195
|
+
|
|
196
|
+
if args.command == "inventory":
|
|
197
|
+
include = _parse_include_agents(args.include_agents)
|
|
198
|
+
if args.format == "toml" and not args.json:
|
|
199
|
+
rendered = router.format_inventory_toml(
|
|
200
|
+
args.host, name=args.name, include_agents=include, now=args.now
|
|
201
|
+
)
|
|
202
|
+
if args.output:
|
|
203
|
+
atomic_write(args.output, rendered)
|
|
204
|
+
_emit(rendered, as_json=False)
|
|
205
|
+
else:
|
|
206
|
+
res = router.inventory(
|
|
207
|
+
args.host, name=args.name, include_agents=include, now=args.now
|
|
208
|
+
)
|
|
209
|
+
if args.output:
|
|
210
|
+
atomic_write(args.output, json.dumps(res, indent=2) + "\n")
|
|
211
|
+
_emit(res, as_json=True)
|
|
212
|
+
return 0
|
|
213
|
+
|
|
214
|
+
if args.command == "plan":
|
|
215
|
+
if args.prompt is not None and args.prompt_file is not None:
|
|
216
|
+
raise AgRouteError("plan: specify either --prompt or --prompt-file, not both")
|
|
217
|
+
prompt = args.prompt
|
|
218
|
+
if args.prompt_file is not None:
|
|
219
|
+
try:
|
|
220
|
+
prompt = Path(args.prompt_file).read_text(encoding="utf-8")
|
|
221
|
+
except OSError as exc:
|
|
222
|
+
raise AgRouteError(f"plan: unable to read {args.prompt_file}: {exc}") from exc
|
|
223
|
+
try:
|
|
224
|
+
decision_obj = json.loads(Path(args.decision).read_text(encoding="utf-8"))
|
|
225
|
+
except (OSError, ValueError, UnicodeError) as exc:
|
|
226
|
+
raise AgRouteError(f"plan: invalid decision JSON file {args.decision}: {exc}") from exc
|
|
227
|
+
include = _parse_include_agents(args.include_agents)
|
|
228
|
+
plan_obj = router.plan(
|
|
229
|
+
args.host,
|
|
230
|
+
decision_obj,
|
|
231
|
+
prompt=prompt,
|
|
232
|
+
role=args.role,
|
|
233
|
+
subagent_type=args.subagent_type,
|
|
234
|
+
workspace=args.workspace,
|
|
235
|
+
include_agents=include,
|
|
236
|
+
now=args.now,
|
|
237
|
+
)
|
|
238
|
+
if args.output:
|
|
239
|
+
atomic_write(args.output, json.dumps(plan_obj, indent=2) + "\n")
|
|
240
|
+
_emit(plan_obj, as_json=True)
|
|
241
|
+
return 0
|
|
242
|
+
|
|
243
|
+
except AgRouteError as exc:
|
|
244
|
+
print(f"agroute: error: {exc}", file=sys.stderr)
|
|
245
|
+
return 1
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
if __name__ == "__main__":
|
|
249
|
+
raise SystemExit(main())
|
agroute/config.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""Layered configuration loading and strict validation."""
|
|
2
|
+
|
|
3
|
+
from copy import deepcopy
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from importlib.resources import files
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import tomllib
|
|
9
|
+
|
|
10
|
+
from .state import roots
|
|
11
|
+
from .validation import (
|
|
12
|
+
AGENTS,
|
|
13
|
+
AgRouteError,
|
|
14
|
+
agent_id,
|
|
15
|
+
boolean,
|
|
16
|
+
effort_id,
|
|
17
|
+
keys,
|
|
18
|
+
number,
|
|
19
|
+
parse_timestamp,
|
|
20
|
+
percentage,
|
|
21
|
+
table,
|
|
22
|
+
text,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
RATE_CATEGORIES = ("input", "output", "cache_read", "cache_write", "cache_write_5m", "cache_write_1h")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Config:
|
|
30
|
+
capture_root: Path
|
|
31
|
+
state_root: Path
|
|
32
|
+
identity_path: Path
|
|
33
|
+
project_identity: str
|
|
34
|
+
config_path: Path | None
|
|
35
|
+
user_config_path: Path | None
|
|
36
|
+
default_host: str
|
|
37
|
+
host_affinity_epsilon: float
|
|
38
|
+
namespace_separator: str
|
|
39
|
+
agents: dict
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _validate_rate_card(card, where):
|
|
43
|
+
keys(
|
|
44
|
+
card,
|
|
45
|
+
["format", "source", "version", "effective_at", "provider", "model", "service_tier", "basis", "rates"],
|
|
46
|
+
where=where,
|
|
47
|
+
)
|
|
48
|
+
if card["format"] != "howhard-rate-card-v1" or card["basis"] not in ("estimate", "charge"):
|
|
49
|
+
raise AgRouteError(f"{where}: unsupported format or basis")
|
|
50
|
+
for key in ("source", "version", "provider", "model"):
|
|
51
|
+
text(card[key], f"{where}.{key}")
|
|
52
|
+
if card["service_tier"] is not None:
|
|
53
|
+
text(card["service_tier"], f"{where}.service_tier")
|
|
54
|
+
parse_timestamp(card["effective_at"], f"{where}.effective_at")
|
|
55
|
+
rates = table(card["rates"], f"{where}.rates")
|
|
56
|
+
keys(rates, RATE_CATEGORIES, where=f"{where}.rates")
|
|
57
|
+
for category, amount in rates.items():
|
|
58
|
+
if amount is not None:
|
|
59
|
+
number(amount, f"{where}.rates.{category}", nonnegative=True)
|
|
60
|
+
return deepcopy(card)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _validate_candidate_list(items, where, *, agent):
|
|
64
|
+
if not isinstance(items, list):
|
|
65
|
+
raise AgRouteError(f"{where}: expected a list of candidate tables")
|
|
66
|
+
seen = set()
|
|
67
|
+
validated = []
|
|
68
|
+
for index, raw in enumerate(items):
|
|
69
|
+
entry_where = f"{where}[{index}]"
|
|
70
|
+
keys(
|
|
71
|
+
raw,
|
|
72
|
+
["model", "effort"],
|
|
73
|
+
["tier", "usd_per_mtok", "rate_card"],
|
|
74
|
+
where=entry_where,
|
|
75
|
+
)
|
|
76
|
+
model = text(raw["model"], f"{entry_where}.model", max_length=200)
|
|
77
|
+
if "/" in model or ":" in model:
|
|
78
|
+
raise AgRouteError(
|
|
79
|
+
f"{entry_where}.model: configure bare native model ID '{model}' without agent prefix"
|
|
80
|
+
)
|
|
81
|
+
effort = effort_id(raw["effort"], f"{entry_where}.effort")
|
|
82
|
+
if agent == "agy" and where.endswith("native_candidates") and model not in (
|
|
83
|
+
"inherit",
|
|
84
|
+
"flash_lite",
|
|
85
|
+
"flash",
|
|
86
|
+
"pro",
|
|
87
|
+
):
|
|
88
|
+
raise AgRouteError(
|
|
89
|
+
f"{entry_where}.model: Antigravity native subagent model must be one of inherit, flash_lite, flash, pro"
|
|
90
|
+
)
|
|
91
|
+
pair = (model, effort)
|
|
92
|
+
if pair in seen:
|
|
93
|
+
raise AgRouteError(f"{entry_where}: duplicate candidate cell ({model!r}, {effort!r})")
|
|
94
|
+
seen.add(pair)
|
|
95
|
+
if "usd_per_mtok" in raw and "rate_card" in raw and raw["usd_per_mtok"] is not None and raw["rate_card"] is not None:
|
|
96
|
+
raise AgRouteError(f"{entry_where}: candidate cannot specify both usd_per_mtok and rate_card")
|
|
97
|
+
cell = {"model": model, "effort": effort}
|
|
98
|
+
if raw.get("tier") is not None:
|
|
99
|
+
cell["tier"] = text(raw["tier"], f"{entry_where}.tier", label=True, max_length=64)
|
|
100
|
+
if raw.get("usd_per_mtok") is not None:
|
|
101
|
+
cell["usd_per_mtok"] = number(raw["usd_per_mtok"], f"{entry_where}.usd_per_mtok", positive=True)
|
|
102
|
+
if raw.get("rate_card") is not None:
|
|
103
|
+
cell["rate_card"] = _validate_rate_card(raw["rate_card"], f"{entry_where}.rate_card")
|
|
104
|
+
validated.append(cell)
|
|
105
|
+
return validated
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _merge_config(base, override, where):
|
|
109
|
+
table(override, where)
|
|
110
|
+
unknown_top = set(override) - {"routing", "agents", "state_dir"}
|
|
111
|
+
if unknown_top:
|
|
112
|
+
raise AgRouteError(f"{where}: unknown top-level keys {sorted(unknown_top)}")
|
|
113
|
+
merged = deepcopy(base)
|
|
114
|
+
if "state_dir" in override:
|
|
115
|
+
merged["state_dir"] = text(override["state_dir"], f"{where}.state_dir", max_length=1024)
|
|
116
|
+
if "routing" in override:
|
|
117
|
+
r_override = table(override["routing"], f"{where}.routing")
|
|
118
|
+
unknown_r = set(r_override) - {"default_host", "host_affinity_epsilon", "namespace_separator"}
|
|
119
|
+
if unknown_r:
|
|
120
|
+
raise AgRouteError(f"{where}.routing: unknown keys {sorted(unknown_r)}")
|
|
121
|
+
merged["routing"].update(deepcopy(r_override))
|
|
122
|
+
if "agents" in override:
|
|
123
|
+
a_override = table(override["agents"], f"{where}.agents")
|
|
124
|
+
for name, spec in a_override.items():
|
|
125
|
+
agent_id(name, f"{where}.agents.{name}")
|
|
126
|
+
table(spec, f"{where}.agents.{name}")
|
|
127
|
+
unknown_a = set(spec) - {
|
|
128
|
+
"enabled",
|
|
129
|
+
"max_session_used_pct",
|
|
130
|
+
"max_weekly_used_pct",
|
|
131
|
+
"max_tier_used_pct",
|
|
132
|
+
"host_reserve_pct",
|
|
133
|
+
"usage_probe_cmd",
|
|
134
|
+
"native_candidates",
|
|
135
|
+
"cli_candidates",
|
|
136
|
+
}
|
|
137
|
+
if unknown_a:
|
|
138
|
+
raise AgRouteError(f"{where}.agents.{name}: unknown keys {sorted(unknown_a)}")
|
|
139
|
+
merged["agents"][name].update(deepcopy(spec))
|
|
140
|
+
return merged
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _validate_merged(raw):
|
|
144
|
+
routing = table(raw.get("routing"), "routing")
|
|
145
|
+
keys(routing, ["default_host", "host_affinity_epsilon", "namespace_separator"], where="routing")
|
|
146
|
+
default_host = agent_id(routing["default_host"], "routing.default_host")
|
|
147
|
+
epsilon = number(routing["host_affinity_epsilon"], "routing.host_affinity_epsilon", nonnegative=True)
|
|
148
|
+
separator = text(routing["namespace_separator"], "routing.namespace_separator", max_length=1)
|
|
149
|
+
if separator not in ("/", ":"):
|
|
150
|
+
raise AgRouteError("routing.namespace_separator: expected '/' or ':'")
|
|
151
|
+
|
|
152
|
+
agents_raw = table(raw.get("agents"), "agents")
|
|
153
|
+
keys(agents_raw, AGENTS, where="agents")
|
|
154
|
+
agents = {}
|
|
155
|
+
for name in AGENTS:
|
|
156
|
+
where = f"agents.{name}"
|
|
157
|
+
spec = table(agents_raw[name], where)
|
|
158
|
+
keys(
|
|
159
|
+
spec,
|
|
160
|
+
[
|
|
161
|
+
"enabled",
|
|
162
|
+
"max_session_used_pct",
|
|
163
|
+
"max_weekly_used_pct",
|
|
164
|
+
"max_tier_used_pct",
|
|
165
|
+
"host_reserve_pct",
|
|
166
|
+
"usage_probe_cmd",
|
|
167
|
+
"native_candidates",
|
|
168
|
+
"cli_candidates",
|
|
169
|
+
],
|
|
170
|
+
where=where,
|
|
171
|
+
)
|
|
172
|
+
enabled = boolean(spec["enabled"], f"{where}.enabled")
|
|
173
|
+
max_session = percentage(spec["max_session_used_pct"], f"{where}.max_session_used_pct")
|
|
174
|
+
max_weekly = percentage(spec["max_weekly_used_pct"], f"{where}.max_weekly_used_pct")
|
|
175
|
+
max_tier = percentage(spec["max_tier_used_pct"], f"{where}.max_tier_used_pct")
|
|
176
|
+
reserve = percentage(spec["host_reserve_pct"], f"{where}.host_reserve_pct")
|
|
177
|
+
if reserve > max_session or reserve > max_weekly:
|
|
178
|
+
raise AgRouteError(f"{where}.host_reserve_pct: cannot exceed max_session_used_pct or max_weekly_used_pct")
|
|
179
|
+
probe_cmd = spec["usage_probe_cmd"]
|
|
180
|
+
if not isinstance(probe_cmd, list) or any(not isinstance(arg, str) or not arg for arg in probe_cmd):
|
|
181
|
+
raise AgRouteError(f"{where}.usage_probe_cmd: expected a list of non-empty strings")
|
|
182
|
+
native_cands = _validate_candidate_list(
|
|
183
|
+
spec["native_candidates"], f"{where}.native_candidates", agent=name
|
|
184
|
+
)
|
|
185
|
+
cli_cands = _validate_candidate_list(
|
|
186
|
+
spec["cli_candidates"], f"{where}.cli_candidates", agent=name
|
|
187
|
+
)
|
|
188
|
+
if enabled and not native_cands and not cli_cands:
|
|
189
|
+
raise AgRouteError(f"{where}: enabled agent must declare at least one candidate")
|
|
190
|
+
agents[name] = {
|
|
191
|
+
"enabled": enabled,
|
|
192
|
+
"max_session_used_pct": max_session,
|
|
193
|
+
"max_weekly_used_pct": max_weekly,
|
|
194
|
+
"max_tier_used_pct": max_tier,
|
|
195
|
+
"host_reserve_pct": reserve,
|
|
196
|
+
"usage_probe_cmd": list(probe_cmd),
|
|
197
|
+
"native_candidates": native_cands,
|
|
198
|
+
"cli_candidates": cli_cands,
|
|
199
|
+
}
|
|
200
|
+
return default_host, epsilon, separator, agents
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _read_toml(path, where):
|
|
204
|
+
try:
|
|
205
|
+
return tomllib.loads(Path(path).read_text(encoding="utf-8"))
|
|
206
|
+
except OSError as exc:
|
|
207
|
+
raise AgRouteError(f"{where}: unable to read {path}: {exc}") from exc
|
|
208
|
+
except tomllib.TOMLDecodeError as exc:
|
|
209
|
+
raise AgRouteError(f"{where}: invalid TOML in {path}: {exc}") from exc
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def load_config(path=None, *, project=None, state_directory=None, include_user_config=True):
|
|
213
|
+
default_bytes = files("agroute").joinpath("data/default_config.toml").read_text(encoding="utf-8")
|
|
214
|
+
merged = tomllib.loads(default_bytes)
|
|
215
|
+
|
|
216
|
+
user_cfg_path = None
|
|
217
|
+
if include_user_config:
|
|
218
|
+
env_user = os.environ.get("AGROUTE_USER_CONFIG")
|
|
219
|
+
if env_user:
|
|
220
|
+
candidate = Path(env_user).expanduser().resolve()
|
|
221
|
+
if not candidate.is_file():
|
|
222
|
+
raise AgRouteError(f"user config: file not found at {candidate}")
|
|
223
|
+
user_cfg_path = candidate
|
|
224
|
+
else:
|
|
225
|
+
xdg = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
|
|
226
|
+
candidate = (xdg / "agroute" / "config.toml").resolve()
|
|
227
|
+
if candidate.is_file():
|
|
228
|
+
user_cfg_path = candidate
|
|
229
|
+
if user_cfg_path is not None:
|
|
230
|
+
merged = _merge_config(merged, _read_toml(user_cfg_path, "user config"), "user config")
|
|
231
|
+
|
|
232
|
+
explicit = path is not None
|
|
233
|
+
base_dir = Path(project).resolve() if project is not None else Path.cwd().resolve()
|
|
234
|
+
config_path = None
|
|
235
|
+
if explicit:
|
|
236
|
+
config_path = Path(path).expanduser().resolve()
|
|
237
|
+
if not config_path.is_file():
|
|
238
|
+
raise AgRouteError(f"config: file not found at {config_path}")
|
|
239
|
+
if project is None:
|
|
240
|
+
base_dir = config_path.parent
|
|
241
|
+
merged = _merge_config(merged, _read_toml(config_path, "config"), "config")
|
|
242
|
+
else:
|
|
243
|
+
local_candidate = base_dir / "agroute.toml"
|
|
244
|
+
if local_candidate.is_file():
|
|
245
|
+
config_path = local_candidate.resolve()
|
|
246
|
+
merged = _merge_config(merged, _read_toml(config_path, "config"), "config")
|
|
247
|
+
|
|
248
|
+
default_host, epsilon, separator, agents = _validate_merged(merged)
|
|
249
|
+
|
|
250
|
+
cfg_state_dir = state_directory
|
|
251
|
+
if cfg_state_dir is None and "state_dir" in merged:
|
|
252
|
+
raw_state = Path(merged["state_dir"]).expanduser()
|
|
253
|
+
anchor = config_path.parent if config_path is not None else base_dir
|
|
254
|
+
cfg_state_dir = (anchor / raw_state).resolve() if not raw_state.is_absolute() else raw_state.resolve()
|
|
255
|
+
|
|
256
|
+
capture_root, state_root, identity_path, project_identity = roots(
|
|
257
|
+
base_dir,
|
|
258
|
+
explicit=explicit and project is None,
|
|
259
|
+
state_directory=cfg_state_dir,
|
|
260
|
+
)
|
|
261
|
+
return Config(
|
|
262
|
+
capture_root=capture_root,
|
|
263
|
+
state_root=state_root,
|
|
264
|
+
identity_path=identity_path,
|
|
265
|
+
project_identity=project_identity,
|
|
266
|
+
config_path=config_path,
|
|
267
|
+
user_config_path=user_cfg_path,
|
|
268
|
+
default_host=default_host,
|
|
269
|
+
host_affinity_epsilon=epsilon,
|
|
270
|
+
namespace_separator=separator,
|
|
271
|
+
agents=agents,
|
|
272
|
+
)
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
[routing]
|
|
2
|
+
default_host = "agy"
|
|
3
|
+
host_affinity_epsilon = 0.0
|
|
4
|
+
namespace_separator = "/"
|
|
5
|
+
|
|
6
|
+
[agents.agy]
|
|
7
|
+
enabled = true
|
|
8
|
+
max_session_used_pct = 90.0
|
|
9
|
+
max_weekly_used_pct = 95.0
|
|
10
|
+
max_tier_used_pct = 95.0
|
|
11
|
+
host_reserve_pct = 0.0
|
|
12
|
+
usage_probe_cmd = []
|
|
13
|
+
|
|
14
|
+
[[agents.agy.native_candidates]]
|
|
15
|
+
model = "pro"
|
|
16
|
+
effort = "standard"
|
|
17
|
+
tier = "pro"
|
|
18
|
+
|
|
19
|
+
[[agents.agy.native_candidates]]
|
|
20
|
+
model = "flash"
|
|
21
|
+
effort = "standard"
|
|
22
|
+
tier = "flash"
|
|
23
|
+
|
|
24
|
+
[[agents.agy.native_candidates]]
|
|
25
|
+
model = "flash_lite"
|
|
26
|
+
effort = "standard"
|
|
27
|
+
tier = "flash_lite"
|
|
28
|
+
|
|
29
|
+
[[agents.agy.cli_candidates]]
|
|
30
|
+
model = "gemini-2.5-pro"
|
|
31
|
+
effort = "high"
|
|
32
|
+
tier = "pro"
|
|
33
|
+
|
|
34
|
+
[[agents.agy.cli_candidates]]
|
|
35
|
+
model = "gemini-2.5-flash"
|
|
36
|
+
effort = "medium"
|
|
37
|
+
tier = "flash"
|
|
38
|
+
|
|
39
|
+
[agents.codex]
|
|
40
|
+
enabled = true
|
|
41
|
+
max_session_used_pct = 90.0
|
|
42
|
+
max_weekly_used_pct = 95.0
|
|
43
|
+
max_tier_used_pct = 95.0
|
|
44
|
+
host_reserve_pct = 0.0
|
|
45
|
+
usage_probe_cmd = []
|
|
46
|
+
|
|
47
|
+
[[agents.codex.native_candidates]]
|
|
48
|
+
model = "gpt-5.4"
|
|
49
|
+
effort = "high"
|
|
50
|
+
|
|
51
|
+
[[agents.codex.native_candidates]]
|
|
52
|
+
model = "gpt-5.4"
|
|
53
|
+
effort = "medium"
|
|
54
|
+
|
|
55
|
+
[[agents.codex.cli_candidates]]
|
|
56
|
+
model = "gpt-5.4"
|
|
57
|
+
effort = "high"
|
|
58
|
+
|
|
59
|
+
[[agents.codex.cli_candidates]]
|
|
60
|
+
model = "gpt-5.4"
|
|
61
|
+
effort = "medium"
|
|
62
|
+
|
|
63
|
+
[[agents.codex.cli_candidates]]
|
|
64
|
+
model = "gpt-5.4-mini"
|
|
65
|
+
effort = "low"
|
|
66
|
+
|
|
67
|
+
[agents.claude]
|
|
68
|
+
enabled = true
|
|
69
|
+
max_session_used_pct = 90.0
|
|
70
|
+
max_weekly_used_pct = 95.0
|
|
71
|
+
max_tier_used_pct = 95.0
|
|
72
|
+
host_reserve_pct = 0.0
|
|
73
|
+
usage_probe_cmd = []
|
|
74
|
+
|
|
75
|
+
[[agents.claude.native_candidates]]
|
|
76
|
+
model = "claude-opus-4-6"
|
|
77
|
+
effort = "high"
|
|
78
|
+
tier = "opus"
|
|
79
|
+
|
|
80
|
+
[[agents.claude.native_candidates]]
|
|
81
|
+
model = "claude-sonnet-4-6"
|
|
82
|
+
effort = "high"
|
|
83
|
+
tier = "sonnet"
|
|
84
|
+
|
|
85
|
+
[[agents.claude.native_candidates]]
|
|
86
|
+
model = "claude-sonnet-4-6"
|
|
87
|
+
effort = "medium"
|
|
88
|
+
tier = "sonnet"
|
|
89
|
+
|
|
90
|
+
[[agents.claude.cli_candidates]]
|
|
91
|
+
model = "claude-opus-4-6"
|
|
92
|
+
effort = "high"
|
|
93
|
+
tier = "opus"
|
|
94
|
+
|
|
95
|
+
[[agents.claude.cli_candidates]]
|
|
96
|
+
model = "claude-sonnet-4-6"
|
|
97
|
+
effort = "high"
|
|
98
|
+
tier = "sonnet"
|
|
99
|
+
|
|
100
|
+
[[agents.claude.cli_candidates]]
|
|
101
|
+
model = "claude-sonnet-4-6"
|
|
102
|
+
effort = "medium"
|
|
103
|
+
tier = "sonnet"
|
agroute/files.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Atomic writes for backend-owned durable state."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import tempfile
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def atomic_write(path, data):
|
|
9
|
+
path = Path(path)
|
|
10
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
11
|
+
if isinstance(data, str):
|
|
12
|
+
data = data.encode("utf-8")
|
|
13
|
+
name = None
|
|
14
|
+
try:
|
|
15
|
+
with tempfile.NamedTemporaryFile(dir=path.parent, delete=False) as handle:
|
|
16
|
+
name = handle.name
|
|
17
|
+
handle.write(data)
|
|
18
|
+
handle.flush()
|
|
19
|
+
os.fsync(handle.fileno())
|
|
20
|
+
os.replace(name, path)
|
|
21
|
+
finally:
|
|
22
|
+
if name and os.path.exists(name):
|
|
23
|
+
os.unlink(name)
|
agroute/py.typed
ADDED
|
File without changes
|