klaude-code 2.8.0__py3-none-any.whl → 2.9.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/runtime.py +2 -1
- klaude_code/auth/antigravity/oauth.py +0 -9
- klaude_code/auth/antigravity/token_manager.py +0 -18
- klaude_code/auth/base.py +53 -0
- klaude_code/auth/codex/exceptions.py +0 -4
- klaude_code/auth/codex/oauth.py +32 -28
- klaude_code/auth/codex/token_manager.py +0 -18
- klaude_code/cli/cost_cmd.py +128 -39
- klaude_code/cli/list_model.py +27 -10
- klaude_code/cli/main.py +15 -4
- klaude_code/config/assets/builtin_config.yaml +8 -24
- klaude_code/config/config.py +47 -25
- klaude_code/config/sub_agent_model_helper.py +18 -13
- klaude_code/config/thinking.py +0 -8
- klaude_code/const.py +2 -2
- klaude_code/core/agent_profile.py +11 -53
- klaude_code/core/compaction/compaction.py +4 -6
- klaude_code/core/compaction/overflow.py +0 -4
- klaude_code/core/executor.py +51 -5
- klaude_code/core/manager/llm_clients.py +9 -1
- klaude_code/core/prompts/prompt-claude-code.md +4 -4
- klaude_code/core/reminders.py +21 -23
- klaude_code/core/task.py +0 -4
- klaude_code/core/tool/__init__.py +3 -2
- klaude_code/core/tool/file/apply_patch.py +0 -27
- klaude_code/core/tool/file/edit_tool.py +1 -2
- klaude_code/core/tool/file/read_tool.md +3 -2
- klaude_code/core/tool/file/read_tool.py +15 -2
- klaude_code/core/tool/offload.py +0 -35
- klaude_code/core/tool/sub_agent/__init__.py +6 -0
- klaude_code/core/tool/sub_agent/image_gen.md +16 -0
- klaude_code/core/tool/sub_agent/image_gen.py +146 -0
- klaude_code/core/tool/sub_agent/task.md +20 -0
- klaude_code/core/tool/sub_agent/task.py +205 -0
- klaude_code/core/tool/tool_registry.py +0 -16
- klaude_code/core/turn.py +1 -1
- klaude_code/llm/anthropic/input.py +6 -5
- klaude_code/llm/antigravity/input.py +14 -7
- klaude_code/llm/codex/client.py +22 -0
- klaude_code/llm/codex/prompt_sync.py +237 -0
- klaude_code/llm/google/client.py +8 -6
- klaude_code/llm/google/input.py +20 -12
- klaude_code/llm/image.py +18 -11
- klaude_code/llm/input_common.py +14 -6
- klaude_code/llm/json_stable.py +37 -0
- klaude_code/llm/openai_compatible/input.py +0 -10
- klaude_code/llm/openai_compatible/stream.py +16 -1
- klaude_code/llm/registry.py +0 -5
- klaude_code/llm/responses/input.py +15 -5
- klaude_code/llm/usage.py +0 -8
- klaude_code/protocol/commands.py +1 -0
- klaude_code/protocol/events.py +2 -1
- klaude_code/protocol/message.py +2 -2
- klaude_code/protocol/model.py +20 -1
- klaude_code/protocol/op.py +27 -0
- klaude_code/protocol/op_handler.py +10 -0
- klaude_code/protocol/sub_agent/AGENTS.md +5 -5
- klaude_code/protocol/sub_agent/__init__.py +13 -34
- klaude_code/protocol/sub_agent/explore.py +7 -34
- klaude_code/protocol/sub_agent/image_gen.py +3 -74
- klaude_code/protocol/sub_agent/task.py +3 -47
- klaude_code/protocol/sub_agent/web.py +8 -52
- klaude_code/protocol/tools.py +2 -0
- klaude_code/session/export.py +308 -299
- klaude_code/session/session.py +58 -21
- klaude_code/session/store.py +0 -4
- klaude_code/session/templates/export_session.html +430 -134
- klaude_code/skill/assets/deslop/SKILL.md +9 -0
- klaude_code/skill/system_skills.py +0 -20
- klaude_code/tui/command/__init__.py +3 -0
- klaude_code/tui/command/continue_cmd.py +34 -0
- klaude_code/tui/command/fork_session_cmd.py +5 -2
- klaude_code/tui/command/resume_cmd.py +9 -2
- klaude_code/tui/command/sub_agent_model_cmd.py +85 -18
- klaude_code/tui/components/assistant.py +0 -26
- klaude_code/tui/components/command_output.py +3 -1
- klaude_code/tui/components/developer.py +3 -0
- klaude_code/tui/components/diffs.py +2 -208
- klaude_code/tui/components/errors.py +4 -0
- klaude_code/tui/components/mermaid_viewer.py +2 -2
- klaude_code/tui/components/rich/markdown.py +60 -63
- klaude_code/tui/components/rich/theme.py +2 -0
- klaude_code/tui/components/sub_agent.py +2 -46
- klaude_code/tui/components/thinking.py +0 -33
- klaude_code/tui/components/tools.py +43 -21
- klaude_code/tui/input/images.py +21 -18
- klaude_code/tui/input/key_bindings.py +2 -2
- klaude_code/tui/input/prompt_toolkit.py +49 -49
- klaude_code/tui/machine.py +15 -11
- klaude_code/tui/renderer.py +12 -20
- klaude_code/tui/runner.py +2 -1
- klaude_code/tui/terminal/image.py +6 -34
- klaude_code/ui/common.py +0 -70
- {klaude_code-2.8.0.dist-info → klaude_code-2.9.0.dist-info}/METADATA +3 -6
- {klaude_code-2.8.0.dist-info → klaude_code-2.9.0.dist-info}/RECORD +97 -92
- klaude_code/core/tool/sub_agent_tool.py +0 -126
- klaude_code/llm/openai_compatible/tool_call_accumulator.py +0 -108
- klaude_code/tui/components/rich/searchable_text.py +0 -68
- {klaude_code-2.8.0.dist-info → klaude_code-2.9.0.dist-info}/WHEEL +0 -0
- {klaude_code-2.8.0.dist-info → klaude_code-2.9.0.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Task tool implementation for running sub-agents by type."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, cast
|
|
8
|
+
|
|
9
|
+
from klaude_code.core.tool.context import ToolContext
|
|
10
|
+
from klaude_code.core.tool.tool_abc import ToolABC, ToolConcurrencyPolicy, ToolMetadata, load_desc
|
|
11
|
+
from klaude_code.core.tool.tool_registry import register
|
|
12
|
+
from klaude_code.protocol import llm_param, message, model, tools
|
|
13
|
+
from klaude_code.protocol.sub_agent import get_sub_agent_profile, iter_sub_agent_profiles
|
|
14
|
+
from klaude_code.session.session import Session
|
|
15
|
+
|
|
16
|
+
TASK_TYPE_TO_SUB_AGENT: dict[str, str] = {
|
|
17
|
+
"general-purpose": "Task",
|
|
18
|
+
"explore": "Explore",
|
|
19
|
+
"web": "Web",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _task_description() -> str:
|
|
24
|
+
summaries: dict[str, str] = {}
|
|
25
|
+
for profile in iter_sub_agent_profiles():
|
|
26
|
+
if profile.invoker_type:
|
|
27
|
+
summaries[profile.invoker_type] = profile.invoker_summary.strip()
|
|
28
|
+
|
|
29
|
+
type_lines: list[str] = []
|
|
30
|
+
for invoker_type in TASK_TYPE_TO_SUB_AGENT:
|
|
31
|
+
summary = summaries.get(invoker_type, "")
|
|
32
|
+
if summary:
|
|
33
|
+
type_lines.append(f"- {invoker_type}: {summary}")
|
|
34
|
+
else:
|
|
35
|
+
type_lines.append(f"- {invoker_type}")
|
|
36
|
+
|
|
37
|
+
types_section = "\n".join(type_lines) if type_lines else "- general-purpose"
|
|
38
|
+
|
|
39
|
+
return load_desc(Path(__file__).parent / "task.md", {"types_section": types_section})
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
TASK_SCHEMA = llm_param.ToolSchema(
|
|
43
|
+
name=tools.TASK,
|
|
44
|
+
type="function",
|
|
45
|
+
description=_task_description(),
|
|
46
|
+
parameters={
|
|
47
|
+
"type": "object",
|
|
48
|
+
"properties": {
|
|
49
|
+
"type": {
|
|
50
|
+
"type": "string",
|
|
51
|
+
"enum": list(TASK_TYPE_TO_SUB_AGENT.keys()),
|
|
52
|
+
"description": "Sub-agent type selector.",
|
|
53
|
+
},
|
|
54
|
+
"description": {
|
|
55
|
+
"type": "string",
|
|
56
|
+
"description": "A short (3-5 word) description of the task.",
|
|
57
|
+
},
|
|
58
|
+
"prompt": {
|
|
59
|
+
"type": "string",
|
|
60
|
+
"description": "The task for the agent to perform.",
|
|
61
|
+
},
|
|
62
|
+
"output_schema": {
|
|
63
|
+
"type": "object",
|
|
64
|
+
"description": "Optional JSON Schema for structured output.",
|
|
65
|
+
},
|
|
66
|
+
"resume": {
|
|
67
|
+
"type": "string",
|
|
68
|
+
"description": "Optional agent ID to resume from.",
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
"required": ["description", "prompt"],
|
|
72
|
+
"additionalProperties": False,
|
|
73
|
+
},
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@register(tools.TASK)
|
|
78
|
+
class TaskTool(ToolABC):
|
|
79
|
+
"""Run a sub-agent based on the requested type."""
|
|
80
|
+
|
|
81
|
+
@classmethod
|
|
82
|
+
def metadata(cls) -> ToolMetadata:
|
|
83
|
+
return ToolMetadata(concurrency_policy=ToolConcurrencyPolicy.CONCURRENT, has_side_effects=True)
|
|
84
|
+
|
|
85
|
+
@classmethod
|
|
86
|
+
def schema(cls) -> llm_param.ToolSchema:
|
|
87
|
+
return TASK_SCHEMA
|
|
88
|
+
|
|
89
|
+
@classmethod
|
|
90
|
+
async def call(cls, arguments: str, context: ToolContext) -> message.ToolResultMessage:
|
|
91
|
+
try:
|
|
92
|
+
args = json.loads(arguments)
|
|
93
|
+
except json.JSONDecodeError as exc:
|
|
94
|
+
return message.ToolResultMessage(status="error", output_text=f"Invalid JSON arguments: {exc}")
|
|
95
|
+
|
|
96
|
+
if not isinstance(args, dict):
|
|
97
|
+
return message.ToolResultMessage(status="error", output_text="Invalid arguments: expected object")
|
|
98
|
+
|
|
99
|
+
typed_args = cast(dict[str, Any], args)
|
|
100
|
+
|
|
101
|
+
runner = context.run_subtask
|
|
102
|
+
if runner is None:
|
|
103
|
+
return message.ToolResultMessage(status="error", output_text="No subtask runner available in this context")
|
|
104
|
+
|
|
105
|
+
description = str(typed_args.get("description") or "")
|
|
106
|
+
|
|
107
|
+
resume_raw = typed_args.get("resume")
|
|
108
|
+
resume_session_id: str | None = None
|
|
109
|
+
resume_sub_agent_type: str | None = None
|
|
110
|
+
if isinstance(resume_raw, str) and resume_raw.strip():
|
|
111
|
+
try:
|
|
112
|
+
resume_session_id = Session.resolve_sub_agent_session_id(resume_raw)
|
|
113
|
+
except ValueError as exc:
|
|
114
|
+
return message.ToolResultMessage(status="error", output_text=str(exc))
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
resume_session = Session.load(resume_session_id)
|
|
118
|
+
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
119
|
+
return message.ToolResultMessage(
|
|
120
|
+
status="error",
|
|
121
|
+
output_text=f"Failed to resume sub-agent session '{resume_session_id}': {exc}",
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
if resume_session.sub_agent_state is None:
|
|
125
|
+
return message.ToolResultMessage(
|
|
126
|
+
status="error",
|
|
127
|
+
output_text=f"Invalid resume id '{resume_session_id}': target session is not a sub-agent session",
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
resume_sub_agent_type = resume_session.sub_agent_state.sub_agent_type
|
|
131
|
+
if resume_sub_agent_type == tools.IMAGE_GEN:
|
|
132
|
+
return message.ToolResultMessage(
|
|
133
|
+
status="error",
|
|
134
|
+
output_text="This resume id belongs to ImageGen; use the ImageGen tool to resume it.",
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
claims = context.sub_agent_resume_claims
|
|
138
|
+
if claims is not None:
|
|
139
|
+
ok = await claims.claim(resume_session_id)
|
|
140
|
+
if not ok:
|
|
141
|
+
return message.ToolResultMessage(
|
|
142
|
+
status="error",
|
|
143
|
+
output_text=(
|
|
144
|
+
"Duplicate sub-agent resume in the same response: "
|
|
145
|
+
f"resume='{resume_raw.strip()}' (resolved='{resume_session_id[:7]}…'). "
|
|
146
|
+
"Merge into a single call or resume in a later turn."
|
|
147
|
+
),
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
type_raw = typed_args.get("type")
|
|
151
|
+
requested_type = str(type_raw).strip() if isinstance(type_raw, str) else ""
|
|
152
|
+
|
|
153
|
+
if resume_session_id and not requested_type:
|
|
154
|
+
sub_agent_type = resume_sub_agent_type or TASK_TYPE_TO_SUB_AGENT["general-purpose"]
|
|
155
|
+
else:
|
|
156
|
+
if not requested_type:
|
|
157
|
+
requested_type = "general-purpose"
|
|
158
|
+
sub_agent_type = TASK_TYPE_TO_SUB_AGENT.get(requested_type)
|
|
159
|
+
if sub_agent_type is None:
|
|
160
|
+
return message.ToolResultMessage(
|
|
161
|
+
status="error",
|
|
162
|
+
output_text=f"Unknown Task type '{requested_type}'.",
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
if resume_session_id and resume_sub_agent_type and resume_sub_agent_type != sub_agent_type:
|
|
166
|
+
return message.ToolResultMessage(
|
|
167
|
+
status="error",
|
|
168
|
+
output_text=(
|
|
169
|
+
"Invalid resume id: sub-agent type mismatch. "
|
|
170
|
+
f"Expected '{sub_agent_type}', got '{resume_sub_agent_type}'."
|
|
171
|
+
),
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
profile = get_sub_agent_profile(sub_agent_type)
|
|
176
|
+
except KeyError as exc:
|
|
177
|
+
return message.ToolResultMessage(status="error", output_text=str(exc))
|
|
178
|
+
|
|
179
|
+
sub_agent_prompt = profile.prompt_builder(typed_args)
|
|
180
|
+
|
|
181
|
+
output_schema_raw = typed_args.get("output_schema")
|
|
182
|
+
output_schema = cast(dict[str, Any], output_schema_raw) if isinstance(output_schema_raw, dict) else None
|
|
183
|
+
|
|
184
|
+
try:
|
|
185
|
+
result = await runner(
|
|
186
|
+
model.SubAgentState(
|
|
187
|
+
sub_agent_type=profile.name,
|
|
188
|
+
sub_agent_desc=description,
|
|
189
|
+
sub_agent_prompt=sub_agent_prompt,
|
|
190
|
+
resume=resume_session_id,
|
|
191
|
+
output_schema=output_schema,
|
|
192
|
+
generation=None,
|
|
193
|
+
),
|
|
194
|
+
context.record_sub_agent_session_id,
|
|
195
|
+
context.register_sub_agent_metadata_getter,
|
|
196
|
+
)
|
|
197
|
+
except Exception as exc:
|
|
198
|
+
return message.ToolResultMessage(status="error", output_text=f"Failed to run subtask: {exc}")
|
|
199
|
+
|
|
200
|
+
return message.ToolResultMessage(
|
|
201
|
+
status="success" if not result.error else "error",
|
|
202
|
+
output_text=result.task_result,
|
|
203
|
+
ui_extra=model.SessionIdUIExtra(session_id=result.session_id),
|
|
204
|
+
task_metadata=result.task_metadata,
|
|
205
|
+
)
|
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
from collections.abc import Callable
|
|
2
2
|
from typing import TypeVar
|
|
3
3
|
|
|
4
|
-
from klaude_code.core.tool.sub_agent_tool import SubAgentTool
|
|
5
4
|
from klaude_code.core.tool.tool_abc import ToolABC
|
|
6
5
|
from klaude_code.protocol import llm_param
|
|
7
|
-
from klaude_code.protocol.sub_agent import iter_sub_agent_profiles
|
|
8
6
|
|
|
9
7
|
_REGISTRY: dict[str, type[ToolABC]] = {}
|
|
10
8
|
|
|
@@ -19,20 +17,6 @@ def register(name: str) -> Callable[[type[T]], type[T]]:
|
|
|
19
17
|
return _decorator
|
|
20
18
|
|
|
21
19
|
|
|
22
|
-
def _register_sub_agent_tools() -> None:
|
|
23
|
-
"""Automatically register all sub-agent tools based on their profiles."""
|
|
24
|
-
for profile in iter_sub_agent_profiles():
|
|
25
|
-
tool_cls = SubAgentTool.for_profile(profile)
|
|
26
|
-
_REGISTRY[profile.name] = tool_cls
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
_register_sub_agent_tools()
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
def list_tools() -> list[str]:
|
|
33
|
-
return list(_REGISTRY.keys())
|
|
34
|
-
|
|
35
|
-
|
|
36
20
|
def get_tool_schemas(tool_names: list[str]) -> list[llm_param.ToolSchema]:
|
|
37
21
|
schemas: list[llm_param.ToolSchema] = []
|
|
38
22
|
for tool_name in tool_names:
|
klaude_code/core/turn.py
CHANGED
|
@@ -243,7 +243,7 @@ class TurnExecutor:
|
|
|
243
243
|
)
|
|
244
244
|
|
|
245
245
|
# ImageGen per-call overrides (tool-level `generation` parameters)
|
|
246
|
-
if ctx.sub_agent_state is not None and ctx.sub_agent_state.sub_agent_type ==
|
|
246
|
+
if ctx.sub_agent_state is not None and ctx.sub_agent_state.sub_agent_type == tools.IMAGE_GEN:
|
|
247
247
|
call_param.modalities = ["image", "text"]
|
|
248
248
|
generation = ctx.sub_agent_state.generation or {}
|
|
249
249
|
image_config = llm_param.ImageConfig()
|
|
@@ -18,9 +18,10 @@ from anthropic.types.beta.beta_tool_use_block_param import BetaToolUseBlockParam
|
|
|
18
18
|
from anthropic.types.beta.beta_url_image_source_param import BetaURLImageSourceParam
|
|
19
19
|
|
|
20
20
|
from klaude_code.const import EMPTY_TOOL_OUTPUT_MESSAGE
|
|
21
|
-
from klaude_code.llm.image import parse_data_url
|
|
21
|
+
from klaude_code.llm.image import image_file_to_data_url, parse_data_url
|
|
22
22
|
from klaude_code.llm.input_common import (
|
|
23
23
|
DeveloperAttachment,
|
|
24
|
+
ImagePart,
|
|
24
25
|
attach_developer_messages,
|
|
25
26
|
merge_reminder_text,
|
|
26
27
|
split_thinking_parts,
|
|
@@ -36,8 +37,8 @@ _INLINE_IMAGE_MEDIA_TYPES: tuple[AllowedMediaType, ...] = (
|
|
|
36
37
|
)
|
|
37
38
|
|
|
38
39
|
|
|
39
|
-
def _image_part_to_block(image:
|
|
40
|
-
url = image.url
|
|
40
|
+
def _image_part_to_block(image: ImagePart) -> BetaImageBlockParam:
|
|
41
|
+
url = image_file_to_data_url(image) if isinstance(image, message.ImageFilePart) else image.url
|
|
41
42
|
if url.startswith("data:"):
|
|
42
43
|
media_type, base64_payload, _ = parse_data_url(url)
|
|
43
44
|
if media_type not in _INLINE_IMAGE_MEDIA_TYPES:
|
|
@@ -64,7 +65,7 @@ def _user_message_to_message(
|
|
|
64
65
|
for part in msg.parts:
|
|
65
66
|
if isinstance(part, message.TextPart):
|
|
66
67
|
blocks.append(cast(BetaTextBlockParam, {"type": "text", "text": part.text}))
|
|
67
|
-
elif isinstance(part, message.ImageURLPart):
|
|
68
|
+
elif isinstance(part, (message.ImageURLPart, message.ImageFilePart)):
|
|
68
69
|
blocks.append(_image_part_to_block(part))
|
|
69
70
|
if attachment.text:
|
|
70
71
|
blocks.append(cast(BetaTextBlockParam, {"type": "text", "text": attachment.text}))
|
|
@@ -86,7 +87,7 @@ def _tool_message_to_block(
|
|
|
86
87
|
attachment.text,
|
|
87
88
|
)
|
|
88
89
|
tool_content.append(cast(BetaTextBlockParam, {"type": "text", "text": merged_text}))
|
|
89
|
-
for image in [part for part in msg.parts if isinstance(part, message.ImageURLPart)]:
|
|
90
|
+
for image in [part for part in msg.parts if isinstance(part, (message.ImageURLPart, message.ImageFilePart))]:
|
|
90
91
|
tool_content.append(_image_part_to_block(image))
|
|
91
92
|
for image in attachment.images:
|
|
92
93
|
tool_content.append(_image_part_to_block(image))
|
|
@@ -6,9 +6,10 @@ from binascii import Error as BinasciiError
|
|
|
6
6
|
from typing import Any, TypedDict
|
|
7
7
|
|
|
8
8
|
from klaude_code.const import EMPTY_TOOL_OUTPUT_MESSAGE
|
|
9
|
-
from klaude_code.llm.image import assistant_image_to_data_url, parse_data_url
|
|
9
|
+
from klaude_code.llm.image import assistant_image_to_data_url, image_file_to_data_url, parse_data_url
|
|
10
10
|
from klaude_code.llm.input_common import (
|
|
11
11
|
DeveloperAttachment,
|
|
12
|
+
ImagePart,
|
|
12
13
|
attach_developer_messages,
|
|
13
14
|
merge_reminder_text,
|
|
14
15
|
split_thinking_parts,
|
|
@@ -66,9 +67,9 @@ def _data_url_to_inline_data(url: str) -> InlineData:
|
|
|
66
67
|
return InlineData(mimeType=media_type, data=base64.b64encode(decoded).decode("ascii"))
|
|
67
68
|
|
|
68
69
|
|
|
69
|
-
def _image_part_to_part(image:
|
|
70
|
-
"""Convert ImageURLPart to Part dict."""
|
|
71
|
-
url = image.url
|
|
70
|
+
def _image_part_to_part(image: ImagePart) -> Part:
|
|
71
|
+
"""Convert ImageURLPart or ImageFilePart to Part dict."""
|
|
72
|
+
url = image_file_to_data_url(image) if isinstance(image, message.ImageFilePart) else image.url
|
|
72
73
|
if url.startswith("data:"):
|
|
73
74
|
return Part(inlineData=_data_url_to_inline_data(url))
|
|
74
75
|
# For non-data URLs, best-effort using inline_data format
|
|
@@ -81,7 +82,7 @@ def _user_message_to_content(msg: message.UserMessage, attachment: DeveloperAtta
|
|
|
81
82
|
for part in msg.parts:
|
|
82
83
|
if isinstance(part, message.TextPart):
|
|
83
84
|
parts.append(Part(text=part.text))
|
|
84
|
-
elif isinstance(part, message.ImageURLPart):
|
|
85
|
+
elif isinstance(part, (message.ImageURLPart, message.ImageFilePart)):
|
|
85
86
|
parts.append(_image_part_to_part(part))
|
|
86
87
|
if attachment.text:
|
|
87
88
|
parts.append(Part(text=attachment.text))
|
|
@@ -108,14 +109,20 @@ def _tool_messages_to_contents(
|
|
|
108
109
|
)
|
|
109
110
|
has_text = merged_text.strip() != ""
|
|
110
111
|
|
|
111
|
-
images
|
|
112
|
+
images: list[ImagePart] = [
|
|
113
|
+
part for part in msg.parts if isinstance(part, (message.ImageURLPart, message.ImageFilePart))
|
|
114
|
+
]
|
|
115
|
+
images.extend(attachment.images)
|
|
112
116
|
image_parts: list[Part] = []
|
|
113
117
|
function_response_parts: list[dict[str, Any]] = []
|
|
114
118
|
|
|
115
119
|
for image in images:
|
|
116
120
|
try:
|
|
117
121
|
image_parts.append(_image_part_to_part(image))
|
|
118
|
-
if image.
|
|
122
|
+
if isinstance(image, message.ImageFilePart):
|
|
123
|
+
inline_data = _data_url_to_inline_data(image_file_to_data_url(image))
|
|
124
|
+
function_response_parts.append({"inlineData": inline_data})
|
|
125
|
+
elif image.url.startswith("data:"):
|
|
119
126
|
inline_data = _data_url_to_inline_data(image.url)
|
|
120
127
|
function_response_parts.append({"inlineData": inline_data})
|
|
121
128
|
except ValueError:
|
klaude_code/llm/codex/client.py
CHANGED
|
@@ -146,6 +146,28 @@ class CodexClient(LLMClientABC):
|
|
|
146
146
|
)
|
|
147
147
|
except (openai.OpenAIError, httpx.HTTPError) as e:
|
|
148
148
|
error_message = f"{e.__class__.__name__} {e!s}"
|
|
149
|
+
|
|
150
|
+
# Check for invalid instruction error and invalidate prompt cache
|
|
151
|
+
if _is_invalid_instruction_error(e) and param.model_id:
|
|
152
|
+
_invalidate_prompt_cache_for_model(param.model_id)
|
|
153
|
+
|
|
149
154
|
return error_llm_stream(metadata_tracker, error=error_message)
|
|
150
155
|
|
|
151
156
|
return ResponsesLLMStream(stream, param=param, metadata_tracker=metadata_tracker)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _is_invalid_instruction_error(e: Exception) -> bool:
|
|
160
|
+
"""Check if the error is related to invalid instructions."""
|
|
161
|
+
error_str = str(e).lower()
|
|
162
|
+
return "invalid instruction" in error_str or "invalid_instruction" in error_str
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _invalidate_prompt_cache_for_model(model_id: str) -> None:
|
|
166
|
+
"""Invalidate the cached prompt for a model to force refresh."""
|
|
167
|
+
from klaude_code.llm.codex.prompt_sync import invalidate_cache
|
|
168
|
+
|
|
169
|
+
log_debug(
|
|
170
|
+
f"Invalidating prompt cache for model {model_id} due to invalid instruction error",
|
|
171
|
+
debug_type=DebugType.GENERAL,
|
|
172
|
+
)
|
|
173
|
+
invalidate_cache(model_id)
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Dynamic prompt synchronization from OpenAI Codex GitHub repository."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
from functools import cache
|
|
6
|
+
from importlib.resources import files
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Literal
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from klaude_code.log import DebugType, log_debug
|
|
13
|
+
|
|
14
|
+
GITHUB_API_RELEASES = "https://api.github.com/repos/openai/codex/releases/latest"
|
|
15
|
+
GITHUB_HTML_RELEASES = "https://github.com/openai/codex/releases/latest"
|
|
16
|
+
GITHUB_RAW_BASE = "https://raw.githubusercontent.com/openai/codex"
|
|
17
|
+
|
|
18
|
+
CACHE_DIR = Path.home() / ".klaude" / "codex-prompts"
|
|
19
|
+
CACHE_TTL_SECONDS = 24 * 60 * 60 # 24 hours
|
|
20
|
+
|
|
21
|
+
type ModelFamily = Literal["gpt-5.2-codex", "codex-max", "codex", "gpt-5.2", "gpt-5.1"]
|
|
22
|
+
|
|
23
|
+
PROMPT_FILES: dict[ModelFamily, str] = {
|
|
24
|
+
"gpt-5.2-codex": "gpt-5.2-codex_prompt.md",
|
|
25
|
+
"codex-max": "gpt-5.1-codex-max_prompt.md",
|
|
26
|
+
"codex": "gpt_5_codex_prompt.md",
|
|
27
|
+
"gpt-5.2": "gpt_5_2_prompt.md",
|
|
28
|
+
"gpt-5.1": "gpt_5_1_prompt.md",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
CACHE_FILES: dict[ModelFamily, str] = {
|
|
32
|
+
"gpt-5.2-codex": "gpt-5.2-codex-instructions.md",
|
|
33
|
+
"codex-max": "codex-max-instructions.md",
|
|
34
|
+
"codex": "codex-instructions.md",
|
|
35
|
+
"gpt-5.2": "gpt-5.2-instructions.md",
|
|
36
|
+
"gpt-5.1": "gpt-5.1-instructions.md",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@cache
|
|
41
|
+
def _load_bundled_prompt(prompt_path: str) -> str:
|
|
42
|
+
"""Load bundled prompt from package resources."""
|
|
43
|
+
return files("klaude_code.core").joinpath(prompt_path).read_text(encoding="utf-8").strip()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class CacheMetadata:
|
|
47
|
+
def __init__(self, etag: str | None, tag: str, last_checked: int, url: str):
|
|
48
|
+
self.etag = etag
|
|
49
|
+
self.tag = tag
|
|
50
|
+
self.last_checked = last_checked
|
|
51
|
+
self.url = url
|
|
52
|
+
|
|
53
|
+
def to_dict(self) -> dict[str, str | int | None]:
|
|
54
|
+
return {
|
|
55
|
+
"etag": self.etag,
|
|
56
|
+
"tag": self.tag,
|
|
57
|
+
"last_checked": self.last_checked,
|
|
58
|
+
"url": self.url,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
def from_dict(cls, data: dict[str, object]) -> "CacheMetadata":
|
|
63
|
+
etag = data.get("etag")
|
|
64
|
+
last_checked = data.get("last_checked")
|
|
65
|
+
return cls(
|
|
66
|
+
etag=etag if isinstance(etag, str) else None,
|
|
67
|
+
tag=str(data.get("tag", "")),
|
|
68
|
+
last_checked=int(last_checked) if isinstance(last_checked, int | float) else 0,
|
|
69
|
+
url=str(data.get("url", "")),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def get_model_family(model: str) -> ModelFamily:
|
|
74
|
+
"""Determine model family from model name."""
|
|
75
|
+
if "gpt-5.2-codex" in model or "gpt 5.2 codex" in model:
|
|
76
|
+
return "gpt-5.2-codex"
|
|
77
|
+
if "codex-max" in model:
|
|
78
|
+
return "codex-max"
|
|
79
|
+
if "codex" in model or model.startswith("codex-"):
|
|
80
|
+
return "codex"
|
|
81
|
+
if "gpt-5.2" in model:
|
|
82
|
+
return "gpt-5.2"
|
|
83
|
+
return "gpt-5.1"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _get_latest_release_tag(client: httpx.Client) -> str:
|
|
87
|
+
"""Get latest release tag from GitHub."""
|
|
88
|
+
try:
|
|
89
|
+
response = client.get(GITHUB_API_RELEASES)
|
|
90
|
+
if response.status_code == 200:
|
|
91
|
+
data: dict[str, Any] = response.json()
|
|
92
|
+
tag_name: Any = data.get("tag_name")
|
|
93
|
+
if isinstance(tag_name, str):
|
|
94
|
+
return tag_name
|
|
95
|
+
except httpx.HTTPError:
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
# Fallback: follow redirect from releases/latest
|
|
99
|
+
response = client.get(GITHUB_HTML_RELEASES, follow_redirects=True)
|
|
100
|
+
if response.status_code == 200:
|
|
101
|
+
final_url = str(response.url)
|
|
102
|
+
if "/tag/" in final_url:
|
|
103
|
+
parts = final_url.split("/tag/")
|
|
104
|
+
if len(parts) > 1 and "/" not in parts[-1]:
|
|
105
|
+
return parts[-1]
|
|
106
|
+
|
|
107
|
+
raise RuntimeError("Failed to determine latest release tag from GitHub")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _load_cache_metadata(meta_file: Path) -> CacheMetadata | None:
|
|
111
|
+
if not meta_file.exists():
|
|
112
|
+
return None
|
|
113
|
+
try:
|
|
114
|
+
data = json.loads(meta_file.read_text())
|
|
115
|
+
return CacheMetadata.from_dict(data)
|
|
116
|
+
except (json.JSONDecodeError, ValueError):
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _save_cache_metadata(meta_file: Path, metadata: CacheMetadata) -> None:
|
|
121
|
+
meta_file.parent.mkdir(parents=True, exist_ok=True)
|
|
122
|
+
meta_file.write_text(json.dumps(metadata.to_dict(), indent=2))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def get_codex_instructions(model: str = "gpt-5.1-codex", force_refresh: bool = False) -> str:
|
|
126
|
+
"""Get Codex instructions for the given model.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
model: Model name to get instructions for.
|
|
130
|
+
force_refresh: If True, bypass cache TTL and fetch fresh instructions.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
The Codex system prompt instructions.
|
|
134
|
+
"""
|
|
135
|
+
model_family = get_model_family(model)
|
|
136
|
+
prompt_file = PROMPT_FILES[model_family]
|
|
137
|
+
cache_file = CACHE_DIR / CACHE_FILES[model_family]
|
|
138
|
+
meta_file = CACHE_DIR / f"{CACHE_FILES[model_family].replace('.md', '-meta.json')}"
|
|
139
|
+
|
|
140
|
+
# Check cache unless force refresh
|
|
141
|
+
if not force_refresh:
|
|
142
|
+
metadata = _load_cache_metadata(meta_file)
|
|
143
|
+
if metadata and cache_file.exists():
|
|
144
|
+
age = int(time.time()) - metadata.last_checked
|
|
145
|
+
if age < CACHE_TTL_SECONDS:
|
|
146
|
+
log_debug(f"Using cached {model_family} instructions (age: {age}s)", debug_type=DebugType.GENERAL)
|
|
147
|
+
return cache_file.read_text()
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
with httpx.Client(timeout=30.0) as client:
|
|
151
|
+
latest_tag = _get_latest_release_tag(client)
|
|
152
|
+
instructions_url = f"{GITHUB_RAW_BASE}/{latest_tag}/codex-rs/core/{prompt_file}"
|
|
153
|
+
|
|
154
|
+
# Load existing metadata for conditional request
|
|
155
|
+
metadata = _load_cache_metadata(meta_file)
|
|
156
|
+
headers: dict[str, str] = {}
|
|
157
|
+
|
|
158
|
+
# Only use ETag if tag matches (different release = different content)
|
|
159
|
+
if metadata and metadata.tag == latest_tag and metadata.etag:
|
|
160
|
+
headers["If-None-Match"] = metadata.etag
|
|
161
|
+
|
|
162
|
+
response = client.get(instructions_url, headers=headers)
|
|
163
|
+
|
|
164
|
+
if response.status_code == 304 and cache_file.exists():
|
|
165
|
+
# Not modified, update last_checked and return cached
|
|
166
|
+
if metadata:
|
|
167
|
+
metadata.last_checked = int(time.time())
|
|
168
|
+
_save_cache_metadata(meta_file, metadata)
|
|
169
|
+
log_debug(f"Codex {model_family} instructions not modified", debug_type=DebugType.GENERAL)
|
|
170
|
+
return cache_file.read_text()
|
|
171
|
+
|
|
172
|
+
if response.status_code == 200:
|
|
173
|
+
instructions = response.text
|
|
174
|
+
new_etag = response.headers.get("etag")
|
|
175
|
+
|
|
176
|
+
# Save to cache
|
|
177
|
+
cache_file.parent.mkdir(parents=True, exist_ok=True)
|
|
178
|
+
cache_file.write_text(instructions)
|
|
179
|
+
_save_cache_metadata(
|
|
180
|
+
meta_file,
|
|
181
|
+
CacheMetadata(
|
|
182
|
+
etag=new_etag,
|
|
183
|
+
tag=latest_tag,
|
|
184
|
+
last_checked=int(time.time()),
|
|
185
|
+
url=instructions_url,
|
|
186
|
+
),
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
log_debug(f"Updated {model_family} instructions from GitHub", debug_type=DebugType.GENERAL)
|
|
190
|
+
return instructions
|
|
191
|
+
|
|
192
|
+
raise RuntimeError(f"HTTP {response.status_code}")
|
|
193
|
+
|
|
194
|
+
except Exception as e:
|
|
195
|
+
log_debug(f"Failed to fetch {model_family} instructions: {e}", debug_type=DebugType.GENERAL)
|
|
196
|
+
|
|
197
|
+
# Fallback to cached version
|
|
198
|
+
if cache_file.exists():
|
|
199
|
+
log_debug(f"Using cached {model_family} instructions (fallback)", debug_type=DebugType.GENERAL)
|
|
200
|
+
return cache_file.read_text()
|
|
201
|
+
|
|
202
|
+
# Last resort: use bundled prompt
|
|
203
|
+
bundled_path = _get_bundled_prompt_path(model_family)
|
|
204
|
+
if bundled_path:
|
|
205
|
+
log_debug(f"Using bundled {model_family} instructions (fallback)", debug_type=DebugType.GENERAL)
|
|
206
|
+
return _load_bundled_prompt(bundled_path)
|
|
207
|
+
|
|
208
|
+
raise RuntimeError(f"No Codex instructions available for {model_family}") from e
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _get_bundled_prompt_path(model_family: ModelFamily) -> str | None:
|
|
212
|
+
"""Get bundled prompt path for model family."""
|
|
213
|
+
if model_family == "gpt-5.2-codex":
|
|
214
|
+
return "prompts/prompt-codex-gpt-5-2-codex.md"
|
|
215
|
+
if model_family == "gpt-5.2":
|
|
216
|
+
return "prompts/prompt-codex-gpt-5-2.md"
|
|
217
|
+
if model_family in ("codex", "codex-max", "gpt-5.1"):
|
|
218
|
+
return "prompts/prompt-codex.md"
|
|
219
|
+
return None
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def invalidate_cache(model: str | None = None) -> None:
|
|
223
|
+
"""Invalidate cached instructions to force refresh on next access.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
model: If provided, only invalidate cache for this model's family.
|
|
227
|
+
If None, invalidate all cached instructions.
|
|
228
|
+
"""
|
|
229
|
+
if model:
|
|
230
|
+
model_family = get_model_family(model)
|
|
231
|
+
meta_file = CACHE_DIR / f"{CACHE_FILES[model_family].replace('.md', '-meta.json')}"
|
|
232
|
+
if meta_file.exists():
|
|
233
|
+
meta_file.unlink()
|
|
234
|
+
else:
|
|
235
|
+
if CACHE_DIR.exists():
|
|
236
|
+
for meta_file in CACHE_DIR.glob("*-meta.json"):
|
|
237
|
+
meta_file.unlink()
|
klaude_code/llm/google/client.py
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
# pyright: reportUnknownArgumentType=false
|
|
4
4
|
# pyright: reportAttributeAccessIssue=false
|
|
5
5
|
|
|
6
|
-
import json
|
|
7
6
|
from base64 import b64encode
|
|
8
7
|
from collections.abc import AsyncGenerator, AsyncIterator
|
|
9
8
|
from typing import Any, cast, override
|
|
@@ -33,6 +32,7 @@ from klaude_code.llm.client import LLMClientABC, LLMStreamABC
|
|
|
33
32
|
from klaude_code.llm.google.input import convert_history_to_contents, convert_tool_schema
|
|
34
33
|
from klaude_code.llm.image import save_assistant_image
|
|
35
34
|
from klaude_code.llm.input_common import apply_config_defaults
|
|
35
|
+
from klaude_code.llm.json_stable import dumps_canonical_json
|
|
36
36
|
from klaude_code.llm.registry import register
|
|
37
37
|
from klaude_code.llm.stream_parts import (
|
|
38
38
|
append_text_part,
|
|
@@ -122,6 +122,8 @@ def _usage_from_metadata(
|
|
|
122
122
|
if usage is None:
|
|
123
123
|
return None
|
|
124
124
|
|
|
125
|
+
# In Gemini usage metadata, prompt_token_count represents the full prompt tokens
|
|
126
|
+
# (including cached tokens). cached_content_token_count is a subset of prompt tokens.
|
|
125
127
|
cached = usage.cached_content_token_count or 0
|
|
126
128
|
prompt = usage.prompt_token_count or 0
|
|
127
129
|
response = usage.candidates_token_count or 0
|
|
@@ -136,10 +138,10 @@ def _usage_from_metadata(
|
|
|
136
138
|
|
|
137
139
|
total = usage.total_token_count
|
|
138
140
|
if total is None:
|
|
139
|
-
total = prompt +
|
|
141
|
+
total = prompt + response + thoughts
|
|
140
142
|
|
|
141
143
|
return model.Usage(
|
|
142
|
-
input_tokens=prompt
|
|
144
|
+
input_tokens=prompt,
|
|
143
145
|
cached_tokens=cached,
|
|
144
146
|
output_tokens=response + thoughts,
|
|
145
147
|
reasoning_tokens=thoughts,
|
|
@@ -385,7 +387,7 @@ async def parse_google_stream(
|
|
|
385
387
|
args_obj = function_call.args
|
|
386
388
|
if args_obj is not None:
|
|
387
389
|
# Add ToolCallPart, then ThinkingSignaturePart after it
|
|
388
|
-
state.append_tool_call(call_id, name,
|
|
390
|
+
state.append_tool_call(call_id, name, dumps_canonical_json(args_obj))
|
|
389
391
|
encoded_sig = _encode_thought_signature(thought_signature)
|
|
390
392
|
if encoded_sig:
|
|
391
393
|
state.append_thinking_signature(encoded_sig)
|
|
@@ -400,7 +402,7 @@ async def parse_google_stream(
|
|
|
400
402
|
will_continue = function_call.will_continue
|
|
401
403
|
if will_continue is False and call_id in partial_args_by_call and call_id not in completed_tool_items:
|
|
402
404
|
# Add ToolCallPart, then ThinkingSignaturePart after it
|
|
403
|
-
state.append_tool_call(call_id, name,
|
|
405
|
+
state.append_tool_call(call_id, name, dumps_canonical_json(partial_args_by_call[call_id]))
|
|
404
406
|
stored_sig = started_tool_calls.get(call_id, (name, None))[1]
|
|
405
407
|
encoded_stored_sig = _encode_thought_signature(stored_sig)
|
|
406
408
|
if encoded_stored_sig:
|
|
@@ -412,7 +414,7 @@ async def parse_google_stream(
|
|
|
412
414
|
if call_id in completed_tool_items:
|
|
413
415
|
continue
|
|
414
416
|
args = partial_args_by_call.get(call_id, {})
|
|
415
|
-
state.append_tool_call(call_id, name,
|
|
417
|
+
state.append_tool_call(call_id, name, dumps_canonical_json(args))
|
|
416
418
|
encoded_stored_sig = _encode_thought_signature(stored_sig)
|
|
417
419
|
if encoded_stored_sig:
|
|
418
420
|
state.append_thinking_signature(encoded_stored_sig)
|