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

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

10 

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

12""" 

13 

14from __future__ import annotations 

15 

16from .config import DelegateProfile, ProjectConfig 

17 

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

19HOST_DEFAULT = "claude" 

20 

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

28 

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

33 

34#: Local-model delegate vendors: no agent CLI, no hosted key, and no tools. 

35LOCAL_VENDORS = ("ollama",) 

36 

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 

41 

42 

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) 

47 

48 

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 

52 

53 

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

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

56 

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) 

65 

66 

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 

70 

71 

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. 

80 

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 

89 

90 

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

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

93 

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] 

111 

112 

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

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

115 return f"agent:{vendor}" 

116 

117 

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 

122 

123 

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

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

126 

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 } 

136 

137 

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

144 

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

148 

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. 

152 

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 

162 

163 

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) 

171 

172 

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

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

175 

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)