klaude-code 2.1.1__py3-none-any.whl → 2.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- klaude_code/app/__init__.py +1 -2
- klaude_code/app/runtime.py +13 -41
- klaude_code/cli/list_model.py +27 -10
- klaude_code/cli/main.py +42 -159
- klaude_code/config/assets/builtin_config.yaml +36 -14
- klaude_code/config/config.py +144 -7
- klaude_code/config/select_model.py +38 -13
- klaude_code/config/sub_agent_model_helper.py +217 -0
- klaude_code/const.py +2 -2
- klaude_code/core/agent_profile.py +71 -5
- klaude_code/core/executor.py +75 -0
- klaude_code/core/manager/llm_clients_builder.py +18 -12
- klaude_code/core/prompts/prompt-nano-banana.md +1 -0
- klaude_code/core/tool/shell/command_safety.py +4 -189
- klaude_code/core/tool/sub_agent_tool.py +2 -1
- klaude_code/core/turn.py +1 -1
- klaude_code/llm/anthropic/client.py +8 -5
- klaude_code/llm/anthropic/input.py +54 -29
- klaude_code/llm/google/client.py +2 -2
- klaude_code/llm/google/input.py +23 -2
- klaude_code/llm/openai_compatible/input.py +22 -13
- klaude_code/llm/openai_compatible/stream.py +1 -1
- klaude_code/llm/openrouter/input.py +37 -25
- klaude_code/llm/responses/client.py +1 -1
- klaude_code/llm/responses/input.py +96 -57
- klaude_code/protocol/commands.py +1 -2
- klaude_code/protocol/events/system.py +4 -0
- klaude_code/protocol/message.py +2 -2
- klaude_code/protocol/op.py +17 -0
- klaude_code/protocol/op_handler.py +5 -0
- klaude_code/protocol/sub_agent/AGENTS.md +28 -0
- klaude_code/protocol/sub_agent/__init__.py +10 -14
- klaude_code/protocol/sub_agent/image_gen.py +2 -1
- klaude_code/session/codec.py +2 -6
- klaude_code/session/session.py +9 -1
- klaude_code/skill/assets/create-plan/SKILL.md +3 -5
- klaude_code/tui/command/__init__.py +7 -10
- klaude_code/tui/command/clear_cmd.py +1 -1
- klaude_code/tui/command/command_abc.py +1 -2
- klaude_code/tui/command/copy_cmd.py +1 -2
- klaude_code/tui/command/fork_session_cmd.py +4 -4
- klaude_code/tui/command/model_cmd.py +6 -43
- klaude_code/tui/command/model_select.py +75 -15
- klaude_code/tui/command/refresh_cmd.py +1 -2
- klaude_code/tui/command/resume_cmd.py +3 -4
- klaude_code/tui/command/status_cmd.py +1 -1
- klaude_code/tui/command/sub_agent_model_cmd.py +190 -0
- klaude_code/tui/components/bash_syntax.py +1 -1
- klaude_code/tui/components/common.py +1 -1
- klaude_code/tui/components/developer.py +10 -15
- klaude_code/tui/components/metadata.py +2 -64
- klaude_code/tui/components/rich/cjk_wrap.py +3 -2
- klaude_code/tui/components/rich/status.py +49 -3
- klaude_code/tui/components/rich/theme.py +4 -2
- klaude_code/tui/components/sub_agent.py +25 -46
- klaude_code/tui/components/user_input.py +9 -21
- klaude_code/tui/components/welcome.py +99 -0
- klaude_code/tui/input/prompt_toolkit.py +14 -1
- klaude_code/tui/renderer.py +2 -3
- klaude_code/tui/runner.py +2 -2
- klaude_code/tui/terminal/selector.py +8 -18
- klaude_code/ui/__init__.py +0 -24
- klaude_code/ui/common.py +3 -2
- klaude_code/ui/core/display.py +2 -2
- {klaude_code-2.1.1.dist-info → klaude_code-2.3.0.dist-info}/METADATA +16 -81
- {klaude_code-2.1.1.dist-info → klaude_code-2.3.0.dist-info}/RECORD +68 -67
- klaude_code/tui/command/help_cmd.py +0 -51
- klaude_code/tui/command/prompt-commit.md +0 -82
- klaude_code/tui/command/release_notes_cmd.py +0 -85
- klaude_code/ui/exec_mode.py +0 -60
- {klaude_code-2.1.1.dist-info → klaude_code-2.3.0.dist-info}/WHEEL +0 -0
- {klaude_code-2.1.1.dist-info → klaude_code-2.3.0.dist-info}/entry_points.txt +0 -0
klaude_code/config/config.py
CHANGED
|
@@ -140,6 +140,16 @@ class ModelEntry(BaseModel):
|
|
|
140
140
|
provider: str
|
|
141
141
|
model_params: llm_param.LLMConfigModelParameter
|
|
142
142
|
|
|
143
|
+
@property
|
|
144
|
+
def selector(self) -> str:
|
|
145
|
+
"""Return a provider-qualified model selector.
|
|
146
|
+
|
|
147
|
+
This selector can be persisted in user config (e.g. ``sonnet@openrouter``)
|
|
148
|
+
and later resolved via :meth:`Config.get_model_config`.
|
|
149
|
+
"""
|
|
150
|
+
|
|
151
|
+
return f"{self.model_name}@{self.provider}"
|
|
152
|
+
|
|
143
153
|
|
|
144
154
|
class UserConfig(BaseModel):
|
|
145
155
|
"""User configuration (what gets saved to disk)."""
|
|
@@ -191,8 +201,103 @@ class Config(BaseModel):
|
|
|
191
201
|
"""Set the user config reference for saving."""
|
|
192
202
|
object.__setattr__(self, "_user_config", user_config)
|
|
193
203
|
|
|
204
|
+
@classmethod
|
|
205
|
+
def _split_model_selector(cls, model_selector: str) -> tuple[str, str | None]:
|
|
206
|
+
"""Split a model selector into (model_name, provider_name).
|
|
207
|
+
|
|
208
|
+
Supported forms:
|
|
209
|
+
- ``sonnet``: unqualified; caller should pick the first matching provider.
|
|
210
|
+
- ``sonnet@openrouter``: provider-qualified.
|
|
211
|
+
|
|
212
|
+
Note: the provider segment is normalized for backwards compatibility.
|
|
213
|
+
"""
|
|
214
|
+
|
|
215
|
+
trimmed = model_selector.strip()
|
|
216
|
+
if "@" not in trimmed:
|
|
217
|
+
return trimmed, None
|
|
218
|
+
|
|
219
|
+
base, provider = trimmed.rsplit("@", 1)
|
|
220
|
+
base = base.strip()
|
|
221
|
+
provider = provider.strip()
|
|
222
|
+
if not base or not provider:
|
|
223
|
+
raise ValueError(f"Invalid model selector: {model_selector!r}")
|
|
224
|
+
return base, provider
|
|
225
|
+
|
|
226
|
+
def has_model_config_name(self, model_selector: str) -> bool:
|
|
227
|
+
"""Return True if the selector points to a configured model.
|
|
228
|
+
|
|
229
|
+
This check is configuration-only: it does not require a valid API key or
|
|
230
|
+
OAuth login.
|
|
231
|
+
"""
|
|
232
|
+
|
|
233
|
+
model_name, provider_name = self._split_model_selector(model_selector)
|
|
234
|
+
if provider_name is not None:
|
|
235
|
+
for provider in self.provider_list:
|
|
236
|
+
if provider.provider_name.casefold() != provider_name.casefold():
|
|
237
|
+
continue
|
|
238
|
+
return any(m.model_name == model_name for m in provider.model_list)
|
|
239
|
+
return False
|
|
240
|
+
|
|
241
|
+
return any(any(m.model_name == model_name for m in provider.model_list) for provider in self.provider_list)
|
|
242
|
+
|
|
243
|
+
def resolve_model_location(self, model_selector: str) -> tuple[str, str] | None:
|
|
244
|
+
"""Resolve a selector to (model_name, provider_name), without auth checks.
|
|
245
|
+
|
|
246
|
+
- If the selector is provider-qualified, returns that provider.
|
|
247
|
+
- If unqualified, returns the first provider that defines the model.
|
|
248
|
+
"""
|
|
249
|
+
|
|
250
|
+
model_name, provider_name = self._split_model_selector(model_selector)
|
|
251
|
+
if provider_name is not None:
|
|
252
|
+
for provider in self.provider_list:
|
|
253
|
+
if provider.provider_name.casefold() != provider_name.casefold():
|
|
254
|
+
continue
|
|
255
|
+
if any(m.model_name == model_name for m in provider.model_list):
|
|
256
|
+
return model_name, provider.provider_name
|
|
257
|
+
return None
|
|
258
|
+
|
|
259
|
+
for provider in self.provider_list:
|
|
260
|
+
if any(m.model_name == model_name for m in provider.model_list):
|
|
261
|
+
return model_name, provider.provider_name
|
|
262
|
+
return None
|
|
263
|
+
|
|
264
|
+
def resolve_model_location_prefer_available(self, model_selector: str) -> tuple[str, str] | None:
|
|
265
|
+
"""Resolve a selector to (model_name, provider_name), preferring usable providers.
|
|
266
|
+
|
|
267
|
+
This uses the same availability logic as :meth:`get_model_config` (API-key
|
|
268
|
+
presence for non-OAuth protocols).
|
|
269
|
+
"""
|
|
270
|
+
|
|
271
|
+
requested_model, requested_provider = self._split_model_selector(model_selector)
|
|
272
|
+
|
|
273
|
+
for provider in self.provider_list:
|
|
274
|
+
if requested_provider is not None and provider.provider_name.casefold() != requested_provider.casefold():
|
|
275
|
+
continue
|
|
276
|
+
|
|
277
|
+
api_key = provider.get_resolved_api_key()
|
|
278
|
+
if (
|
|
279
|
+
provider.protocol
|
|
280
|
+
not in {
|
|
281
|
+
llm_param.LLMClientProtocol.CODEX_OAUTH,
|
|
282
|
+
llm_param.LLMClientProtocol.CLAUDE_OAUTH,
|
|
283
|
+
llm_param.LLMClientProtocol.BEDROCK,
|
|
284
|
+
}
|
|
285
|
+
and not api_key
|
|
286
|
+
):
|
|
287
|
+
continue
|
|
288
|
+
|
|
289
|
+
if any(m.model_name == requested_model for m in provider.model_list):
|
|
290
|
+
return requested_model, provider.provider_name
|
|
291
|
+
|
|
292
|
+
return None
|
|
293
|
+
|
|
194
294
|
def get_model_config(self, model_name: str) -> llm_param.LLMConfigParameter:
|
|
295
|
+
requested_model, requested_provider = self._split_model_selector(model_name)
|
|
296
|
+
|
|
195
297
|
for provider in self.provider_list:
|
|
298
|
+
if requested_provider is not None and provider.provider_name.casefold() != requested_provider.casefold():
|
|
299
|
+
continue
|
|
300
|
+
|
|
196
301
|
# Resolve ${ENV_VAR} syntax for api_key
|
|
197
302
|
api_key = provider.get_resolved_api_key()
|
|
198
303
|
|
|
@@ -206,15 +311,22 @@ class Config(BaseModel):
|
|
|
206
311
|
}
|
|
207
312
|
and not api_key
|
|
208
313
|
):
|
|
314
|
+
# When provider is explicitly requested, fail fast with a clearer error.
|
|
315
|
+
if requested_provider is not None:
|
|
316
|
+
raise ValueError(
|
|
317
|
+
f"Provider '{provider.provider_name}' is not available (missing API key) for: {model_name}"
|
|
318
|
+
)
|
|
209
319
|
continue
|
|
320
|
+
|
|
210
321
|
for model in provider.model_list:
|
|
211
|
-
if model.model_name
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
)
|
|
322
|
+
if model.model_name != requested_model:
|
|
323
|
+
continue
|
|
324
|
+
provider_dump = provider.model_dump(exclude={"model_list"})
|
|
325
|
+
provider_dump["api_key"] = api_key
|
|
326
|
+
return llm_param.LLMConfigParameter(
|
|
327
|
+
**provider_dump,
|
|
328
|
+
**model.model_params.model_dump(),
|
|
329
|
+
)
|
|
218
330
|
|
|
219
331
|
raise ValueError(f"Unknown model: {model_name}")
|
|
220
332
|
|
|
@@ -235,6 +347,27 @@ class Config(BaseModel):
|
|
|
235
347
|
for model in provider.model_list
|
|
236
348
|
]
|
|
237
349
|
|
|
350
|
+
def has_available_image_model(self) -> bool:
|
|
351
|
+
"""Check if any image generation model is available."""
|
|
352
|
+
for entry in self.iter_model_entries(only_available=True):
|
|
353
|
+
if entry.model_params.modalities and "image" in entry.model_params.modalities:
|
|
354
|
+
return True
|
|
355
|
+
return False
|
|
356
|
+
|
|
357
|
+
def get_first_available_nano_banana_model(self) -> str | None:
|
|
358
|
+
"""Get the first available nano-banana model, or None."""
|
|
359
|
+
for entry in self.iter_model_entries(only_available=True):
|
|
360
|
+
if "nano-banana" in entry.model_name:
|
|
361
|
+
return entry.model_name
|
|
362
|
+
return None
|
|
363
|
+
|
|
364
|
+
def get_first_available_image_model(self) -> str | None:
|
|
365
|
+
"""Get the first available image generation model, or None."""
|
|
366
|
+
for entry in self.iter_model_entries(only_available=True):
|
|
367
|
+
if entry.model_params.modalities and "image" in entry.model_params.modalities:
|
|
368
|
+
return entry.model_name
|
|
369
|
+
return None
|
|
370
|
+
|
|
238
371
|
async def save(self) -> None:
|
|
239
372
|
"""Save user config to file (excludes builtin providers).
|
|
240
373
|
|
|
@@ -418,6 +551,10 @@ def create_example_config() -> bool:
|
|
|
418
551
|
header = "# Example configuration for klaude-code\n"
|
|
419
552
|
header += "# Copy this file to klaude-config.yaml and modify as needed.\n"
|
|
420
553
|
header += "# Run `klaude list` to see available models.\n"
|
|
554
|
+
header += "# Tip: you can pick a provider explicitly with `model@provider` (e.g. `sonnet@openrouter`).\n"
|
|
555
|
+
header += (
|
|
556
|
+
"# If you omit `@provider` (e.g. `sonnet`), klaude picks the first configured provider with credentials.\n"
|
|
557
|
+
)
|
|
421
558
|
header += "#\n"
|
|
422
559
|
header += "# Built-in providers (anthropic, openai, openrouter, deepseek) are available automatically.\n"
|
|
423
560
|
header += "# Just set the corresponding API key environment variable to use them.\n\n"
|
|
@@ -50,7 +50,8 @@ def match_model_from_config(preferred: str | None = None) -> ModelMatchResult:
|
|
|
50
50
|
|
|
51
51
|
# Only show models from providers with valid API keys
|
|
52
52
|
models: list[ModelEntry] = sorted(
|
|
53
|
-
config.iter_model_entries(only_available=True),
|
|
53
|
+
config.iter_model_entries(only_available=True),
|
|
54
|
+
key=lambda m: (m.model_name.lower(), m.provider.lower()),
|
|
54
55
|
)
|
|
55
56
|
|
|
56
57
|
if not models:
|
|
@@ -62,26 +63,42 @@ def match_model_from_config(preferred: str | None = None) -> ModelMatchResult:
|
|
|
62
63
|
error_message="No models available",
|
|
63
64
|
)
|
|
64
65
|
|
|
65
|
-
|
|
66
|
+
selectors: list[str] = [m.selector for m in models]
|
|
66
67
|
|
|
67
68
|
# Try to match preferred model name
|
|
68
69
|
filter_hint = preferred
|
|
69
70
|
if preferred and preferred.strip():
|
|
70
71
|
preferred = preferred.strip()
|
|
71
|
-
|
|
72
|
-
|
|
72
|
+
|
|
73
|
+
# Exact match on selector (e.g. sonnet@openrouter)
|
|
74
|
+
if preferred in selectors:
|
|
73
75
|
return ModelMatchResult(matched_model=preferred, filtered_models=models, filter_hint=None)
|
|
74
76
|
|
|
77
|
+
# Exact match on base model name (e.g. sonnet)
|
|
78
|
+
exact_base_matches = [m for m in models if m.model_name == preferred]
|
|
79
|
+
if len(exact_base_matches) == 1:
|
|
80
|
+
return ModelMatchResult(
|
|
81
|
+
matched_model=exact_base_matches[0].selector,
|
|
82
|
+
filtered_models=models,
|
|
83
|
+
filter_hint=None,
|
|
84
|
+
)
|
|
85
|
+
if len(exact_base_matches) > 1:
|
|
86
|
+
return ModelMatchResult(matched_model=None, filtered_models=exact_base_matches, filter_hint=filter_hint)
|
|
87
|
+
|
|
75
88
|
preferred_lower = preferred.lower()
|
|
76
|
-
# Case-insensitive exact match (model_name
|
|
89
|
+
# Case-insensitive exact match (selector/model_name/model_params.model)
|
|
77
90
|
exact_ci_matches = [
|
|
78
91
|
m
|
|
79
92
|
for m in models
|
|
80
|
-
if preferred_lower == m.
|
|
93
|
+
if preferred_lower == m.selector.lower()
|
|
94
|
+
or preferred_lower == m.model_name.lower()
|
|
95
|
+
or preferred_lower == (m.model_params.model or "").lower()
|
|
81
96
|
]
|
|
82
97
|
if len(exact_ci_matches) == 1:
|
|
83
98
|
return ModelMatchResult(
|
|
84
|
-
matched_model=exact_ci_matches[0].
|
|
99
|
+
matched_model=exact_ci_matches[0].selector,
|
|
100
|
+
filtered_models=models,
|
|
101
|
+
filter_hint=None,
|
|
85
102
|
)
|
|
86
103
|
|
|
87
104
|
# Normalized matching (e.g. gpt52 == gpt-5.2, gpt52 in gpt-5.2-2025-...)
|
|
@@ -91,24 +108,30 @@ def match_model_from_config(preferred: str | None = None) -> ModelMatchResult:
|
|
|
91
108
|
normalized_matches = [
|
|
92
109
|
m
|
|
93
110
|
for m in models
|
|
94
|
-
if preferred_norm == _normalize_model_key(m.
|
|
111
|
+
if preferred_norm == _normalize_model_key(m.selector)
|
|
112
|
+
or preferred_norm == _normalize_model_key(m.model_name)
|
|
95
113
|
or preferred_norm == _normalize_model_key(m.model_params.model or "")
|
|
96
114
|
]
|
|
97
115
|
if len(normalized_matches) == 1:
|
|
98
116
|
return ModelMatchResult(
|
|
99
|
-
matched_model=normalized_matches[0].
|
|
117
|
+
matched_model=normalized_matches[0].selector,
|
|
118
|
+
filtered_models=models,
|
|
119
|
+
filter_hint=None,
|
|
100
120
|
)
|
|
101
121
|
|
|
102
122
|
if not normalized_matches and len(preferred_norm) >= 4:
|
|
103
123
|
normalized_matches = [
|
|
104
124
|
m
|
|
105
125
|
for m in models
|
|
106
|
-
if preferred_norm in _normalize_model_key(m.
|
|
126
|
+
if preferred_norm in _normalize_model_key(m.selector)
|
|
127
|
+
or preferred_norm in _normalize_model_key(m.model_name)
|
|
107
128
|
or preferred_norm in _normalize_model_key(m.model_params.model or "")
|
|
108
129
|
]
|
|
109
130
|
if len(normalized_matches) == 1:
|
|
110
131
|
return ModelMatchResult(
|
|
111
|
-
matched_model=normalized_matches[0].
|
|
132
|
+
matched_model=normalized_matches[0].selector,
|
|
133
|
+
filtered_models=models,
|
|
134
|
+
filter_hint=None,
|
|
112
135
|
)
|
|
113
136
|
|
|
114
137
|
# Partial match (case-insensitive) on model_name or model_params.model.
|
|
@@ -116,10 +139,12 @@ def match_model_from_config(preferred: str | None = None) -> ModelMatchResult:
|
|
|
116
139
|
matches = normalized_matches or [
|
|
117
140
|
m
|
|
118
141
|
for m in models
|
|
119
|
-
if preferred_lower in m.
|
|
142
|
+
if preferred_lower in m.selector.lower()
|
|
143
|
+
or preferred_lower in m.model_name.lower()
|
|
144
|
+
or preferred_lower in (m.model_params.model or "").lower()
|
|
120
145
|
]
|
|
121
146
|
if len(matches) == 1:
|
|
122
|
-
return ModelMatchResult(matched_model=matches[0].
|
|
147
|
+
return ModelMatchResult(matched_model=matches[0].selector, filtered_models=models, filter_hint=None)
|
|
123
148
|
if matches:
|
|
124
149
|
# Multiple matches: filter the list for interactive selection
|
|
125
150
|
return ModelMatchResult(matched_model=None, filtered_models=matches, filter_hint=filter_hint)
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Helper for sub-agent model availability and selection logic."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from klaude_code.protocol.sub_agent import (
|
|
9
|
+
AVAILABILITY_IMAGE_MODEL,
|
|
10
|
+
SubAgentProfile,
|
|
11
|
+
get_sub_agent_profile,
|
|
12
|
+
get_sub_agent_profile_by_tool,
|
|
13
|
+
iter_sub_agent_profiles,
|
|
14
|
+
sub_agent_tool_names,
|
|
15
|
+
)
|
|
16
|
+
from klaude_code.protocol.tools import SubAgentType
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from klaude_code.config.config import Config, ModelEntry
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class SubAgentModelInfo:
|
|
24
|
+
"""Sub-agent and its current model configuration."""
|
|
25
|
+
|
|
26
|
+
profile: SubAgentProfile
|
|
27
|
+
# Explicitly configured model selector (from config), if any.
|
|
28
|
+
configured_model: str | None
|
|
29
|
+
|
|
30
|
+
# Effective model name used by this sub-agent.
|
|
31
|
+
# - When configured_model is set: equals configured_model.
|
|
32
|
+
# - When requirement-based default applies (e.g. ImageGen): resolved model.
|
|
33
|
+
# - When inheriting from main agent: None.
|
|
34
|
+
effective_model: str | None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True, slots=True)
|
|
38
|
+
class EmptySubAgentModelBehavior:
|
|
39
|
+
"""Human-facing description for an unset (empty) sub-agent model config."""
|
|
40
|
+
|
|
41
|
+
# Summary text for UI (kept UI-framework agnostic).
|
|
42
|
+
description: str
|
|
43
|
+
|
|
44
|
+
# Best-effort resolved model name (if any). For ImageGen this is usually the
|
|
45
|
+
# first available image model; for other sub-agents it's the main model.
|
|
46
|
+
resolved_model_name: str | None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class SubAgentModelHelper:
|
|
50
|
+
"""Centralized logic for sub-agent availability and model selection."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, config: Config) -> None:
|
|
53
|
+
self._config = config
|
|
54
|
+
|
|
55
|
+
def check_availability_requirement(self, requirement: str | None) -> bool:
|
|
56
|
+
"""Check if a sub-agent's availability requirement is met.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
requirement: The availability requirement constant (e.g., AVAILABILITY_IMAGE_MODEL).
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
True if the requirement is met or if there's no requirement.
|
|
63
|
+
"""
|
|
64
|
+
if requirement is None:
|
|
65
|
+
return True
|
|
66
|
+
|
|
67
|
+
if requirement == AVAILABILITY_IMAGE_MODEL:
|
|
68
|
+
return self._config.has_available_image_model()
|
|
69
|
+
|
|
70
|
+
return True
|
|
71
|
+
|
|
72
|
+
def resolve_model_for_requirement(self, requirement: str | None) -> str | None:
|
|
73
|
+
"""Resolve the model name for a given availability requirement.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
requirement: The availability requirement constant.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
The model name if found, None otherwise.
|
|
80
|
+
"""
|
|
81
|
+
if requirement == AVAILABILITY_IMAGE_MODEL:
|
|
82
|
+
return self._config.get_first_available_image_model()
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
def resolve_default_model_override(self, sub_agent_type: str) -> str | None:
|
|
86
|
+
"""Resolve the default model override for a sub-agent when unset.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
- None for sub-agents that default to inheriting the main agent.
|
|
90
|
+
- A model name for sub-agents that require a dedicated model (e.g. ImageGen).
|
|
91
|
+
|
|
92
|
+
Note: This intentionally ignores any explicit user config; callers use this
|
|
93
|
+
when they want the *unset* behavior.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
profile = get_sub_agent_profile(sub_agent_type)
|
|
97
|
+
if profile.availability_requirement is None:
|
|
98
|
+
return None
|
|
99
|
+
return self.resolve_model_for_requirement(profile.availability_requirement)
|
|
100
|
+
|
|
101
|
+
def describe_empty_model_config_behavior(
|
|
102
|
+
self,
|
|
103
|
+
sub_agent_type: str,
|
|
104
|
+
*,
|
|
105
|
+
main_model_name: str,
|
|
106
|
+
) -> EmptySubAgentModelBehavior:
|
|
107
|
+
"""Describe what happens when a sub-agent model is not configured.
|
|
108
|
+
|
|
109
|
+
Most sub-agents default to inheriting the main model.
|
|
110
|
+
|
|
111
|
+
Sub-agents with an availability requirement (e.g. ImageGen) do NOT
|
|
112
|
+
inherit from the main model; instead they auto-resolve a suitable model
|
|
113
|
+
(currently: the first available image model).
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
profile = get_sub_agent_profile(sub_agent_type)
|
|
117
|
+
|
|
118
|
+
requirement = profile.availability_requirement
|
|
119
|
+
if requirement is None:
|
|
120
|
+
return EmptySubAgentModelBehavior(
|
|
121
|
+
description=f"inherit from main agent: {main_model_name}",
|
|
122
|
+
resolved_model_name=main_model_name,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
resolved = self.resolve_model_for_requirement(requirement)
|
|
126
|
+
if requirement == AVAILABILITY_IMAGE_MODEL:
|
|
127
|
+
if resolved:
|
|
128
|
+
return EmptySubAgentModelBehavior(
|
|
129
|
+
description=f"auto-select first available image model: {resolved}",
|
|
130
|
+
resolved_model_name=resolved,
|
|
131
|
+
)
|
|
132
|
+
return EmptySubAgentModelBehavior(
|
|
133
|
+
description="auto-select first available image model",
|
|
134
|
+
resolved_model_name=None,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
if resolved:
|
|
138
|
+
return EmptySubAgentModelBehavior(
|
|
139
|
+
description=f"auto-select model for requirement '{requirement}': {resolved}",
|
|
140
|
+
resolved_model_name=resolved,
|
|
141
|
+
)
|
|
142
|
+
return EmptySubAgentModelBehavior(
|
|
143
|
+
description=f"auto-select model for requirement '{requirement}'",
|
|
144
|
+
resolved_model_name=None,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
def get_available_sub_agents(self) -> list[SubAgentModelInfo]:
|
|
148
|
+
"""Return all available sub-agents with their current model config.
|
|
149
|
+
|
|
150
|
+
Only returns sub-agents that:
|
|
151
|
+
1. Are enabled by default
|
|
152
|
+
2. Have their availability requirements met
|
|
153
|
+
|
|
154
|
+
For sub-agents without explicit config, resolves model based on availability_requirement.
|
|
155
|
+
"""
|
|
156
|
+
result: list[SubAgentModelInfo] = []
|
|
157
|
+
for profile in iter_sub_agent_profiles(enabled_only=True):
|
|
158
|
+
if not self.check_availability_requirement(profile.availability_requirement):
|
|
159
|
+
continue
|
|
160
|
+
configured_model = self._config.sub_agent_models.get(profile.name)
|
|
161
|
+
effective_model = configured_model
|
|
162
|
+
if not effective_model and profile.availability_requirement:
|
|
163
|
+
effective_model = self.resolve_model_for_requirement(profile.availability_requirement)
|
|
164
|
+
result.append(
|
|
165
|
+
SubAgentModelInfo(
|
|
166
|
+
profile=profile,
|
|
167
|
+
configured_model=configured_model,
|
|
168
|
+
effective_model=effective_model,
|
|
169
|
+
)
|
|
170
|
+
)
|
|
171
|
+
return result
|
|
172
|
+
|
|
173
|
+
def get_selectable_models(self, sub_agent_type: str) -> list[ModelEntry]:
|
|
174
|
+
"""Return selectable models for a specific sub-agent type.
|
|
175
|
+
|
|
176
|
+
For sub-agents with availability_requirement (e.g., ImageGen):
|
|
177
|
+
- Only returns models matching the requirement (e.g., image models)
|
|
178
|
+
|
|
179
|
+
For other sub-agents:
|
|
180
|
+
- Returns all available models
|
|
181
|
+
"""
|
|
182
|
+
profile = get_sub_agent_profile(sub_agent_type)
|
|
183
|
+
all_models = self._config.iter_model_entries(only_available=True)
|
|
184
|
+
|
|
185
|
+
if profile.availability_requirement == AVAILABILITY_IMAGE_MODEL:
|
|
186
|
+
return [m for m in all_models if m.model_params.modalities and "image" in m.model_params.modalities]
|
|
187
|
+
|
|
188
|
+
return all_models
|
|
189
|
+
|
|
190
|
+
def get_enabled_sub_agent_tool_names(self) -> list[str]:
|
|
191
|
+
"""Return sub-agent tool names that should be added to main agent's tool list."""
|
|
192
|
+
result: list[str] = []
|
|
193
|
+
for name in sub_agent_tool_names(enabled_only=True):
|
|
194
|
+
profile = get_sub_agent_profile_by_tool(name)
|
|
195
|
+
if profile is not None and self.check_availability_requirement(profile.availability_requirement):
|
|
196
|
+
result.append(name)
|
|
197
|
+
return result
|
|
198
|
+
|
|
199
|
+
def get_enabled_sub_agent_types(self) -> set[SubAgentType]:
|
|
200
|
+
"""Return set of sub-agent types that are enabled and available."""
|
|
201
|
+
enabled: set[SubAgentType] = set()
|
|
202
|
+
for name in sub_agent_tool_names(enabled_only=True):
|
|
203
|
+
profile = get_sub_agent_profile_by_tool(name)
|
|
204
|
+
if profile is not None and self.check_availability_requirement(profile.availability_requirement):
|
|
205
|
+
enabled.add(profile.name)
|
|
206
|
+
return enabled
|
|
207
|
+
|
|
208
|
+
def build_sub_agent_client_configs(self) -> dict[SubAgentType, str]:
|
|
209
|
+
"""Return model names for each sub-agent that needs a dedicated client."""
|
|
210
|
+
result: dict[SubAgentType, str] = {}
|
|
211
|
+
for profile in iter_sub_agent_profiles():
|
|
212
|
+
model_name = self._config.sub_agent_models.get(profile.name)
|
|
213
|
+
if not model_name and profile.availability_requirement:
|
|
214
|
+
model_name = self.resolve_model_for_requirement(profile.availability_requirement)
|
|
215
|
+
if model_name:
|
|
216
|
+
result[profile.name] = model_name
|
|
217
|
+
return result
|
klaude_code/const.py
CHANGED
|
@@ -123,7 +123,7 @@ TAB_EXPAND_WIDTH = 8 # Tab expansion width for text rendering
|
|
|
123
123
|
DIFF_PREFIX_WIDTH = 4 # Width of line number prefix in diff display
|
|
124
124
|
MAX_DIFF_LINES = 500 # Maximum lines to show in diff output
|
|
125
125
|
INVALID_TOOL_CALL_MAX_LENGTH = 200 # Maximum length for invalid tool call display
|
|
126
|
-
TRUNCATE_DISPLAY_MAX_LINE_LENGTH =
|
|
126
|
+
TRUNCATE_DISPLAY_MAX_LINE_LENGTH = 500 # Maximum line length for truncated display
|
|
127
127
|
TRUNCATE_DISPLAY_MAX_LINES = 4 # Maximum lines for truncated display
|
|
128
128
|
MIN_HIDDEN_LINES_FOR_INDICATOR = 5 # Minimum hidden lines before showing truncation indicator
|
|
129
129
|
SUB_AGENT_RESULT_MAX_LINES = 10 # Maximum lines for sub-agent result display
|
|
@@ -155,7 +155,7 @@ MARKDOWN_RIGHT_MARGIN = 2 # Right margin (columns) for markdown rendering
|
|
|
155
155
|
STATUS_HINT_TEXT = " (esc to interrupt)" # Status hint text shown after spinner
|
|
156
156
|
|
|
157
157
|
# Spinner status texts
|
|
158
|
-
STATUS_WAITING_TEXT = "
|
|
158
|
+
STATUS_WAITING_TEXT = "Connecting …"
|
|
159
159
|
STATUS_THINKING_TEXT = "Reasoning …"
|
|
160
160
|
STATUS_COMPOSING_TEXT = "Generating"
|
|
161
161
|
|
|
@@ -7,8 +7,12 @@ from dataclasses import dataclass
|
|
|
7
7
|
from functools import cache
|
|
8
8
|
from importlib.resources import files
|
|
9
9
|
from pathlib import Path
|
|
10
|
-
from typing import Any, Protocol
|
|
10
|
+
from typing import TYPE_CHECKING, Any, Protocol
|
|
11
11
|
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from klaude_code.config.config import Config
|
|
14
|
+
|
|
15
|
+
from klaude_code.config.sub_agent_model_helper import SubAgentModelHelper
|
|
12
16
|
from klaude_code.core.reminders import (
|
|
13
17
|
at_file_reader_reminder,
|
|
14
18
|
empty_todo_reminder,
|
|
@@ -23,7 +27,7 @@ from klaude_code.core.tool.report_back_tool import ReportBackTool
|
|
|
23
27
|
from klaude_code.core.tool.tool_registry import get_tool_schemas
|
|
24
28
|
from klaude_code.llm import LLMClientABC
|
|
25
29
|
from klaude_code.protocol import llm_param, message, tools
|
|
26
|
-
from klaude_code.protocol.sub_agent import get_sub_agent_profile
|
|
30
|
+
from klaude_code.protocol.sub_agent import get_sub_agent_profile
|
|
27
31
|
from klaude_code.session import Session
|
|
28
32
|
|
|
29
33
|
type Reminder = Callable[[Session], Awaitable[message.DeveloperMessage | None]]
|
|
@@ -58,6 +62,9 @@ PROMPT_FILES: dict[str, str] = {
|
|
|
58
62
|
}
|
|
59
63
|
|
|
60
64
|
|
|
65
|
+
NANO_BANANA_SYSTEM_PROMPT_PATH = "prompts/prompt-nano-banana.md"
|
|
66
|
+
|
|
67
|
+
|
|
61
68
|
STRUCTURED_OUTPUT_PROMPT = """\
|
|
62
69
|
|
|
63
70
|
# Structured Output
|
|
@@ -166,12 +173,14 @@ def load_system_prompt(
|
|
|
166
173
|
def load_agent_tools(
|
|
167
174
|
model_name: str,
|
|
168
175
|
sub_agent_type: tools.SubAgentType | None = None,
|
|
176
|
+
config: Config | None = None,
|
|
169
177
|
) -> list[llm_param.ToolSchema]:
|
|
170
178
|
"""Get tools for an agent based on model and agent type.
|
|
171
179
|
|
|
172
180
|
Args:
|
|
173
181
|
model_name: The model name.
|
|
174
182
|
sub_agent_type: If None, returns main agent tools. Otherwise returns sub-agent tools.
|
|
183
|
+
config: Config for checking sub-agent availability (e.g., image model availability).
|
|
175
184
|
"""
|
|
176
185
|
|
|
177
186
|
if sub_agent_type is not None:
|
|
@@ -180,13 +189,20 @@ def load_agent_tools(
|
|
|
180
189
|
|
|
181
190
|
# Main agent tools
|
|
182
191
|
if "gpt-5" in model_name:
|
|
183
|
-
tool_names = [tools.BASH, tools.READ, tools.APPLY_PATCH, tools.UPDATE_PLAN]
|
|
192
|
+
tool_names: list[str] = [tools.BASH, tools.READ, tools.APPLY_PATCH, tools.UPDATE_PLAN]
|
|
184
193
|
elif "gemini-3" in model_name:
|
|
185
194
|
tool_names = [tools.BASH, tools.READ, tools.EDIT, tools.WRITE]
|
|
186
195
|
else:
|
|
187
196
|
tool_names = [tools.BASH, tools.READ, tools.EDIT, tools.WRITE, tools.TODO_WRITE]
|
|
188
197
|
|
|
189
|
-
|
|
198
|
+
if config is not None:
|
|
199
|
+
helper = SubAgentModelHelper(config)
|
|
200
|
+
tool_names.extend(helper.get_enabled_sub_agent_tool_names())
|
|
201
|
+
else:
|
|
202
|
+
from klaude_code.protocol.sub_agent import sub_agent_tool_names
|
|
203
|
+
|
|
204
|
+
tool_names.extend(sub_agent_tool_names(enabled_only=True))
|
|
205
|
+
|
|
190
206
|
tool_names.extend([tools.MERMAID])
|
|
191
207
|
# tool_names.extend([tools.MEMORY])
|
|
192
208
|
return get_tool_schemas(tool_names)
|
|
@@ -246,10 +262,17 @@ class ModelProfileProvider(Protocol):
|
|
|
246
262
|
output_schema: dict[str, Any] | None = None,
|
|
247
263
|
) -> AgentProfile: ...
|
|
248
264
|
|
|
265
|
+
def enabled_sub_agent_types(self) -> set[tools.SubAgentType]:
|
|
266
|
+
"""Return set of sub-agent types enabled for this provider."""
|
|
267
|
+
...
|
|
268
|
+
|
|
249
269
|
|
|
250
270
|
class DefaultModelProfileProvider(ModelProfileProvider):
|
|
251
271
|
"""Default provider backed by global prompts/tool/reminder registries."""
|
|
252
272
|
|
|
273
|
+
def __init__(self, config: Config | None = None) -> None:
|
|
274
|
+
self._config = config
|
|
275
|
+
|
|
253
276
|
def build_profile(
|
|
254
277
|
self,
|
|
255
278
|
llm_client: LLMClientABC,
|
|
@@ -261,13 +284,25 @@ class DefaultModelProfileProvider(ModelProfileProvider):
|
|
|
261
284
|
profile = AgentProfile(
|
|
262
285
|
llm_client=llm_client,
|
|
263
286
|
system_prompt=load_system_prompt(model_name, llm_client.protocol, sub_agent_type),
|
|
264
|
-
tools=load_agent_tools(model_name, sub_agent_type),
|
|
287
|
+
tools=load_agent_tools(model_name, sub_agent_type, config=self._config),
|
|
265
288
|
reminders=load_agent_reminders(model_name, sub_agent_type),
|
|
266
289
|
)
|
|
267
290
|
if output_schema:
|
|
268
291
|
return with_structured_output(profile, output_schema)
|
|
269
292
|
return profile
|
|
270
293
|
|
|
294
|
+
def enabled_sub_agent_types(self) -> set[tools.SubAgentType]:
|
|
295
|
+
if self._config is None:
|
|
296
|
+
from klaude_code.protocol.sub_agent import get_sub_agent_profile_by_tool, sub_agent_tool_names
|
|
297
|
+
|
|
298
|
+
return {
|
|
299
|
+
profile.name
|
|
300
|
+
for name in sub_agent_tool_names(enabled_only=True)
|
|
301
|
+
if (profile := get_sub_agent_profile_by_tool(name)) is not None
|
|
302
|
+
}
|
|
303
|
+
helper = SubAgentModelHelper(self._config)
|
|
304
|
+
return helper.get_enabled_sub_agent_types()
|
|
305
|
+
|
|
271
306
|
|
|
272
307
|
class VanillaModelProfileProvider(ModelProfileProvider):
|
|
273
308
|
"""Provider that strips prompts, reminders, and tools for vanilla mode."""
|
|
@@ -289,3 +324,34 @@ class VanillaModelProfileProvider(ModelProfileProvider):
|
|
|
289
324
|
if output_schema:
|
|
290
325
|
return with_structured_output(profile, output_schema)
|
|
291
326
|
return profile
|
|
327
|
+
|
|
328
|
+
def enabled_sub_agent_types(self) -> set[tools.SubAgentType]:
|
|
329
|
+
return set()
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
class NanoBananaModelProfileProvider(ModelProfileProvider):
|
|
333
|
+
"""Provider for the Nano Banana image generation model.
|
|
334
|
+
|
|
335
|
+
This mode uses a dedicated system prompt and strips all tools/reminders.
|
|
336
|
+
"""
|
|
337
|
+
|
|
338
|
+
def build_profile(
|
|
339
|
+
self,
|
|
340
|
+
llm_client: LLMClientABC,
|
|
341
|
+
sub_agent_type: tools.SubAgentType | None = None,
|
|
342
|
+
*,
|
|
343
|
+
output_schema: dict[str, Any] | None = None,
|
|
344
|
+
) -> AgentProfile:
|
|
345
|
+
del sub_agent_type
|
|
346
|
+
profile = AgentProfile(
|
|
347
|
+
llm_client=llm_client,
|
|
348
|
+
system_prompt=_load_prompt_by_path(NANO_BANANA_SYSTEM_PROMPT_PATH),
|
|
349
|
+
tools=[],
|
|
350
|
+
reminders=[],
|
|
351
|
+
)
|
|
352
|
+
if output_schema:
|
|
353
|
+
return with_structured_output(profile, output_schema)
|
|
354
|
+
return profile
|
|
355
|
+
|
|
356
|
+
def enabled_sub_agent_types(self) -> set[tools.SubAgentType]:
|
|
357
|
+
return set()
|