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 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
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
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