otto-cli-agent 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/README.md +22 -0
- agent/__init__.py +0 -0
- agent/cli/README.md +77 -0
- agent/cli/__init__.py +0 -0
- agent/cli/art.py +371 -0
- agent/cli/chat.py +309 -0
- agent/cli/clipboard.py +106 -0
- agent/cli/context.py +56 -0
- agent/cli/doctor.py +60 -0
- agent/cli/errors.py +58 -0
- agent/cli/eval.py +130 -0
- agent/cli/eval_claw.py +414 -0
- agent/cli/eval_compaction.py +106 -0
- agent/cli/eval_hle.py +92 -0
- agent/cli/eval_memory.py +253 -0
- agent/cli/eval_swe.py +172 -0
- agent/cli/lessons.py +97 -0
- agent/cli/main.py +67 -0
- agent/cli/modals.py +570 -0
- agent/cli/models.py +64 -0
- agent/cli/output.py +54 -0
- agent/cli/route.py +78 -0
- agent/cli/sessions.py +125 -0
- agent/cli/setup_screen.py +562 -0
- agent/cli/shell.py +548 -0
- agent/cli/tui.py +1807 -0
- agent/cli/ui.py +14 -0
- agent/cli/usage_panel.py +159 -0
- agent/config/README.md +7 -0
- agent/config/__init__.py +0 -0
- agent/config/envfile.py +76 -0
- agent/eval/README.md +76 -0
- agent/eval/__init__.py +0 -0
- agent/eval/claw_bench.py +1031 -0
- agent/eval/compaction_bench.py +229 -0
- agent/eval/data/README.md +10 -0
- agent/eval/data/claw/README.md +108 -0
- agent/eval/data/claw/llm_judge-gemini.patch +57 -0
- agent/eval/data/claw/otto.yaml +35 -0
- agent/eval/failures.py +276 -0
- agent/eval/golden/README.md +33 -0
- agent/eval/golden/code_01.json +6 -0
- agent/eval/golden/code_02.json +6 -0
- agent/eval/golden/code_03.json +6 -0
- agent/eval/golden/code_04.json +6 -0
- agent/eval/golden/code_05.json +6 -0
- agent/eval/golden/code_06.json +6 -0
- agent/eval/golden/math_01.json +6 -0
- agent/eval/golden/math_02.json +6 -0
- agent/eval/golden/math_03.json +6 -0
- agent/eval/golden/math_04.json +6 -0
- agent/eval/golden/math_05.json +6 -0
- agent/eval/golden/math_06.json +6 -0
- agent/eval/golden/nphard_gcp_01.json +6 -0
- agent/eval/golden/nphard_ksp_01.json +6 -0
- agent/eval/golden/nphard_math_binpacking_01.json +6 -0
- agent/eval/golden/nphard_math_clique_01.json +6 -0
- agent/eval/golden/nphard_math_setcover_01.json +6 -0
- agent/eval/golden/nphard_math_subsetsum_01.json +6 -0
- agent/eval/golden/nphard_tsp_01.json +6 -0
- agent/eval/golden/nphard_tsp_02.json +6 -0
- agent/eval/hle_bench.py +273 -0
- agent/eval/langfuse_sync.py +172 -0
- agent/eval/memory_bench.py +538 -0
- agent/eval/runner.py +174 -0
- agent/eval/single_agent.py +120 -0
- agent/eval/swe_bench.py +604 -0
- agent/eval/terminal_bench.py +345 -0
- agent/memory/README.md +102 -0
- agent/memory/__init__.py +42 -0
- agent/memory/embeddings.py +302 -0
- agent/memory/hashing.py +15 -0
- agent/memory/lessons.py +483 -0
- agent/memory/queue.py +531 -0
- agent/memory/retrieval.py +493 -0
- agent/memory/session.py +60 -0
- agent/memory/sessions.py +436 -0
- agent/memory/store.py +429 -0
- agent/memory/tokens.py +60 -0
- agent/memory/wiring.py +146 -0
- agent/pipeline/README.md +135 -0
- agent/pipeline/__init__.py +0 -0
- agent/pipeline/browsing.py +609 -0
- agent/pipeline/budget.py +403 -0
- agent/pipeline/codemap.py +254 -0
- agent/pipeline/evidence.py +325 -0
- agent/pipeline/execution.py +67 -0
- agent/pipeline/modes.py +137 -0
- agent/pipeline/native.py +1137 -0
- agent/pipeline/nodes.py +3644 -0
- agent/pipeline/pricing.py +209 -0
- agent/pipeline/progress.py +139 -0
- agent/pipeline/rag.py +139 -0
- agent/pipeline/research.py +1325 -0
- agent/pipeline/run.py +528 -0
- agent/pipeline/screen.py +77 -0
- agent/pipeline/state.py +220 -0
- agent/pipeline/toolkit.py +328 -0
- agent/pipeline/tools.py +1990 -0
- agent/pipeline/tracing.py +147 -0
- agent/pipeline/usage.py +251 -0
- agent/pipeline/vision.py +84 -0
- agent/pipeline/walkthrough.py +735 -0
- agent/pipeline/workspace.py +229 -0
- agent/router/README.md +60 -0
- agent/router/__init__.py +0 -0
- agent/router/automap.py +114 -0
- agent/router/health.py +229 -0
- agent/router/llm_provider/README.md +38 -0
- agent/router/llm_provider/__init__.py +202 -0
- agent/router/llm_provider/anthropic_provider.py +128 -0
- agent/router/llm_provider/base.py +507 -0
- agent/router/llm_provider/custom.py +152 -0
- agent/router/llm_provider/gemini_provider.py +122 -0
- agent/router/llm_provider/inception_provider.py +687 -0
- agent/router/llm_provider/openai_provider.py +151 -0
- agent/router/llm_provider/retired.py +145 -0
- agent/router/llm_provider/temperature.py +371 -0
- agent/router/mapping.py +579 -0
- agent/router/outcomes.py +363 -0
- agent/router/overrides.py +389 -0
- agent/router/reload.py +28 -0
- agent/router/router.py +413 -0
- agent/router/setup.py +123 -0
- otto_cli_agent-0.1.0.dist-info/METADATA +115 -0
- otto_cli_agent-0.1.0.dist-info/RECORD +129 -0
- otto_cli_agent-0.1.0.dist-info/WHEEL +4 -0
- otto_cli_agent-0.1.0.dist-info/entry_points.txt +2 -0
- otto_cli_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
"""What a person has pinned on top of the shipped routing table, and where.
|
|
2
|
+
|
|
3
|
+
`agent/router/mapping.py`'s TASK_ROUTES is a measured, hand-tuned table in
|
|
4
|
+
source. It cannot know which keys THIS machine has or which model its owner
|
|
5
|
+
wants judging their work, and editing Python to say so is not a setting. So
|
|
6
|
+
this module keeps a small file beside the other per-installation state:
|
|
7
|
+
|
|
8
|
+
~/.otto/routes.json
|
|
9
|
+
{
|
|
10
|
+
"version": 1,
|
|
11
|
+
"pins": {"reason": "openai:gpt-5-mini", "chat_fast": "ollama:llama3.2:latest"},
|
|
12
|
+
"endpoints": {"ollama": {"label": "Ollama",
|
|
13
|
+
"capabilities": {"llama3.2:latest": ["chat", "tools"]}}}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
`apply()` turns that into the live table, IN PLACE: each custom endpoint is
|
|
17
|
+
registered as a provider (agent/router/llm_provider/custom.py) and each pin
|
|
18
|
+
becomes a Candidate at the HEAD of that task's chain, with the shipped chain
|
|
19
|
+
kept behind it as the fallback path. In place, because two Routers read
|
|
20
|
+
TASK_ROUTES at resolve time -- the pipeline's module-level one and the CLI's
|
|
21
|
+
-- and a new dict would reach neither.
|
|
22
|
+
|
|
23
|
+
What a pin does not do: it does not bypass the endpoint. A pin on VISION still
|
|
24
|
+
requires the model to see, a pin on CODE_COMPLETE still has to be an Inception
|
|
25
|
+
FIM model, and WEB stays Anthropic because the web_search tool binds
|
|
26
|
+
Anthropic's own server-side search (agent/pipeline/tools.py). `validate_pin`
|
|
27
|
+
says so up front rather than letting a pin resolve cleanly and fail mid-run.
|
|
28
|
+
|
|
29
|
+
Keys are NOT here. They stay in the repo `.env` (agent/config/envfile.py),
|
|
30
|
+
which the four built-in vendors already read; a custom endpoint's
|
|
31
|
+
`<NAME>_API_KEY` and `<NAME>_BASE_URL` rows go beside them. This file holds
|
|
32
|
+
names and choices, nothing secret, and can be copied between machines as is.
|
|
33
|
+
"""
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import json
|
|
37
|
+
import logging
|
|
38
|
+
import os
|
|
39
|
+
from contextlib import contextmanager
|
|
40
|
+
from contextvars import ContextVar
|
|
41
|
+
from dataclasses import dataclass, field
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
from typing import Iterable, Iterator, Mapping
|
|
44
|
+
|
|
45
|
+
from agent.router import mapping
|
|
46
|
+
from agent.router.llm_provider import custom, is_custom, register_custom, unregister_custom
|
|
47
|
+
from agent.router.llm_provider import temperature
|
|
48
|
+
from agent.router.llm_provider.base import Capability
|
|
49
|
+
from agent.router.mapping import (
|
|
50
|
+
ENDPOINT_CAPABILITY,
|
|
51
|
+
INCEPTION_ONLY_ENDPOINTS,
|
|
52
|
+
PARAMS_BY_PROVIDER,
|
|
53
|
+
TASK_ROUTES,
|
|
54
|
+
Candidate,
|
|
55
|
+
MappingError,
|
|
56
|
+
Task,
|
|
57
|
+
)
|
|
58
|
+
from agent.router.outcomes import DB_DIR
|
|
59
|
+
|
|
60
|
+
log = logging.getLogger(__name__)
|
|
61
|
+
|
|
62
|
+
VERSION = 1
|
|
63
|
+
#: Set to skip the file entirely -- an eval harness that must measure the
|
|
64
|
+
#: shipped table, not this machine's preferences.
|
|
65
|
+
IGNORE_ENV = "OTTO_IGNORE_ROUTES"
|
|
66
|
+
#: Point at a different file (tests; a second profile).
|
|
67
|
+
PATH_ENV = "OTTO_ROUTES"
|
|
68
|
+
|
|
69
|
+
#: Which provider a task is bound to regardless of pins, and why.
|
|
70
|
+
PROVIDER_ONLY: dict[Task, tuple[str, str]] = {
|
|
71
|
+
Task.WEB: ("anthropic", "web_search binds Anthropic's server-side search tool"),
|
|
72
|
+
Task.CODE_COMPLETE: ("inception", "only Inception serves the FIM endpoint"),
|
|
73
|
+
Task.CODE_EDIT: ("inception", "only Inception serves the edit endpoint"),
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class PinError(ValueError):
|
|
78
|
+
"""A pin that can be seen to be wrong without asking a provider."""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True)
|
|
82
|
+
class EndpointSpec:
|
|
83
|
+
label: str = ""
|
|
84
|
+
#: model id -> capability names, overriding the heuristic classifier.
|
|
85
|
+
capabilities: dict[str, tuple[str, ...]] = field(default_factory=dict)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True)
|
|
89
|
+
class Overrides:
|
|
90
|
+
pins: dict[Task, str] = field(default_factory=dict)
|
|
91
|
+
endpoints: dict[str, EndpointSpec] = field(default_factory=dict)
|
|
92
|
+
version: int = VERSION
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# --------------------------------------------------------------------------
|
|
96
|
+
# Where the file is
|
|
97
|
+
# --------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
_UNSET = object()
|
|
100
|
+
_PATH: ContextVar = ContextVar("otto_routes_path", default=_UNSET)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def routes_path() -> Path:
|
|
104
|
+
bound = _PATH.get()
|
|
105
|
+
if bound is not _UNSET:
|
|
106
|
+
return Path(bound)
|
|
107
|
+
env = os.environ.get(PATH_ENV, "").strip()
|
|
108
|
+
return Path(env).expanduser() if env else DB_DIR / "routes.json"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@contextmanager
|
|
112
|
+
def bind_routes(path: Path | str) -> Iterator[Path]:
|
|
113
|
+
"""Use `path` for the duration -- a test's tmp_path. Same seam shape as
|
|
114
|
+
agent/router/outcomes.py's bind_log."""
|
|
115
|
+
token = _PATH.set(Path(path))
|
|
116
|
+
try:
|
|
117
|
+
yield Path(path)
|
|
118
|
+
finally:
|
|
119
|
+
_PATH.reset(token)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# --------------------------------------------------------------------------
|
|
123
|
+
# Load / save
|
|
124
|
+
# --------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
def load(path: Path | None = None) -> Overrides:
|
|
127
|
+
"""The file's content, or an empty Overrides. Never raises: a corrupt
|
|
128
|
+
preferences file must not stop otto from starting -- it is logged and
|
|
129
|
+
the shipped table is used."""
|
|
130
|
+
path = path or routes_path()
|
|
131
|
+
try:
|
|
132
|
+
raw = json.loads(path.read_text())
|
|
133
|
+
except FileNotFoundError:
|
|
134
|
+
return Overrides()
|
|
135
|
+
except (OSError, ValueError) as exc:
|
|
136
|
+
log.warning("ignoring %s: %s", path, exc)
|
|
137
|
+
return Overrides()
|
|
138
|
+
if not isinstance(raw, dict) or raw.get("version") != VERSION:
|
|
139
|
+
log.warning("ignoring %s: unsupported format", path)
|
|
140
|
+
return Overrides()
|
|
141
|
+
|
|
142
|
+
pins: dict[Task, str] = {}
|
|
143
|
+
for key, spec in (raw.get("pins") or {}).items():
|
|
144
|
+
try:
|
|
145
|
+
task = Task(key)
|
|
146
|
+
except ValueError:
|
|
147
|
+
log.warning("%s: unknown task %r, skipped", path, key)
|
|
148
|
+
continue
|
|
149
|
+
if isinstance(spec, str) and spec.strip():
|
|
150
|
+
pins[task] = spec.strip()
|
|
151
|
+
|
|
152
|
+
endpoints: dict[str, EndpointSpec] = {}
|
|
153
|
+
for name, body in (raw.get("endpoints") or {}).items():
|
|
154
|
+
try:
|
|
155
|
+
custom.validate_name(name)
|
|
156
|
+
except ValueError as exc:
|
|
157
|
+
log.warning("%s: %s, skipped", path, exc)
|
|
158
|
+
continue
|
|
159
|
+
body = body if isinstance(body, dict) else {}
|
|
160
|
+
caps = {
|
|
161
|
+
str(model_id): tuple(str(c) for c in values)
|
|
162
|
+
for model_id, values in (body.get("capabilities") or {}).items()
|
|
163
|
+
if isinstance(values, (list, tuple))
|
|
164
|
+
}
|
|
165
|
+
endpoints[name] = EndpointSpec(label=str(body.get("label") or ""), capabilities=caps)
|
|
166
|
+
return Overrides(pins=pins, endpoints=endpoints)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def save(o: Overrides, path: Path | None = None) -> None:
|
|
170
|
+
path = path or routes_path()
|
|
171
|
+
payload = {
|
|
172
|
+
"version": VERSION,
|
|
173
|
+
"pins": {task.value: spec for task, spec in sorted(o.pins.items(), key=lambda kv: kv[0].value)},
|
|
174
|
+
"endpoints": {
|
|
175
|
+
name: {"label": spec.label,
|
|
176
|
+
**({"capabilities": {m: list(c) for m, c in spec.capabilities.items()}}
|
|
177
|
+
if spec.capabilities else {})}
|
|
178
|
+
for name, spec in sorted(o.endpoints.items())
|
|
179
|
+
},
|
|
180
|
+
}
|
|
181
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
182
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
183
|
+
tmp.write_text(json.dumps(payload, indent=2) + "\n")
|
|
184
|
+
os.replace(tmp, path)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# --------------------------------------------------------------------------
|
|
188
|
+
# Applying to the live table
|
|
189
|
+
# --------------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
#: The table as shipped -- snapshotted once, before any pin touches it, so a
|
|
192
|
+
#: cleared pin restores exactly what mapping.py declared.
|
|
193
|
+
_SHIPPED: dict[Task, tuple[Candidate, ...]] = dict(TASK_ROUTES)
|
|
194
|
+
#: The pin Candidate at the head of each pinned task, by identity. What
|
|
195
|
+
#: `is_pin()` answers from -- the router asks it to skip evidence reordering.
|
|
196
|
+
_ACTIVE: dict[Task, Candidate] = {}
|
|
197
|
+
_REGISTERED: set[str] = set()
|
|
198
|
+
_PROBLEMS: list[str] = []
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def shipped(task: Task) -> tuple[Candidate, ...]:
|
|
202
|
+
return _SHIPPED[task]
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def is_pin(task: Task, c: Candidate) -> bool:
|
|
206
|
+
return _ACTIVE.get(task) is c
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def pinned_spec(task: Task) -> str | None:
|
|
210
|
+
pin = _ACTIVE.get(task)
|
|
211
|
+
return pin.spec if pin is not None else None
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def active_pins() -> dict[Task, str]:
|
|
215
|
+
return {task: c.spec for task, c in _ACTIVE.items() if c.spec}
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def last_problems() -> list[str]:
|
|
219
|
+
return list(_PROBLEMS)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def validate_pin(task: Task, spec: str) -> None:
|
|
223
|
+
"""Shape and policy only -- whether the model exists is a question for a
|
|
224
|
+
live catalogue, which the setup screen asks before offering it."""
|
|
225
|
+
provider, sep, model = (spec or "").partition(":")
|
|
226
|
+
if not sep or not provider or not model:
|
|
227
|
+
raise PinError(f"{spec!r} is not 'provider:model'")
|
|
228
|
+
if provider not in mapping.known_providers():
|
|
229
|
+
raise PinError(
|
|
230
|
+
f"unknown provider {provider!r}; known: {sorted(mapping.known_providers())}")
|
|
231
|
+
bound = PROVIDER_ONLY.get(task)
|
|
232
|
+
if bound and provider != bound[0]:
|
|
233
|
+
raise PinError(f"{task.value} must stay on {bound[0]}: {bound[1]}")
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _pin_requires(head: Candidate) -> frozenset[Capability]:
|
|
237
|
+
# The endpoint's own capability always, VISION always. Nothing else: a
|
|
238
|
+
# person's explicit choice is not gated by a heuristic REASONING tag.
|
|
239
|
+
return frozenset({ENDPOINT_CAPABILITY[head.endpoint]}) | (head.requires & {Capability.VISION})
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _pin_params(provider: str, chain: tuple[Candidate, ...]) -> dict:
|
|
243
|
+
head = chain[0]
|
|
244
|
+
for c in chain:
|
|
245
|
+
if c.provider_name == provider:
|
|
246
|
+
return dict(c.params)
|
|
247
|
+
allowed = PARAMS_BY_PROVIDER.get(provider, {}).get(head.endpoint)
|
|
248
|
+
if allowed is not None:
|
|
249
|
+
return {k: v for k, v in head.params.items() if k in allowed}
|
|
250
|
+
return {k: v for k, v in head.params.items() if k in ("temperature", "max_tokens")}
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _register_endpoints(endpoints: Mapping[str, EndpointSpec]) -> list[str]:
|
|
254
|
+
problems: list[str] = []
|
|
255
|
+
for name in list(_REGISTERED - set(endpoints)):
|
|
256
|
+
unregister_custom(name)
|
|
257
|
+
mapping.unregister_provider_name(name)
|
|
258
|
+
_REGISTERED.discard(name)
|
|
259
|
+
for name, spec in endpoints.items():
|
|
260
|
+
try:
|
|
261
|
+
cls = custom.openai_compatible(name, capability_overrides=spec.capabilities)
|
|
262
|
+
register_custom(name, cls)
|
|
263
|
+
except Exception as exc: # bad name, SDK missing
|
|
264
|
+
problems.append(f"endpoint {name!r} not registered: {exc}")
|
|
265
|
+
continue
|
|
266
|
+
mapping.register_provider_name(name, params_like="openai")
|
|
267
|
+
temperature.register_provider(name)
|
|
268
|
+
_REGISTERED.add(name)
|
|
269
|
+
return problems
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def apply(routes: dict[Task, tuple[Candidate, ...]] = TASK_ROUTES,
|
|
273
|
+
o: Overrides | None = None, *,
|
|
274
|
+
shipped_routes: Mapping[Task, tuple[Candidate, ...]] | None = None) -> list[str]:
|
|
275
|
+
"""Rebuild `routes` from the shipped table plus `o`. Idempotent. Returns
|
|
276
|
+
the problems it found; every task with a problem keeps its shipped chain.
|
|
277
|
+
|
|
278
|
+
`shipped_routes` is for a caller working on a copy of the table (tests);
|
|
279
|
+
the live table always restores from the snapshot taken at import.
|
|
280
|
+
"""
|
|
281
|
+
o = load() if o is None else o
|
|
282
|
+
base = dict(shipped_routes) if shipped_routes is not None else (
|
|
283
|
+
_SHIPPED if routes is TASK_ROUTES else dict(routes))
|
|
284
|
+
problems = _register_endpoints(o.endpoints)
|
|
285
|
+
|
|
286
|
+
for task, chain in base.items():
|
|
287
|
+
routes[task] = chain
|
|
288
|
+
_ACTIVE.clear()
|
|
289
|
+
|
|
290
|
+
for task, spec in o.pins.items():
|
|
291
|
+
chain = base[task]
|
|
292
|
+
try:
|
|
293
|
+
validate_pin(task, spec)
|
|
294
|
+
provider, _, _ = spec.partition(":")
|
|
295
|
+
pin = Candidate(
|
|
296
|
+
spec=spec,
|
|
297
|
+
requires=_pin_requires(chain[0]),
|
|
298
|
+
endpoint=chain[0].endpoint,
|
|
299
|
+
params=_pin_params(provider, chain),
|
|
300
|
+
)
|
|
301
|
+
new_chain = (pin, *[c for c in chain if c.spec != spec])
|
|
302
|
+
mapping.validate({**routes, task: new_chain})
|
|
303
|
+
except (PinError, MappingError) as exc:
|
|
304
|
+
problems.append(f"pin {task.value} -> {spec}: {exc}")
|
|
305
|
+
continue
|
|
306
|
+
routes[task] = new_chain
|
|
307
|
+
_ACTIVE[task] = pin
|
|
308
|
+
|
|
309
|
+
_PROBLEMS[:] = problems
|
|
310
|
+
for problem in problems:
|
|
311
|
+
log.warning("routes: %s", problem)
|
|
312
|
+
return problems
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def apply_at_startup() -> None:
|
|
316
|
+
"""Called by agent/cli/main.py before the pipeline imports. Swallows
|
|
317
|
+
everything: a preferences file can degrade routing, never prevent it."""
|
|
318
|
+
if os.environ.get(IGNORE_ENV, "").strip():
|
|
319
|
+
return
|
|
320
|
+
try:
|
|
321
|
+
apply()
|
|
322
|
+
except Exception as exc: # pragma: no cover -- last line of defence
|
|
323
|
+
log.warning("could not apply %s: %s", routes_path(), exc)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
# --------------------------------------------------------------------------
|
|
327
|
+
# Edits -- each saves the file and re-applies
|
|
328
|
+
# --------------------------------------------------------------------------
|
|
329
|
+
|
|
330
|
+
def pins() -> dict[Task, str]:
|
|
331
|
+
return dict(load().pins)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def endpoints() -> dict[str, EndpointSpec]:
|
|
335
|
+
return dict(load().endpoints)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def set_pin(task: Task, spec: str) -> None:
|
|
339
|
+
o = load()
|
|
340
|
+
if o.endpoints:
|
|
341
|
+
_register_endpoints(o.endpoints) # so validate_pin knows custom names
|
|
342
|
+
validate_pin(task, spec)
|
|
343
|
+
new = Overrides(pins={**o.pins, task: spec}, endpoints=o.endpoints)
|
|
344
|
+
problems = [p for p in apply(o=new) if p.startswith(f"pin {task.value} ")]
|
|
345
|
+
if problems:
|
|
346
|
+
apply(o=o)
|
|
347
|
+
raise PinError(problems[0])
|
|
348
|
+
save(new)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def clear_pin(task: Task) -> None:
|
|
352
|
+
o = load()
|
|
353
|
+
if task not in o.pins:
|
|
354
|
+
return
|
|
355
|
+
new = Overrides(pins={t: s for t, s in o.pins.items() if t != task}, endpoints=o.endpoints)
|
|
356
|
+
save(new)
|
|
357
|
+
apply(o=new)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def add_endpoint(name: str, label: str = "") -> None:
|
|
361
|
+
custom.validate_name(name)
|
|
362
|
+
o = load()
|
|
363
|
+
spec = o.endpoints.get(name, EndpointSpec())
|
|
364
|
+
new = Overrides(pins=o.pins, endpoints={**o.endpoints, name: EndpointSpec(label or spec.label, spec.capabilities)})
|
|
365
|
+
save(new)
|
|
366
|
+
apply(o=new)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def remove_endpoint(name: str) -> None:
|
|
370
|
+
"""Forget an endpoint and every pin on it. The caller unsets the .env rows."""
|
|
371
|
+
o = load()
|
|
372
|
+
new = Overrides(
|
|
373
|
+
pins={t: s for t, s in o.pins.items() if s.partition(":")[0] != name},
|
|
374
|
+
endpoints={n: e for n, e in o.endpoints.items() if n != name},
|
|
375
|
+
)
|
|
376
|
+
save(new)
|
|
377
|
+
apply(o=new)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def set_capabilities(endpoint: str, model_id: str, caps: Iterable[Capability | str]) -> None:
|
|
381
|
+
o = load()
|
|
382
|
+
if endpoint not in o.endpoints:
|
|
383
|
+
raise PinError(f"no endpoint called {endpoint!r}")
|
|
384
|
+
spec = o.endpoints[endpoint]
|
|
385
|
+
names = tuple(Capability(c).value for c in caps)
|
|
386
|
+
new_spec = EndpointSpec(spec.label, {**spec.capabilities, model_id: names})
|
|
387
|
+
new = Overrides(pins=o.pins, endpoints={**o.endpoints, endpoint: new_spec})
|
|
388
|
+
save(new)
|
|
389
|
+
apply(o=new)
|
agent/router/reload.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""One call after keys, endpoints or pins changed in the running process.
|
|
2
|
+
|
|
3
|
+
Three things have to happen, in this order, and every front end used to be
|
|
4
|
+
one forgotten step away from a stale router:
|
|
5
|
+
|
|
6
|
+
1. `overrides.apply()` -- re-register custom endpoints and rebuild the
|
|
7
|
+
pinned chains in the live table;
|
|
8
|
+
2. `llm_provider.reset()` -- drop cached provider instances and classes, so
|
|
9
|
+
a new key or a new base URL is read on the next construction;
|
|
10
|
+
3. `Router.reset_all()` -- every live router re-snapshots which providers
|
|
11
|
+
are configured (agent/pipeline/nodes.py's module-level one included).
|
|
12
|
+
|
|
13
|
+
Precondition: call it between turns. A graph thread mid-resolve sees only
|
|
14
|
+
whole-tuple swaps, so nothing tears, but a turn already handed its model a
|
|
15
|
+
key it would be confusing to change under it.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from agent.router import llm_provider, overrides
|
|
20
|
+
from agent.router.router import Router
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def reload_everything() -> list[str]:
|
|
24
|
+
"""Returns the problems `overrides.apply()` reported, for display."""
|
|
25
|
+
problems = overrides.apply()
|
|
26
|
+
llm_provider.reset()
|
|
27
|
+
Router.reset_all()
|
|
28
|
+
return problems
|