Coverage for src/keel/agents.py: 100%
33 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-16 18:07 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-16 18:07 +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``. Attribution records the
6*effective* implementer as labels (``agent:<vendor>`` + a versionless
7``model:<base>``), reusing the ship #2036 stripping algorithm.
9All functions here are pure and deterministic — no subprocess, no network.
10"""
12from __future__ import annotations
14from .config import ProjectConfig
16#: Default host agent when nothing else is resolved.
17HOST_DEFAULT = "claude"
20def split_delegate(value: str) -> tuple[str, str | None]:
21 """Split ``ollama:qwen2.5`` -> ``("ollama", "qwen2.5")``; ``codex`` -> ``("codex", None)``."""
22 vendor, sep, model = value.partition(":")
23 return vendor, (model if (sep and model) else None)
26def resolve_agent(
27 config: ProjectConfig,
28 *,
29 role: str | None = None,
30 delegate: str | None = None,
31 host_agent: str = HOST_DEFAULT,
32) -> str:
33 """Resolve which agent runs a step.
35 Precedence: explicit ``delegate`` > per-role ``implementer_agents`` mapping >
36 ``host_agent`` default.
37 """
38 if delegate:
39 return delegate
40 if role and role in config.knobs.implementer_agents:
41 return config.knobs.implementer_agents[role]
42 return host_agent
45def model_base(model: str) -> str:
46 """Strip a model id to a coarse, versionless base label (ship #2036 algorithm).
48 Examples: ``qwen2.5:7b`` -> ``qwen``, ``gemma2`` -> ``gemma``,
49 ``llama3.1`` -> ``llama``, ``gpt-5.5`` -> ``gpt-5``, ``gpt-4o`` -> ``gpt-4o``.
50 """
51 m = model.strip().lower()
52 if not m:
53 return ""
54 m = m.split(":", 1)[0] # (1) drop any ollama :tag
55 if "-" in m:
56 # (3) hyphenated family: keep <word>-<major>, drop the .minor
57 head, _, tail = m.partition("-")
58 major = tail.split(".", 1)[0]
59 return f"{head}-{major}"
60 # (2) non-hyphenated family: drop the trailing numeric run (digits + dots)
61 i = len(m)
62 while i > 0 and (m[i - 1].isdigit() or m[i - 1] == "."):
63 i -= 1
64 return m[:i]
67def agent_label(vendor: str) -> str:
68 """The persistent ``agent:<vendor>`` label."""
69 return f"agent:{vendor}"
72def model_label(model: str) -> str | None:
73 """The versionless ``model:<base>`` label, or ``None`` when no base is known."""
74 base = model_base(model)
75 return f"model:{base}" if base else None
78def attribution(vendor: str, model: str | None = None) -> dict[str, str | None]:
79 """Resolve the effective attribution for an implementer/reviewer.
81 Returns ``{"agent_label", "model_label", "system"}`` where ``system`` is the
82 full ``vendor`` or ``vendor:model`` string for the closure comment.
83 """
84 system = f"{vendor}:{model}" if model else vendor
85 return {
86 "agent_label": agent_label(vendor),
87 "model_label": model_label(model) if model else None,
88 "system": system,
89 }