Coverage for src/keel/agents.py: 100%
56 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-18 12:05 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-18 12:05 +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 agent from ``knobs.implementer_agents``. A delegate is either a
6built-in vendor (:data:`BUILTIN_DELEGATE_VENDORS`) or the name of a generic
7``knobs.delegate_profiles`` entry — built-ins always win. Attribution records the
8*effective* implementer as labels (``agent:<vendor>`` + a versionless
9``model:<base>``), reusing the ship #2036 stripping algorithm.
11All functions here are pure and deterministic — no subprocess, no network.
12"""
14from __future__ import annotations
16from .config import DelegateProfile, ProjectConfig
18#: Default host agent when nothing else is resolved.
19HOST_DEFAULT = "claude"
21#: Hosted-API delegate vendors (#548, ``google-api`` added in #666): the vendor's
22#: real API keyed by an env token, no agent CLI installed. Same no-tools contract
23#: as ``ollama:`` — the
24#: orchestrator owns every git/PR step and delegates only code generation. The
25#: vendor names match ai-jury's hosted-adapter vocabulary so the value fits the
26#: existing first-colon ``vendor:model`` split unchanged.
27API_VENDORS = ("anthropic-api", "openai-api", "google-api")
29#: Agent-CLI delegate vendors keel drives as a subprocess. Hardcoded on purpose — not
30#: to be confused with the generic ``cli`` *profile* vendor (issue #659), which is the
31#: operator-configured escape hatch for every CLI that is not one of these three.
32CLI_VENDORS = ("claude", "codex", "agy")
34#: Local-model delegate vendors: no agent CLI, no hosted key, and no tools.
35LOCAL_VENDORS = ("ollama",)
37#: Every delegate name keel understands with no configuration at all. Name resolution
38#: is **fail-closed**: a ``knobs.delegate_profiles`` entry may not shadow one of these,
39#: and the attempt is a config error rather than a silent override (issue #659).
40BUILTIN_DELEGATE_VENDORS = CLI_VENDORS + LOCAL_VENDORS + API_VENDORS
43def split_delegate(value: str) -> tuple[str, str | None]:
44 """Split ``ollama:qwen2.5`` -> ``("ollama", "qwen2.5")``; ``codex`` -> ``("codex", None)``."""
45 vendor, sep, model = value.partition(":")
46 return vendor, (model if (sep and model) else None)
49def is_api_delegate(vendor: str) -> bool:
50 """True when ``vendor`` is a hosted-API delegate (``anthropic-api``/``openai-api``)."""
51 return vendor in API_VENDORS
54def resolve_delegate_profile(config: ProjectConfig, name: str) -> DelegateProfile | None:
55 """The configured delegate profile for ``name``, or ``None``.
57 ``name`` is the bare ``--delegate`` token (``split_delegate``'s vendor part). A
58 built-in vendor **always wins** and never resolves to a profile — config cannot
59 redefine ``codex`` even if a same-named profile somehow reached this point
60 (:func:`keel.config.parse_config` rejects that shadowing up front).
61 """
62 if name in BUILTIN_DELEGATE_VENDORS:
63 return None
64 return config.knobs.delegate_profiles.get(name)
67def is_profile_delegate(config: ProjectConfig, name: str) -> bool:
68 """True when ``--delegate <name>`` dispatches to a generic delegate profile."""
69 return resolve_delegate_profile(config, name) is not None
72def resolve_agent(
73 config: ProjectConfig,
74 *,
75 role: str | None = None,
76 delegate: str | None = None,
77 host_agent: str = HOST_DEFAULT,
78) -> str:
79 """Resolve which agent runs a step.
81 Precedence: explicit ``delegate`` > per-role ``implementer_agents`` mapping >
82 ``host_agent`` default.
83 """
84 if delegate:
85 return delegate
86 if role and role in config.knobs.implementer_agents:
87 return config.knobs.implementer_agents[role]
88 return host_agent
91def model_base(model: str) -> str:
92 """Strip a model id to a coarse, versionless base label (ship #2036 algorithm).
94 Examples: ``qwen2.5:7b`` -> ``qwen``, ``gemma2`` -> ``gemma``,
95 ``llama3.1`` -> ``llama``, ``gpt-5.5`` -> ``gpt-5``, ``gpt-4o`` -> ``gpt-4o``.
96 """
97 m = model.strip().lower()
98 if not m:
99 return ""
100 m = m.split(":", 1)[0] # (1) drop any ollama :tag
101 if "-" in m:
102 # (3) hyphenated family: keep <word>-<major>, drop the .minor
103 head, _, tail = m.partition("-")
104 major = tail.split(".", 1)[0]
105 return f"{head}-{major}"
106 # (2) non-hyphenated family: drop the trailing numeric run (digits + dots)
107 i = len(m)
108 while i > 0 and (m[i - 1].isdigit() or m[i - 1] == "."):
109 i -= 1
110 return m[:i]
113def agent_label(vendor: str) -> str:
114 """The persistent ``agent:<vendor>`` label."""
115 return f"agent:{vendor}"
118def model_label(model: str) -> str | None:
119 """The versionless ``model:<base>`` label, or ``None`` when no base is known."""
120 base = model_base(model)
121 return f"model:{base}" if base else None
124def attribution(vendor: str, model: str | None = None) -> dict[str, str | None]:
125 """Resolve the effective attribution for an implementer/reviewer.
127 Returns ``{"agent_label", "model_label", "system"}`` where ``system`` is the
128 full ``vendor`` or ``vendor:model`` string for the closure comment.
129 """
130 system = f"{vendor}:{model}" if model else vendor
131 return {
132 "agent_label": agent_label(vendor),
133 "model_label": model_label(model) if model else None,
134 "system": system,
135 }
138def profile_attribution(
139 name: str,
140 profile: DelegateProfile,
141 model: str | None = None,
142) -> dict[str, str | None]:
143 """Attribution for a generic delegate profile (issue #659).
145 ``agent:<vendor>`` (``agent:cli``) plus the **effective** model — the same shape as
146 :func:`attribution` — with an extra key naming the entry, so the closure comment can
147 say *which* CLI ran rather than just ``cli``.
149 That key is ``delegate_profile``, **not** ``profile``: the ship run record already
150 uses ``profile`` for the workflow profile (``standard``/``compound``), so merging
151 this dict into the record under the shorter name would silently overwrite it.
153 ``model`` is the per-run override from ``--delegate <profile>:<model>`` and wins
154 over the profile's own ``model``, matching the precedence s4 documents. Without it
155 the helper could only ever report the configured model, which would break keel's
156 rule that attribution records the *effective* implementer whenever an operator
157 picked a model per run.
158 """
159 record = attribution(profile.vendor, model or profile.model)
160 record["delegate_profile"] = name
161 return record
164#: Characters a per-run model token may contain. Deliberately tight: the effective model
165#: can arrive from ``--delegate <profile>:<model>`` or a ``delegate-model:<name>`` issue
166#: label, which is a lower-trust source than the operator-authored ``command``, and it
167#: ends up on a subprocess argv.
168_MODEL_TOKEN_OK = frozenset(
169 "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-"
170)
173def is_safe_model_token(model: str | None) -> bool:
174 """True when ``model`` is safe to pass to a delegate CLI as an argument.
176 A profile's ``command`` is operator-authored config, but the *model* beside it may
177 come from an issue label, so it does not carry the same trust. Anything outside
178 ``[A-Za-z0-9._-]`` — whitespace, quotes, shell metacharacters, a leading dash that
179 would read as another flag — is rejected rather than escaped, because no legitimate
180 model id needs it. Empty/``None`` is False: pass no model instead.
181 """
182 if not model:
183 return False
184 if model.startswith("-"):
185 return False
186 return _MODEL_TOKEN_OK.issuperset(model)