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

1"""Agent dispatch + attribution — the pure resolution logic. 

2 

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. 

15 

16All functions here are pure and deterministic — no subprocess, no network. 

17""" 

18 

19from __future__ import annotations 

20 

21from . import team 

22from .config import DELEGATE_PROFILE_VENDORS, DelegateProfile, ProjectConfig 

23 

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 

32 

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] 

65 

66#: Default host agent when nothing else is resolved. 

67HOST_DEFAULT = "claude" 

68 

69 

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) 

74 

75 

76def known_vendors(config: ProjectConfig | None = None) -> frozenset[str]: 

77 """Every vendor slug keel's attribution vocabulary can legitimately produce. 

78 

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. 

83 

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. 

90 

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) 

101 

102 

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 

106 

107 

108def resolve_delegate_profile(config: ProjectConfig, name: str) -> DelegateProfile | None: 

109 """The configured delegate profile for ``name``, or ``None``. 

110 

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) 

119 

120 

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 

124 

125 

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}) 

129 

130 

131def legacy_team_seats(config: ProjectConfig) -> dict[str, team.Seat]: 

132 """``knobs.implementer_agents`` read as ``team.implement.by_role`` seats (#1014). 

133 

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)) 

140 

141 

142def known_roles(config: ProjectConfig) -> frozenset[str]: 

143 """Every role name this project's routing can be keyed on, in **either** vocabulary. 

144 

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). 

152 

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). 

158 

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}) 

164 

165 

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",) 

171 

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) 

176 

177 

178def strip_transport(model: str) -> str: 

179 """Drop a ``<transport>:`` prefix, leaving the vendor's own model id. 

180 

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``. 

185 

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 

195 

196 

197def model_base(model: str) -> str: 

198 """Strip a model id to a coarse, versionless base label (ship #2036 algorithm). 

199 

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] 

218 

219 

220def agent_label(vendor: str) -> str: 

221 """The persistent ``agent:<vendor>`` label.""" 

222 return f"agent:{vendor}" 

223 

224 

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 

229 

230 

231def attribution_labels(config: ProjectConfig | None = None) -> tuple[str, ...]: 

232 """Every ``agent:*`` / ``model:*`` label keel's attribution vocabulary can write. 

233 

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. 

238 

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. 

244 

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. 

252 

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)) 

267 

268 

269def attribution(vendor: str, model: str | None = None) -> dict[str, str | None]: 

270 """Resolve the effective attribution for an implementer/reviewer. 

271 

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 } 

281 

282 

283def attribution_from_implementer(implementer: str | None) -> dict[str, str | None] | None: 

284 """Attribution for a ledger ``actors.implementer`` value, or ``None`` when unset. 

285 

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) 

299 

300 

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). 

307 

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``. 

311 

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. 

315 

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 

325 

326 

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._-") 

332 

333 

334def is_safe_model_token(model: str | None) -> bool: 

335 """True when ``model`` is safe to pass to a delegate CLI as an argument. 

336 

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)