Coverage for src/keel/agents.py: 100%
92 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-02 20:26 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-02 20:26 +0000
1"""Agent dispatch + attribution — the pure resolution logic.
3The backbone dispatches agentic steps (implement / review / extensions) to a
4configured agent: the **host agent** by default, a per-run **delegate** override,
5or a per-role seat from ``knobs.team.implement.by_role``. *Which* seat that is, is
6:func:`keel.team.resolve_assignment`'s single answer — this module owns the delegate
7vocabulary that feeds it and the attribution written afterwards, not a second copy of
8the precedence rule (#1099). :func:`legacy_team_seats` is the bridge: it reads the
9deprecated ``knobs.implementer_agents`` as ``by_role`` seats so the one resolver can
10fall back to it. A delegate is either a built-in vendor
11(:data:`BUILTIN_DELEGATE_VENDORS`) or the name of a generic ``knobs.delegate_profiles``
12entry — built-ins always win. Attribution records the *effective* implementer as labels
13(``agent:<vendor>`` + a versionless ``model:<base>``), reusing the ship #2036 stripping
14algorithm.
16All functions here are pure and deterministic — no subprocess, no network.
17"""
19from __future__ import annotations
21from . import team
22from .config import DELEGATE_PROFILE_VENDORS, DelegateProfile, ProjectConfig
24# The vendor vocabulary itself lives in the leaf :mod:`keel.vocab`, so the *validating*
25# half of keel (``keel.team``, ``keel.config``) can read it without importing dispatch
26# (#1050). Re-exported here under the original names: ``agents.CLI_VENDORS`` and friends
27# are what the rest of the package, the docs and the tests have always read.
28from .vocab import API_VENDORS as API_VENDORS
29from .vocab import BUILTIN_DELEGATE_VENDORS as BUILTIN_DELEGATE_VENDORS
30from .vocab import CLI_VENDORS as CLI_VENDORS
31from .vocab import LOCAL_VENDORS as LOCAL_VENDORS
33#: The module's public surface, in definition order (#1070). It is declared because the
34#: ``X as X`` re-exports above are read from *other* modules — a use CodeQL's
35#: ``py/unused-import`` cannot see, since it counts same-module uses only. A name listed
36#: in ``__all__`` is used by definition, so the declaration answers the scanner with the
37#: language's own statement of intent rather than with a dismissal. Being a real
38#: declaration it has to be the *whole* surface, not the re-exports alone;
39#: ``tests/test_reexport_surface.py`` holds it to that in both directions.
40__all__ = [
41 "API_VENDORS",
42 "BUILTIN_DELEGATE_VENDORS",
43 "CLI_VENDORS",
44 "LOCAL_VENDORS",
45 "HOST_DEFAULT",
46 "split_delegate",
47 "known_vendors",
48 "is_api_delegate",
49 "resolve_delegate_profile",
50 "is_profile_delegate",
51 "provider_names",
52 "legacy_team_seats",
53 "known_roles",
54 "LOCAL_TRANSPORTS",
55 "strip_transport",
56 "model_base",
57 "agent_label",
58 "model_label",
59 "attribution_labels",
60 "attribution",
61 "attribution_from_implementer",
62 "profile_attribution",
63 "is_safe_model_token",
64]
66#: Default host agent when nothing else is resolved.
67HOST_DEFAULT = "claude"
70def split_delegate(value: str) -> tuple[str, str | None]:
71 """Split ``ollama:qwen2.5`` -> ``("ollama", "qwen2.5")``; ``codex`` -> ``("codex", None)``."""
72 vendor, sep, model = value.partition(":")
73 return vendor, (model if (sep and model) else None)
76def known_vendors(config: ProjectConfig | None = None) -> frozenset[str]:
77 """Every vendor slug keel's attribution vocabulary can legitimately produce.
79 The built-in vendors, the host default, the profile vendors a
80 ``knobs.delegate_profiles`` entry may declare (``cli`` /
81 ``openai-compatible``), and — when a config is supplied — the configured
82 profile *names*, because ``--delegate <name>`` is spelled with the name.
84 It must also contain each profile's :meth:`~keel.config.DelegateProfile.label_vendor`,
85 which is the vendor attribution actually **produces** for that entry (#1129). Adding
86 the field without adding it here split the vocabulary from the labels: `keel doctor`
87 demanded ``agent:xai`` while ``keel attribution --vendor xai`` answered *unknown
88 vendor*, and ``ship --live --append-ledger`` warned that the implementer it had just
89 recorded was not one of keel's delegate vendors. Both gate seats found it.
91 Callers use this to refuse a vendor keel could never have produced. Without
92 a config the set is the configuration-free vocabulary, which is why the
93 ledger-writing check only warns: a record may predate the current config.
94 """
95 names = {*BUILTIN_DELEGATE_VENDORS, *DELEGATE_PROFILE_VENDORS, HOST_DEFAULT}
96 if config is not None:
97 names.update(config.knobs.delegate_profiles)
98 names.update(profile.label_vendor() for profile in config.knobs.delegate_profiles.values())
99 names.update(profile.vendor for profile in config.knobs.delegate_profiles.values())
100 return frozenset(names)
103def is_api_delegate(vendor: str) -> bool:
104 """True when ``vendor`` is a hosted-API delegate (``anthropic-api``/``openai-api``)."""
105 return vendor in API_VENDORS
108def resolve_delegate_profile(config: ProjectConfig, name: str) -> DelegateProfile | None:
109 """The configured delegate profile for ``name``, or ``None``.
111 ``name`` is the bare ``--delegate`` token (``split_delegate``'s vendor part). A
112 built-in vendor **always wins** and never resolves to a profile — config cannot
113 redefine ``codex`` even if a same-named profile somehow reached this point
114 (:func:`keel.config.parse_config` rejects that shadowing up front).
115 """
116 if name in BUILTIN_DELEGATE_VENDORS:
117 return None
118 return config.knobs.delegate_profiles.get(name)
121def is_profile_delegate(config: ProjectConfig, name: str) -> bool:
122 """True when ``--delegate <name>`` dispatches to a generic delegate profile."""
123 return resolve_delegate_profile(config, name) is not None
126def provider_names(config: ProjectConfig) -> frozenset[str]:
127 """Every provider name this project can select without a machine-level registry."""
128 return frozenset({*BUILTIN_DELEGATE_VENDORS, *config.knobs.delegate_profiles})
131def legacy_team_seats(config: ProjectConfig) -> dict[str, team.Seat]:
132 """``knobs.implementer_agents`` read as ``team.implement.by_role`` seats (#1014).
134 The deprecated knob stays accepted; this is where its values acquire the meaning the
135 schema never stated. A value that names a provider this project can select is that
136 provider; anything else is the Claude subagent ``ship.md`` s4 always treated it as,
137 and gets the explicit ``subagent:`` prefix.
138 """
139 return team.legacy_seats(config.knobs.implementer_agents, provider_names=provider_names(config))
142def known_roles(config: ProjectConfig) -> frozenset[str]:
143 """Every role name this project's routing can be keyed on, in **either** vocabulary.
145 ``team.implement.by_role`` (#1014) is where a role lives now, and the deprecated
146 ``knobs.implementer_agents`` still routes for a project that has not migrated — so a
147 role may be spelled in either, and the set of role names is the union of both key
148 sets. Reading only the old one silently stopped narrowing the role for any project
149 that had adopted ``team``, including keel itself; #1014 had to correct that in two
150 files at once, and this is the one place it is now stated, so a third vocabulary — or
151 the day the deprecated knob is finally dropped — is one edit and not a search (#1107).
153 It sits beside :func:`legacy_team_seats`, the other bridge from the deprecated knob
154 into the current vocabulary, because :mod:`keel.team` — where the rest of the team
155 policy lives — takes exactly one keel import (the leaf :mod:`keel.vocab`) and cannot
156 read a :class:`~keel.config.ProjectConfig` without :mod:`keel.config` importing it
157 back (#1050).
159 The rule is *which spellings name a role*, and only that: a caller that needs an
160 order imposes its own. The ``implement`` contract sorts the result for its
161 ``routing_keys`` because the contract's ordering is the contract's business.
162 """
163 return frozenset({*config.knobs.implementer_agents, *config.knobs.team.implement_by_role})
166#: Transports that run a model on the operator's own hardware. Named separately
167#: because :mod:`keel.cost` prices the *tier* at zero rather than the model —
168#: the one place pricing and attribution want different halves of the same
169#: string. ``local`` is not a keel delegate; it appears in ids keel ingests.
170LOCAL_TRANSPORTS = LOCAL_VENDORS + ("local",)
172#: Prefixes that name a **transport** rather than a model. Derived from the
173#: vendor tuples above, so a vendor added there is covered the day it lands
174#: instead of on the day someone remembers this list.
175_TRANSPORT_PREFIXES = frozenset(API_VENDORS + LOCAL_TRANSPORTS)
178def strip_transport(model: str) -> str:
179 """Drop a ``<transport>:`` prefix, leaving the vendor's own model id.
181 Both ``ollama:qwen2.5:7b`` and ``anthropic-api:claude-opus-4-5`` carry a
182 colon and only the second has the model on the right, so the colon has to be
183 read by *what is on either side of it* — never by position (#955). Reading
184 it positionally is what labelled every hosted-API run ``model:anthropic-api``.
186 A colon that is not preceded by a transport belongs to the model: an Ollama
187 ``:tag`` (``qwen2.5:7b``) or a Bedrock revision (``…-v1:0``). Those are left
188 for the caller, which knows whether it wants the tag.
189 """
190 m = model.strip().lower()
191 head, sep, tail = m.partition(":")
192 if sep and tail and head in _TRANSPORT_PREFIXES:
193 return tail
194 return m
197def model_base(model: str) -> str:
198 """Strip a model id to a coarse, versionless base label (ship #2036 algorithm).
200 Examples: ``qwen2.5:7b`` -> ``qwen``, ``gemma2`` -> ``gemma``,
201 ``llama3.1`` -> ``llama``, ``gpt-5.5`` -> ``gpt-5``, ``gpt-4o`` -> ``gpt-4o``,
202 ``anthropic-api:claude-opus-4-5`` -> ``claude-opus-4-5``.
203 """
204 m = strip_transport(model)
205 if not m:
206 return ""
207 m = m.split(":", 1)[0] # (1) drop any ollama :tag
208 if "-" in m:
209 # (3) hyphenated family: keep <word>-<major>, drop the .minor
210 head, _, tail = m.partition("-")
211 major = tail.split(".", 1)[0]
212 return f"{head}-{major}"
213 # (2) non-hyphenated family: drop the trailing numeric run (digits + dots)
214 i = len(m)
215 while i > 0 and (m[i - 1].isdigit() or m[i - 1] == "."):
216 i -= 1
217 return m[:i]
220def agent_label(vendor: str) -> str:
221 """The persistent ``agent:<vendor>`` label."""
222 return f"agent:{vendor}"
225def model_label(model: str) -> str | None:
226 """The versionless ``model:<base>`` label, or ``None`` when no base is known."""
227 base = model_base(model)
228 return f"model:{base}" if base else None
231def attribution_labels(config: ProjectConfig | None = None) -> tuple[str, ...]:
232 """Every ``agent:*`` / ``model:*`` label keel's attribution vocabulary can write.
234 Sorted and deduplicated, for ``keel doctor``'s ``policy_labels`` check (#1021): a
235 label keel applies must already exist on the repository or GitHub rejects the call,
236 and the attribution pair is applied by name just like the policy pack's own
237 vocabularies.
239 The set is the built-in vendors plus the host default and — when a config is given —
240 each ``knobs.delegate_profiles`` entry's **label vendor** (its ``vendor_label`` when
241 set, else the generic ``cli``; the label :func:`profile_attribution` writes — the
242 profile *name* goes in ``delegate_profile``, never in a label) and the model that
243 entry pins.
245 A machine-level ``~/.keel/providers.yaml`` entry's ``vendor_label`` is **not**
246 enumerable here and never will be: this check exists so ``keel doctor`` can tell an
247 operator that a label keel may apply is missing from the repository, and the registry
248 lives outside the repository, on one machine, unread by the project config. An
249 operator who labels a registry entry has to create ``agent:<label>`` themselves —
250 which is the same trade the registry already makes everywhere else, and is documented
251 beside the field.
253 ``model:*`` is only enumerable that far. The effective model can arrive per run from
254 ``--delegate <vendor>:<model>`` or ``keel delegate run --model``, so the labels minted
255 from those are unbounded and no check can list them ahead of time.
256 """
257 vendors = {*BUILTIN_DELEGATE_VENDORS, HOST_DEFAULT}
258 models: set[str] = set()
259 if config is not None:
260 for profile in config.knobs.delegate_profiles.values():
261 vendors.add(profile.label_vendor())
262 if profile.model:
263 models.add(profile.model)
264 labels = {agent_label(vendor) for vendor in vendors}
265 labels.update(label for label in map(model_label, models) if label)
266 return tuple(sorted(labels))
269def attribution(vendor: str, model: str | None = None) -> dict[str, str | None]:
270 """Resolve the effective attribution for an implementer/reviewer.
272 Returns ``{"agent_label", "model_label", "system"}`` where ``system`` is the
273 full ``vendor`` or ``vendor:model`` string for the closure comment.
274 """
275 system = f"{vendor}:{model}" if model else vendor
276 return {
277 "agent_label": agent_label(vendor),
278 "model_label": model_label(model) if model else None,
279 "system": system,
280 }
283def attribution_from_implementer(implementer: str | None) -> dict[str, str | None] | None:
284 """Attribution for a ledger ``actors.implementer`` value, or ``None`` when unset.
286 The ledger records the effective implementer as ``vendor`` or ``vendor:model``
287 (issue #1013 — never the delegate-profile name, which goes in
288 ``delegate_profile``). Splitting it here rather than at each call site is what
289 keeps the PR labels, the provenance comment and the evidence cross-check reading
290 the *same* vocabulary from the *same* string instead of three hand-written ones.
291 """
292 if not isinstance(implementer, str) or not implementer.strip():
293 return None
294 vendor, model = split_delegate(implementer.strip())
295 vendor = vendor.strip().lower()
296 if not vendor:
297 return None
298 return attribution(vendor, model)
301def profile_attribution(
302 name: str,
303 profile: DelegateProfile,
304 model: str | None = None,
305) -> dict[str, str | None]:
306 """Attribution for a generic delegate profile (issue #659).
308 ``agent:<vendor>`` (``agent:cli``) plus the **effective** model — the same shape as
309 :func:`attribution` — with an extra key naming the entry, so the closure comment can
310 say *which* CLI ran rather than just ``cli``.
312 That key is ``delegate_profile``, **not** ``profile``: the ship run record already
313 uses ``profile`` for the workflow profile (``standard``/``compound``), so merging
314 this dict into the record under the shorter name would silently overwrite it.
316 ``model`` is the per-run override from ``--delegate <profile>:<model>`` and wins
317 over the profile's own ``model``, matching the precedence s4 documents. Without it
318 the helper could only ever report the configured model, which would break keel's
319 rule that attribution records the *effective* implementer whenever an operator
320 picked a model per run.
321 """
322 record = attribution(profile.label_vendor(), model or profile.model)
323 record["delegate_profile"] = name
324 return record
327#: Characters a per-run model token may contain. Deliberately tight: the effective model
328#: can arrive per run from ``--delegate <profile>:<model>`` or ``keel delegate run
329#: --model``, which is a lower-trust source than the operator-authored ``command``, and it
330#: ends up on a subprocess argv.
331_MODEL_TOKEN_OK = frozenset("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-")
334def is_safe_model_token(model: str | None) -> bool:
335 """True when ``model`` is safe to pass to a delegate CLI as an argument.
337 A profile's ``command`` is operator-authored config, but the *model* beside it may
338 come from an issue label, so it does not carry the same trust. Anything outside
339 ``[A-Za-z0-9._-]`` — whitespace, quotes, shell metacharacters, a leading dash that
340 would read as another flag — is rejected rather than escaped, because no legitimate
341 model id needs it. Empty/``None`` is False: pass no model instead.
342 """
343 if not model:
344 return False
345 if model.startswith("-"):
346 return False
347 return _MODEL_TOKEN_OK.issuperset(model)