klaude-code 1.2.14__py3-none-any.whl → 1.2.16__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/cli/main.py +66 -42
- klaude_code/cli/runtime.py +24 -13
- klaude_code/command/export_cmd.py +2 -2
- klaude_code/command/prompt-handoff.md +33 -0
- klaude_code/command/thinking_cmd.py +6 -2
- klaude_code/config/config.py +5 -5
- klaude_code/config/list_model.py +1 -1
- klaude_code/const/__init__.py +3 -0
- klaude_code/core/executor.py +2 -2
- klaude_code/core/manager/llm_clients_builder.py +1 -1
- klaude_code/core/manager/sub_agent_manager.py +30 -6
- klaude_code/core/prompt.py +15 -13
- klaude_code/core/prompts/{prompt-subagent-explore.md → prompt-sub-agent-explore.md} +0 -1
- klaude_code/core/prompts/{prompt-subagent-oracle.md → prompt-sub-agent-oracle.md} +1 -1
- klaude_code/core/reminders.py +75 -32
- klaude_code/core/task.py +10 -22
- klaude_code/core/tool/__init__.py +2 -0
- klaude_code/core/tool/report_back_tool.py +58 -0
- klaude_code/core/tool/sub_agent_tool.py +6 -0
- klaude_code/core/tool/tool_runner.py +9 -1
- klaude_code/core/turn.py +45 -4
- klaude_code/llm/anthropic/input.py +14 -5
- klaude_code/llm/input_common.py +1 -1
- klaude_code/llm/openrouter/input.py +14 -3
- klaude_code/llm/responses/input.py +19 -0
- klaude_code/protocol/events.py +1 -0
- klaude_code/protocol/model.py +24 -14
- klaude_code/protocol/sub_agent/__init__.py +117 -0
- klaude_code/protocol/sub_agent/explore.py +63 -0
- klaude_code/protocol/sub_agent/oracle.py +91 -0
- klaude_code/protocol/sub_agent/task.py +61 -0
- klaude_code/protocol/sub_agent/web_fetch.py +74 -0
- klaude_code/protocol/tools.py +1 -0
- klaude_code/session/export.py +12 -6
- klaude_code/session/session.py +12 -2
- klaude_code/session/templates/export_session.html +20 -24
- klaude_code/ui/modes/repl/completers.py +1 -1
- klaude_code/ui/modes/repl/event_handler.py +34 -3
- klaude_code/ui/modes/repl/renderer.py +9 -9
- klaude_code/ui/renderers/developer.py +18 -7
- klaude_code/ui/renderers/metadata.py +57 -84
- klaude_code/ui/renderers/sub_agent.py +59 -3
- klaude_code/ui/renderers/thinking.py +3 -3
- klaude_code/ui/renderers/tools.py +67 -30
- klaude_code/ui/rich/markdown.py +45 -57
- klaude_code/ui/rich/status.py +32 -14
- klaude_code/ui/rich/theme.py +18 -17
- {klaude_code-1.2.14.dist-info → klaude_code-1.2.16.dist-info}/METADATA +3 -2
- {klaude_code-1.2.14.dist-info → klaude_code-1.2.16.dist-info}/RECORD +53 -47
- klaude_code/protocol/sub_agent.py +0 -354
- /klaude_code/core/prompts/{prompt-subagent-webfetch.md → prompt-sub-agent-webfetch.md} +0 -0
- /klaude_code/core/prompts/{prompt-subagent.md → prompt-sub-agent.md} +0 -0
- {klaude_code-1.2.14.dist-info → klaude_code-1.2.16.dist-info}/WHEEL +0 -0
- {klaude_code-1.2.14.dist-info → klaude_code-1.2.16.dist-info}/entry_points.txt +0 -0
klaude_code/core/task.py
CHANGED
|
@@ -41,10 +41,8 @@ class MetadataAccumulator:
|
|
|
41
41
|
if main.usage is None:
|
|
42
42
|
main.usage = model.Usage()
|
|
43
43
|
acc_usage = main.usage
|
|
44
|
-
|
|
45
|
-
acc_usage
|
|
46
|
-
acc_usage.reasoning_tokens += usage.reasoning_tokens
|
|
47
|
-
acc_usage.output_tokens += usage.output_tokens
|
|
44
|
+
|
|
45
|
+
model.TaskMetadata.merge_usage(acc_usage, usage)
|
|
48
46
|
acc_usage.currency = usage.currency
|
|
49
47
|
|
|
50
48
|
if usage.context_size is not None:
|
|
@@ -67,13 +65,6 @@ class MetadataAccumulator:
|
|
|
67
65
|
self._throughput_weighted_sum += usage.throughput_tps * current_output
|
|
68
66
|
self._throughput_tracked_tokens += current_output
|
|
69
67
|
|
|
70
|
-
if usage.input_cost is not None:
|
|
71
|
-
acc_usage.input_cost = (acc_usage.input_cost or 0.0) + usage.input_cost
|
|
72
|
-
if usage.output_cost is not None:
|
|
73
|
-
acc_usage.output_cost = (acc_usage.output_cost or 0.0) + usage.output_cost
|
|
74
|
-
if usage.cache_read_cost is not None:
|
|
75
|
-
acc_usage.cache_read_cost = (acc_usage.cache_read_cost or 0.0) + usage.cache_read_cost
|
|
76
|
-
|
|
77
68
|
if turn_metadata.provider is not None:
|
|
78
69
|
main.provider = turn_metadata.provider
|
|
79
70
|
if turn_metadata.model_name:
|
|
@@ -225,7 +216,7 @@ class TaskExecutor:
|
|
|
225
216
|
yield events.ErrorEvent(error_message=final_error, can_retry=False)
|
|
226
217
|
return
|
|
227
218
|
|
|
228
|
-
if turn is None or
|
|
219
|
+
if turn is None or turn.task_finished:
|
|
229
220
|
break
|
|
230
221
|
|
|
231
222
|
# Finalize metadata
|
|
@@ -234,21 +225,18 @@ class TaskExecutor:
|
|
|
234
225
|
|
|
235
226
|
yield events.TaskMetadataEvent(metadata=accumulated, session_id=session_ctx.session_id)
|
|
236
227
|
session_ctx.append_history([accumulated])
|
|
228
|
+
|
|
229
|
+
# Get task result from turn
|
|
230
|
+
task_result = turn.task_result if turn is not None else ""
|
|
231
|
+
has_structured_output = turn.has_structured_output if turn is not None else False
|
|
232
|
+
|
|
237
233
|
yield events.TaskFinishEvent(
|
|
238
234
|
session_id=session_ctx.session_id,
|
|
239
|
-
task_result=
|
|
235
|
+
task_result=task_result,
|
|
236
|
+
has_structured_output=has_structured_output,
|
|
240
237
|
)
|
|
241
238
|
|
|
242
239
|
|
|
243
|
-
def _get_last_assistant_message(history: list[model.ConversationItem]) -> str | None:
|
|
244
|
-
"""Return the content of the most recent assistant message in history."""
|
|
245
|
-
|
|
246
|
-
for item in reversed(history):
|
|
247
|
-
if isinstance(item, model.AssistantMessageItem):
|
|
248
|
-
return item.content or ""
|
|
249
|
-
return None
|
|
250
|
-
|
|
251
|
-
|
|
252
240
|
def _retry_delay_seconds(attempt: int) -> float:
|
|
253
241
|
"""Compute exponential backoff delay for the given attempt count."""
|
|
254
242
|
capped_attempt = max(1, attempt)
|
|
@@ -7,6 +7,7 @@ from .file.write_tool import WriteTool
|
|
|
7
7
|
from .memory.memory_tool import MEMORY_DIR_NAME, MemoryTool
|
|
8
8
|
from .memory.skill_loader import Skill, SkillLoader
|
|
9
9
|
from .memory.skill_tool import SkillTool
|
|
10
|
+
from .report_back_tool import ReportBackTool
|
|
10
11
|
from .shell.bash_tool import BashTool
|
|
11
12
|
from .shell.command_safety import SafetyCheckResult, is_safe_command
|
|
12
13
|
from .sub_agent_tool import SubAgentTool
|
|
@@ -38,6 +39,7 @@ __all__ = [
|
|
|
38
39
|
"MermaidTool",
|
|
39
40
|
"MultiEditTool",
|
|
40
41
|
"ReadTool",
|
|
42
|
+
"ReportBackTool",
|
|
41
43
|
"SafetyCheckResult",
|
|
42
44
|
"SimpleTruncationStrategy",
|
|
43
45
|
"Skill",
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""ReportBackTool for sub-agents to return structured output."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, ClassVar
|
|
4
|
+
|
|
5
|
+
from klaude_code.protocol import llm_param, model, tools
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ReportBackTool:
|
|
9
|
+
"""Special tool for sub-agents to return structured output and end the task.
|
|
10
|
+
|
|
11
|
+
This tool is dynamically injected when a parent agent calls a sub-agent with
|
|
12
|
+
an output_schema. The schema for this tool's parameters is defined by the
|
|
13
|
+
parent agent, allowing structured data to be returned.
|
|
14
|
+
|
|
15
|
+
Note: This class does not inherit from ToolABC because it's not registered
|
|
16
|
+
in the global tool registry. Instead, it's handled specially by the
|
|
17
|
+
TurnExecutor and SubAgentManager.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
_schema: ClassVar[dict[str, Any]] = {}
|
|
21
|
+
|
|
22
|
+
@classmethod
|
|
23
|
+
def for_schema(cls, schema: dict[str, Any]) -> type["ReportBackTool"]:
|
|
24
|
+
"""Create a tool class with the specified output schema.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
schema: JSON Schema defining the expected structure of the report_back arguments.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
A new class with the schema set as a class variable.
|
|
31
|
+
"""
|
|
32
|
+
return type("ReportBackTool", (ReportBackTool,), {"_schema": schema})
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def schema(cls) -> llm_param.ToolSchema:
|
|
36
|
+
"""Generate the tool schema for this report_back tool."""
|
|
37
|
+
return llm_param.ToolSchema(
|
|
38
|
+
name=tools.REPORT_BACK,
|
|
39
|
+
type="function",
|
|
40
|
+
description=(
|
|
41
|
+
"Report the final structured result back to the parent agent. "
|
|
42
|
+
"Call this when you have completed the task and want to return structured data. "
|
|
43
|
+
"The task will end after this tool is called."
|
|
44
|
+
),
|
|
45
|
+
parameters=cls._schema,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
async def call(cls, arguments: str) -> model.ToolResultItem:
|
|
50
|
+
"""Execute the report_back tool.
|
|
51
|
+
|
|
52
|
+
The actual handling of report_back results is done by TurnExecutor.
|
|
53
|
+
This method just returns a success status to maintain the tool call flow.
|
|
54
|
+
"""
|
|
55
|
+
return model.ToolResultItem(
|
|
56
|
+
status="success",
|
|
57
|
+
output="Result reported successfully. Task will end.",
|
|
58
|
+
)
|
|
@@ -63,12 +63,18 @@ class SubAgentTool(ToolABC):
|
|
|
63
63
|
prompt = profile.prompt_builder(args)
|
|
64
64
|
description = args.get("description", "")
|
|
65
65
|
|
|
66
|
+
# Extract output_schema if configured
|
|
67
|
+
output_schema = None
|
|
68
|
+
if profile.output_schema_arg:
|
|
69
|
+
output_schema = args.get(profile.output_schema_arg)
|
|
70
|
+
|
|
66
71
|
try:
|
|
67
72
|
result = await runner(
|
|
68
73
|
model.SubAgentState(
|
|
69
74
|
sub_agent_type=profile.name,
|
|
70
75
|
sub_agent_desc=description,
|
|
71
76
|
sub_agent_prompt=prompt,
|
|
77
|
+
output_schema=output_schema,
|
|
72
78
|
)
|
|
73
79
|
)
|
|
74
80
|
except asyncio.CancelledError:
|
|
@@ -3,9 +3,10 @@ from collections.abc import AsyncGenerator, Callable, Iterable, Sequence
|
|
|
3
3
|
from dataclasses import dataclass
|
|
4
4
|
|
|
5
5
|
from klaude_code import const
|
|
6
|
+
from klaude_code.core.tool.report_back_tool import ReportBackTool
|
|
6
7
|
from klaude_code.core.tool.tool_abc import ToolABC
|
|
7
8
|
from klaude_code.core.tool.truncation import truncate_tool_output
|
|
8
|
-
from klaude_code.protocol import model
|
|
9
|
+
from klaude_code.protocol import model, tools
|
|
9
10
|
from klaude_code.protocol.sub_agent import is_sub_agent_tool
|
|
10
11
|
|
|
11
12
|
|
|
@@ -19,6 +20,13 @@ async def run_tool(tool_call: model.ToolCallItem, registry: dict[str, type[ToolA
|
|
|
19
20
|
Returns:
|
|
20
21
|
The result of the tool execution.
|
|
21
22
|
"""
|
|
23
|
+
# Special handling for report_back tool (not registered in global registry)
|
|
24
|
+
if tool_call.name == tools.REPORT_BACK:
|
|
25
|
+
tool_result = await ReportBackTool.call(tool_call.arguments)
|
|
26
|
+
tool_result.call_id = tool_call.call_id
|
|
27
|
+
tool_result.tool_name = tool_call.name
|
|
28
|
+
return tool_result
|
|
29
|
+
|
|
22
30
|
if tool_call.name not in registry:
|
|
23
31
|
return model.ToolResultItem(
|
|
24
32
|
call_id=tool_call.call_id,
|
klaude_code/core/turn.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from collections.abc import AsyncGenerator
|
|
4
|
-
from dataclasses import dataclass
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
5
|
from typing import TYPE_CHECKING
|
|
6
6
|
|
|
7
7
|
from klaude_code.core.tool import ToolABC, tool_context
|
|
@@ -17,7 +17,7 @@ from klaude_code.core.tool.tool_runner import (
|
|
|
17
17
|
ToolExecutorEvent,
|
|
18
18
|
)
|
|
19
19
|
from klaude_code.llm import LLMClientABC
|
|
20
|
-
from klaude_code.protocol import events, llm_param, model
|
|
20
|
+
from klaude_code.protocol import events, llm_param, model, tools
|
|
21
21
|
from klaude_code.trace import DebugType, log_debug
|
|
22
22
|
|
|
23
23
|
|
|
@@ -46,6 +46,7 @@ class TurnResult:
|
|
|
46
46
|
assistant_message: model.AssistantMessageItem | None
|
|
47
47
|
tool_calls: list[model.ToolCallItem]
|
|
48
48
|
stream_error: model.StreamErrorItem | None
|
|
49
|
+
report_back_result: str | None = field(default=None)
|
|
49
50
|
|
|
50
51
|
|
|
51
52
|
def build_events_from_tool_executor_event(session_id: str, event: ToolExecutorEvent) -> list[events.Event]:
|
|
@@ -101,8 +102,38 @@ class TurnExecutor:
|
|
|
101
102
|
self._turn_result: TurnResult | None = None
|
|
102
103
|
|
|
103
104
|
@property
|
|
104
|
-
def
|
|
105
|
-
return
|
|
105
|
+
def report_back_result(self) -> str | None:
|
|
106
|
+
return self._turn_result.report_back_result if self._turn_result else None
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def task_finished(self) -> bool:
|
|
110
|
+
"""Check if this turn indicates the task should end.
|
|
111
|
+
|
|
112
|
+
Task ends when there are no tool calls or report_back was called.
|
|
113
|
+
"""
|
|
114
|
+
if self._turn_result is None:
|
|
115
|
+
return True
|
|
116
|
+
if not self._turn_result.tool_calls:
|
|
117
|
+
return True
|
|
118
|
+
return self._turn_result.report_back_result is not None
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def task_result(self) -> str:
|
|
122
|
+
"""Get the task result from this turn.
|
|
123
|
+
|
|
124
|
+
Returns report_back result if available, otherwise returns
|
|
125
|
+
the assistant message content.
|
|
126
|
+
"""
|
|
127
|
+
if self._turn_result is not None and self._turn_result.report_back_result is not None:
|
|
128
|
+
return self._turn_result.report_back_result
|
|
129
|
+
if self._turn_result is not None and self._turn_result.assistant_message is not None:
|
|
130
|
+
return self._turn_result.assistant_message.content or ""
|
|
131
|
+
return ""
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def has_structured_output(self) -> bool:
|
|
135
|
+
"""Check if the task result is structured output from report_back."""
|
|
136
|
+
return bool(self._turn_result and self._turn_result.report_back_result)
|
|
106
137
|
|
|
107
138
|
def cancel(self) -> list[events.Event]:
|
|
108
139
|
"""Cancel running tools and return any resulting events."""
|
|
@@ -143,11 +174,21 @@ class TurnExecutor:
|
|
|
143
174
|
self._append_success_history(self._turn_result)
|
|
144
175
|
|
|
145
176
|
if self._turn_result.tool_calls:
|
|
177
|
+
# Check for report_back before running tools
|
|
178
|
+
self._detect_report_back(self._turn_result)
|
|
179
|
+
|
|
146
180
|
async for ui_event in self._run_tool_executor(self._turn_result.tool_calls):
|
|
147
181
|
yield ui_event
|
|
148
182
|
|
|
149
183
|
yield events.TurnEndEvent(session_id=session_ctx.session_id)
|
|
150
184
|
|
|
185
|
+
def _detect_report_back(self, turn_result: TurnResult) -> None:
|
|
186
|
+
"""Detect report_back tool call and store its arguments as JSON string."""
|
|
187
|
+
for tool_call in turn_result.tool_calls:
|
|
188
|
+
if tool_call.name == tools.REPORT_BACK:
|
|
189
|
+
turn_result.report_back_result = tool_call.arguments
|
|
190
|
+
break
|
|
191
|
+
|
|
151
192
|
async def _consume_llm_stream(self, turn_result: TurnResult) -> AsyncGenerator[events.Event]:
|
|
152
193
|
"""Stream events from LLM and update turn_result in place."""
|
|
153
194
|
|
|
@@ -104,18 +104,22 @@ def _tool_groups_to_message(groups: list[ToolGroup]) -> BetaMessageParam:
|
|
|
104
104
|
def _assistant_group_to_message(group: AssistantGroup, model_name: str | None) -> BetaMessageParam:
|
|
105
105
|
content: list[dict[str, object]] = []
|
|
106
106
|
current_reasoning_content: str | None = None
|
|
107
|
+
degraded_thinking_texts: list[str] = []
|
|
107
108
|
|
|
108
109
|
# Process reasoning items in original order so that text and
|
|
109
110
|
# encrypted parts are paired correctly for the given model.
|
|
111
|
+
# For cross-model scenarios, degrade thinking to plain text.
|
|
110
112
|
for item in group.reasoning_items:
|
|
111
113
|
if isinstance(item, model.ReasoningTextItem):
|
|
112
114
|
if model_name != item.model:
|
|
113
|
-
|
|
114
|
-
|
|
115
|
+
# Cross-model: collect thinking text for degradation
|
|
116
|
+
if item.content:
|
|
117
|
+
degraded_thinking_texts.append(item.content)
|
|
118
|
+
else:
|
|
119
|
+
current_reasoning_content = item.content
|
|
115
120
|
else:
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
if item.encrypted_content and len(item.encrypted_content) > 0:
|
|
121
|
+
# Same model: preserve signature
|
|
122
|
+
if model_name == item.model and item.encrypted_content and len(item.encrypted_content) > 0:
|
|
119
123
|
content.append(
|
|
120
124
|
{
|
|
121
125
|
"type": "thinking",
|
|
@@ -131,6 +135,11 @@ def _assistant_group_to_message(group: AssistantGroup, model_name: str | None) -
|
|
|
131
135
|
if len(current_reasoning_content or "") > 0:
|
|
132
136
|
content.insert(0, {"type": "thinking", "thinking": current_reasoning_content})
|
|
133
137
|
|
|
138
|
+
# Cross-model: degrade thinking to plain text with <thinking> tags
|
|
139
|
+
if degraded_thinking_texts:
|
|
140
|
+
degraded_text = "<thinking>\n" + "\n".join(degraded_thinking_texts) + "\n</thinking>"
|
|
141
|
+
content.insert(0, {"type": "text", "text": degraded_text})
|
|
142
|
+
|
|
134
143
|
if group.text_content:
|
|
135
144
|
content.append({"type": "text", "text": group.text_content})
|
|
136
145
|
|
klaude_code/llm/input_common.py
CHANGED
|
@@ -149,7 +149,7 @@ def parse_message_groups(history: list[model.ConversationItem]) -> list[MessageG
|
|
|
149
149
|
for item in items:
|
|
150
150
|
if isinstance(item, (model.UserMessageItem, model.DeveloperMessageItem)):
|
|
151
151
|
if item.content:
|
|
152
|
-
group.text_parts.append(item.content)
|
|
152
|
+
group.text_parts.append(item.content + "\n")
|
|
153
153
|
if item.images:
|
|
154
154
|
group.images.extend(item.images)
|
|
155
155
|
groups.append(group)
|
|
@@ -28,9 +28,6 @@ def is_gemini_model(model_name: str | None) -> bool:
|
|
|
28
28
|
def _assistant_group_to_message(group: AssistantGroup, model_name: str | None) -> chat.ChatCompletionMessageParam:
|
|
29
29
|
assistant_message: dict[str, object] = {"role": "assistant"}
|
|
30
30
|
|
|
31
|
-
if group.text_content:
|
|
32
|
-
assistant_message["content"] = group.text_content
|
|
33
|
-
|
|
34
31
|
if group.tool_calls:
|
|
35
32
|
assistant_message["tool_calls"] = [
|
|
36
33
|
{
|
|
@@ -48,9 +45,14 @@ def _assistant_group_to_message(group: AssistantGroup, model_name: str | None) -
|
|
|
48
45
|
# The order of items in reasoning_details must match the original
|
|
49
46
|
# stream order from the provider, so we iterate reasoning_items
|
|
50
47
|
# instead of the separated reasoning_text / reasoning_encrypted lists.
|
|
48
|
+
# For cross-model scenarios, degrade thinking to plain text.
|
|
51
49
|
reasoning_details: list[dict[str, object]] = []
|
|
50
|
+
degraded_thinking_texts: list[str] = []
|
|
52
51
|
for item in group.reasoning_items:
|
|
53
52
|
if model_name != item.model:
|
|
53
|
+
# Cross-model: collect thinking text for degradation
|
|
54
|
+
if isinstance(item, model.ReasoningTextItem) and item.content:
|
|
55
|
+
degraded_thinking_texts.append(item.content)
|
|
54
56
|
continue
|
|
55
57
|
if isinstance(item, model.ReasoningEncryptedItem):
|
|
56
58
|
if item.encrypted_content and len(item.encrypted_content) > 0:
|
|
@@ -75,6 +77,15 @@ def _assistant_group_to_message(group: AssistantGroup, model_name: str | None) -
|
|
|
75
77
|
if reasoning_details:
|
|
76
78
|
assistant_message["reasoning_details"] = reasoning_details
|
|
77
79
|
|
|
80
|
+
# Build content with optional degraded thinking prefix
|
|
81
|
+
content_parts: list[str] = []
|
|
82
|
+
if degraded_thinking_texts:
|
|
83
|
+
content_parts.append("<thinking>\n" + "\n".join(degraded_thinking_texts) + "\n</thinking>")
|
|
84
|
+
if group.text_content:
|
|
85
|
+
content_parts.append(group.text_content)
|
|
86
|
+
if content_parts:
|
|
87
|
+
assistant_message["content"] = "\n".join(content_parts)
|
|
88
|
+
|
|
78
89
|
return assistant_message
|
|
79
90
|
|
|
80
91
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# pyright: reportReturnType=false
|
|
2
2
|
# pyright: reportArgumentType=false
|
|
3
|
+
# pyright: reportAssignmentType=false
|
|
3
4
|
|
|
4
5
|
from typing import Any
|
|
5
6
|
|
|
@@ -51,6 +52,7 @@ def convert_history_to_input(
|
|
|
51
52
|
items: list[responses.ResponseInputItemParam] = []
|
|
52
53
|
|
|
53
54
|
pending_reasoning_text: str | None = None
|
|
55
|
+
degraded_thinking_texts: list[str] = []
|
|
54
56
|
|
|
55
57
|
for item in history:
|
|
56
58
|
match item:
|
|
@@ -60,6 +62,9 @@ def convert_history_to_input(
|
|
|
60
62
|
# or we can choose to output it if the next item is NOT reasoning?
|
|
61
63
|
# For now, based on instructions, we pair them.
|
|
62
64
|
if model_name != item.model:
|
|
65
|
+
# Cross-model: collect thinking text for degradation
|
|
66
|
+
if item.content:
|
|
67
|
+
degraded_thinking_texts.append(item.content)
|
|
63
68
|
continue
|
|
64
69
|
pending_reasoning_text = item.content
|
|
65
70
|
|
|
@@ -130,6 +135,20 @@ def convert_history_to_input(
|
|
|
130
135
|
# Other items may be Metadata
|
|
131
136
|
continue
|
|
132
137
|
|
|
138
|
+
# Cross-model: degrade thinking to plain text with <thinking> tags
|
|
139
|
+
if degraded_thinking_texts:
|
|
140
|
+
degraded_item: responses.ResponseInputItemParam = {
|
|
141
|
+
"type": "message",
|
|
142
|
+
"role": "assistant",
|
|
143
|
+
"content": [
|
|
144
|
+
{
|
|
145
|
+
"type": "output_text",
|
|
146
|
+
"text": "<thinking>\n" + "\n".join(degraded_thinking_texts) + "\n</thinking>",
|
|
147
|
+
}
|
|
148
|
+
],
|
|
149
|
+
}
|
|
150
|
+
items.insert(0, degraded_item)
|
|
151
|
+
|
|
133
152
|
return items
|
|
134
153
|
|
|
135
154
|
|
klaude_code/protocol/events.py
CHANGED
klaude_code/protocol/model.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
from datetime import datetime
|
|
2
2
|
from enum import Enum
|
|
3
|
-
from typing import Annotated, Literal
|
|
3
|
+
from typing import Annotated, Any, Literal
|
|
4
4
|
|
|
5
5
|
from pydantic import BaseModel, ConfigDict, Field, computed_field
|
|
6
6
|
|
|
@@ -132,6 +132,7 @@ class AtPatternParseResult(BaseModel):
|
|
|
132
132
|
tool_args: str
|
|
133
133
|
operation: Literal["Read", "List"]
|
|
134
134
|
images: list["ImageURLPart"] | None = None
|
|
135
|
+
mentioned_in: str | None = None # Parent file that referenced this file
|
|
135
136
|
|
|
136
137
|
|
|
137
138
|
class CommandOutput(BaseModel):
|
|
@@ -144,6 +145,7 @@ class SubAgentState(BaseModel):
|
|
|
144
145
|
sub_agent_type: SubAgentType
|
|
145
146
|
sub_agent_desc: str
|
|
146
147
|
sub_agent_prompt: str
|
|
148
|
+
output_schema: dict[str, Any] | None = None
|
|
147
149
|
|
|
148
150
|
|
|
149
151
|
"""
|
|
@@ -327,6 +329,26 @@ class TaskMetadata(BaseModel):
|
|
|
327
329
|
task_duration_s: float | None = None
|
|
328
330
|
turn_count: int = 0
|
|
329
331
|
|
|
332
|
+
@staticmethod
|
|
333
|
+
def merge_usage(dst: Usage, src: Usage) -> None:
|
|
334
|
+
"""Merge src usage into dst usage (in-place).
|
|
335
|
+
|
|
336
|
+
Accumulates token counts and cost components. Does not handle
|
|
337
|
+
special fields like throughput_tps, first_token_latency_ms,
|
|
338
|
+
context_size, or context_limit - those require custom logic.
|
|
339
|
+
"""
|
|
340
|
+
dst.input_tokens += src.input_tokens
|
|
341
|
+
dst.cached_tokens += src.cached_tokens
|
|
342
|
+
dst.reasoning_tokens += src.reasoning_tokens
|
|
343
|
+
dst.output_tokens += src.output_tokens
|
|
344
|
+
|
|
345
|
+
if src.input_cost is not None:
|
|
346
|
+
dst.input_cost = (dst.input_cost or 0.0) + src.input_cost
|
|
347
|
+
if src.output_cost is not None:
|
|
348
|
+
dst.output_cost = (dst.output_cost or 0.0) + src.output_cost
|
|
349
|
+
if src.cache_read_cost is not None:
|
|
350
|
+
dst.cache_read_cost = (dst.cache_read_cost or 0.0) + src.cache_read_cost
|
|
351
|
+
|
|
330
352
|
@staticmethod
|
|
331
353
|
def aggregate_by_model(metadata_list: list["TaskMetadata"]) -> list["TaskMetadata"]:
|
|
332
354
|
"""Aggregate multiple TaskMetadata by (model_name, provider).
|
|
@@ -356,19 +378,7 @@ class TaskMetadata(BaseModel):
|
|
|
356
378
|
if agg.usage is None:
|
|
357
379
|
continue
|
|
358
380
|
|
|
359
|
-
|
|
360
|
-
agg.usage.input_tokens += usage.input_tokens
|
|
361
|
-
agg.usage.cached_tokens += usage.cached_tokens
|
|
362
|
-
agg.usage.reasoning_tokens += usage.reasoning_tokens
|
|
363
|
-
agg.usage.output_tokens += usage.output_tokens
|
|
364
|
-
|
|
365
|
-
# Accumulate cost components (total_cost is computed)
|
|
366
|
-
if usage.input_cost is not None:
|
|
367
|
-
agg.usage.input_cost = (agg.usage.input_cost or 0.0) + usage.input_cost
|
|
368
|
-
if usage.output_cost is not None:
|
|
369
|
-
agg.usage.output_cost = (agg.usage.output_cost or 0.0) + usage.output_cost
|
|
370
|
-
if usage.cache_read_cost is not None:
|
|
371
|
-
agg.usage.cache_read_cost = (agg.usage.cache_read_cost or 0.0) + usage.cache_read_cost
|
|
381
|
+
TaskMetadata.merge_usage(agg.usage, usage)
|
|
372
382
|
|
|
373
383
|
# Sort by total_cost descending
|
|
374
384
|
return sorted(
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
from klaude_code.protocol import tools
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from klaude_code.protocol import model
|
|
11
|
+
|
|
12
|
+
AvailabilityPredicate = Callable[[str], bool]
|
|
13
|
+
PromptBuilder = Callable[[dict[str, Any]], str]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class SubAgentResult:
|
|
18
|
+
task_result: str
|
|
19
|
+
session_id: str
|
|
20
|
+
error: bool = False
|
|
21
|
+
task_metadata: model.TaskMetadata | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _default_prompt_builder(args: dict[str, Any]) -> str:
|
|
25
|
+
"""Default prompt builder that just returns the 'prompt' field."""
|
|
26
|
+
return args.get("prompt", "")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class SubAgentProfile:
|
|
31
|
+
"""Metadata describing a sub agent and how it integrates with the system.
|
|
32
|
+
|
|
33
|
+
This dataclass contains all the information needed to:
|
|
34
|
+
1. Register the sub agent with the system
|
|
35
|
+
2. Generate the tool schema for the main agent
|
|
36
|
+
3. Build the prompt for the sub agent
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
# Identity - single name used for type, tool_name, config_key, and prompt_key
|
|
40
|
+
name: str # e.g., "Task", "Oracle", "Explore"
|
|
41
|
+
|
|
42
|
+
# Tool schema
|
|
43
|
+
description: str # Tool description shown to the main agent
|
|
44
|
+
parameters: dict[str, Any] = field(
|
|
45
|
+
default_factory=lambda: dict[str, Any](), hash=False
|
|
46
|
+
) # JSON Schema for tool parameters
|
|
47
|
+
|
|
48
|
+
# System prompt
|
|
49
|
+
prompt_file: str = "" # Resource file path relative to core package (e.g., "prompts/prompt-sub-agent.md")
|
|
50
|
+
|
|
51
|
+
# Sub agent configuration
|
|
52
|
+
tool_set: tuple[str, ...] = () # Tools available to this sub agent
|
|
53
|
+
prompt_builder: PromptBuilder = _default_prompt_builder # Builds the sub agent prompt from tool arguments
|
|
54
|
+
|
|
55
|
+
# UI display
|
|
56
|
+
active_form: str = "" # Active form for spinner status (e.g., "Tasking", "Exploring")
|
|
57
|
+
|
|
58
|
+
# Availability
|
|
59
|
+
enabled_by_default: bool = True
|
|
60
|
+
show_in_main_agent: bool = True
|
|
61
|
+
target_model_filter: AvailabilityPredicate | None = None
|
|
62
|
+
|
|
63
|
+
# Structured output support: specifies which parameter in the tool schema contains the output schema
|
|
64
|
+
output_schema_arg: str | None = None
|
|
65
|
+
|
|
66
|
+
def enabled_for_model(self, model_name: str | None) -> bool:
|
|
67
|
+
if not self.enabled_by_default:
|
|
68
|
+
return False
|
|
69
|
+
if model_name is None or self.target_model_filter is None:
|
|
70
|
+
return True
|
|
71
|
+
return self.target_model_filter(model_name)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
_PROFILES: dict[str, SubAgentProfile] = {}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def register_sub_agent(profile: SubAgentProfile) -> None:
|
|
78
|
+
if profile.name in _PROFILES:
|
|
79
|
+
raise ValueError(f"Duplicate sub agent profile: {profile.name}")
|
|
80
|
+
_PROFILES[profile.name] = profile
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def get_sub_agent_profile(sub_agent_type: tools.SubAgentType) -> SubAgentProfile:
|
|
84
|
+
try:
|
|
85
|
+
return _PROFILES[sub_agent_type]
|
|
86
|
+
except KeyError as exc:
|
|
87
|
+
raise KeyError(f"Unknown sub agent type: {sub_agent_type}") from exc
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def iter_sub_agent_profiles(enabled_only: bool = False, model_name: str | None = None) -> list[SubAgentProfile]:
|
|
91
|
+
profiles = list(_PROFILES.values())
|
|
92
|
+
if not enabled_only:
|
|
93
|
+
return profiles
|
|
94
|
+
return [p for p in profiles if p.enabled_for_model(model_name)]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def get_sub_agent_profile_by_tool(tool_name: str) -> SubAgentProfile | None:
|
|
98
|
+
return _PROFILES.get(tool_name)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def is_sub_agent_tool(tool_name: str) -> bool:
|
|
102
|
+
return tool_name in _PROFILES
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def sub_agent_tool_names(enabled_only: bool = False, model_name: str | None = None) -> list[str]:
|
|
106
|
+
return [
|
|
107
|
+
profile.name
|
|
108
|
+
for profile in iter_sub_agent_profiles(enabled_only=enabled_only, model_name=model_name)
|
|
109
|
+
if profile.show_in_main_agent
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# Import sub-agent modules to trigger registration
|
|
114
|
+
from klaude_code.protocol.sub_agent import explore as explore # noqa: E402
|
|
115
|
+
from klaude_code.protocol.sub_agent import oracle as oracle # noqa: E402
|
|
116
|
+
from klaude_code.protocol.sub_agent import task as task # noqa: E402
|
|
117
|
+
from klaude_code.protocol.sub_agent import web_fetch as web_fetch # noqa: E402
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from klaude_code.protocol import tools
|
|
6
|
+
from klaude_code.protocol.sub_agent import SubAgentProfile, register_sub_agent
|
|
7
|
+
|
|
8
|
+
EXPLORE_DESCRIPTION = """\
|
|
9
|
+
Spin up a fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), \
|
|
10
|
+
search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?")\
|
|
11
|
+
When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.
|
|
12
|
+
Always spawn multiple search agents in parallel to maximise speed.
|
|
13
|
+
|
|
14
|
+
Structured output:
|
|
15
|
+
- Provide an `output_format` (JSON Schema) parameter for structured data back from the sub-agent
|
|
16
|
+
- Example: `output_format={"type": "object", "properties": {"files": {"type": "array", "items": {"type": "string"}, "description": "List of file paths that match the search criteria, e.g. ['src/main.py', 'src/utils/helper.py']"}}, "required": ["files"]}`\
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
EXPLORE_PARAMETERS = {
|
|
20
|
+
"type": "object",
|
|
21
|
+
"properties": {
|
|
22
|
+
"description": {
|
|
23
|
+
"type": "string",
|
|
24
|
+
"description": "Short (3-5 words) label for the exploration goal",
|
|
25
|
+
},
|
|
26
|
+
"prompt": {
|
|
27
|
+
"type": "string",
|
|
28
|
+
"description": "The task for the agent to perform",
|
|
29
|
+
},
|
|
30
|
+
"thoroughness": {
|
|
31
|
+
"type": "string",
|
|
32
|
+
"enum": ["quick", "medium", "very thorough"],
|
|
33
|
+
"description": "Controls how deep the sub-agent should search the repo",
|
|
34
|
+
},
|
|
35
|
+
"output_format": {
|
|
36
|
+
"type": "object",
|
|
37
|
+
"description": "Optional JSON Schema for sub-agent structured output",
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
"required": ["description", "prompt", "output_format"],
|
|
41
|
+
"additionalProperties": False,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _explore_prompt_builder(args: dict[str, Any]) -> str:
|
|
46
|
+
"""Build the Explore prompt from tool arguments."""
|
|
47
|
+
prompt = args.get("prompt", "").strip()
|
|
48
|
+
thoroughness = args.get("thoroughness", "medium")
|
|
49
|
+
return f"{prompt}\nthoroughness: {thoroughness}"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
register_sub_agent(
|
|
53
|
+
SubAgentProfile(
|
|
54
|
+
name="Explore",
|
|
55
|
+
description=EXPLORE_DESCRIPTION,
|
|
56
|
+
parameters=EXPLORE_PARAMETERS,
|
|
57
|
+
prompt_file="prompts/prompt-sub-agent-explore.md",
|
|
58
|
+
tool_set=(tools.BASH, tools.READ),
|
|
59
|
+
prompt_builder=_explore_prompt_builder,
|
|
60
|
+
active_form="Exploring",
|
|
61
|
+
output_schema_arg="output_format",
|
|
62
|
+
)
|
|
63
|
+
)
|