klaude-code 1.2.4__py3-none-any.whl → 1.2.5__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/runtime.py +5 -4
- klaude_code/core/executor.py +100 -232
- klaude_code/core/manager/__init__.py +19 -0
- klaude_code/core/manager/agent_manager.py +127 -0
- klaude_code/core/manager/llm_clients.py +42 -0
- klaude_code/core/manager/llm_clients_builder.py +49 -0
- klaude_code/core/manager/sub_agent_manager.py +86 -0
- klaude_code/core/tool/shell/bash_tool.py +4 -6
- klaude_code/core/tool/shell/command_safety.py +0 -267
- klaude_code/core/tool/tool_registry.py +2 -0
- klaude_code/llm/anthropic/input.py +4 -1
- klaude_code/llm/openai_compatible/input.py +4 -3
- klaude_code/llm/openrouter/input.py +4 -3
- klaude_code/llm/responses/input.py +1 -1
- klaude_code/session/export.py +16 -18
- klaude_code/session/templates/export_session.html +14 -9
- {klaude_code-1.2.4.dist-info → klaude_code-1.2.5.dist-info}/METADATA +1 -1
- {klaude_code-1.2.4.dist-info → klaude_code-1.2.5.dist-info}/RECORD +20 -15
- {klaude_code-1.2.4.dist-info → klaude_code-1.2.5.dist-info}/WHEEL +0 -0
- {klaude_code-1.2.4.dist-info → klaude_code-1.2.5.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Container for main and sub-agent LLM clients."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from dataclasses import field as dataclass_field
|
|
7
|
+
|
|
8
|
+
from klaude_code.llm.client import LLMClientABC
|
|
9
|
+
from klaude_code.protocol.tools import SubAgentType
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _default_sub_clients() -> dict[SubAgentType, LLMClientABC]:
|
|
13
|
+
"""Return an empty mapping for sub-agent clients.
|
|
14
|
+
|
|
15
|
+
Defined separately so static type checkers can infer the dictionary
|
|
16
|
+
key and value types instead of treating them as ``Unknown``.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
return {}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class LLMClients:
|
|
24
|
+
"""Container for LLM clients used by main agent and sub-agents."""
|
|
25
|
+
|
|
26
|
+
main: LLMClientABC
|
|
27
|
+
sub_clients: dict[SubAgentType, LLMClientABC] = dataclass_field(default_factory=_default_sub_clients)
|
|
28
|
+
|
|
29
|
+
def get_client(self, sub_agent_type: SubAgentType | None = None) -> LLMClientABC:
|
|
30
|
+
"""Return client for a sub-agent type or the main client.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
sub_agent_type: Optional sub-agent type whose client should be returned.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
The LLM client corresponding to the sub-agent type, or the main client
|
|
37
|
+
when no specialized client is available.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
if sub_agent_type is None:
|
|
41
|
+
return self.main
|
|
42
|
+
return self.sub_clients.get(sub_agent_type) or self.main
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Factory helpers for building :class:`LLMClients` from config."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from klaude_code.config import Config
|
|
6
|
+
from klaude_code.core.manager.llm_clients import LLMClients
|
|
7
|
+
from klaude_code.llm.client import LLMClientABC
|
|
8
|
+
from klaude_code.llm.registry import create_llm_client
|
|
9
|
+
from klaude_code.protocol.sub_agent import get_sub_agent_profile
|
|
10
|
+
from klaude_code.protocol.tools import SubAgentType
|
|
11
|
+
from klaude_code.trace import DebugType, log_debug
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def build_llm_clients(
|
|
15
|
+
config: Config,
|
|
16
|
+
*,
|
|
17
|
+
model_override: str | None = None,
|
|
18
|
+
enabled_sub_agents: list[SubAgentType] | None = None,
|
|
19
|
+
) -> LLMClients:
|
|
20
|
+
"""Create an ``LLMClients`` bundle driven by application config."""
|
|
21
|
+
|
|
22
|
+
# Resolve main agent LLM config
|
|
23
|
+
if model_override:
|
|
24
|
+
llm_config = config.get_model_config(model_override)
|
|
25
|
+
else:
|
|
26
|
+
llm_config = config.get_main_model_config()
|
|
27
|
+
|
|
28
|
+
log_debug(
|
|
29
|
+
"Main LLM config",
|
|
30
|
+
llm_config.model_dump_json(exclude_none=True),
|
|
31
|
+
style="yellow",
|
|
32
|
+
debug_type=DebugType.LLM_CONFIG,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
main_client = create_llm_client(llm_config)
|
|
36
|
+
sub_clients: dict[SubAgentType, LLMClientABC] = {}
|
|
37
|
+
|
|
38
|
+
# Initialize sub-agent clients
|
|
39
|
+
for sub_agent_type in enabled_sub_agents or []:
|
|
40
|
+
model_name = config.subagent_models.get(sub_agent_type)
|
|
41
|
+
if not model_name:
|
|
42
|
+
continue
|
|
43
|
+
profile = get_sub_agent_profile(sub_agent_type)
|
|
44
|
+
if not profile.enabled_for_model(main_client.model_name):
|
|
45
|
+
continue
|
|
46
|
+
sub_llm_config = config.get_model_config(model_name)
|
|
47
|
+
sub_clients[sub_agent_type] = create_llm_client(sub_llm_config)
|
|
48
|
+
|
|
49
|
+
return LLMClients(main=main_client, sub_clients=sub_clients)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Manager for running nested sub-agent tasks.
|
|
2
|
+
|
|
3
|
+
The :class:`SubAgentManager` encapsulates the logic for creating child
|
|
4
|
+
sessions, selecting appropriate LLM clients for sub-agents, and streaming
|
|
5
|
+
their events back to the shared event queue.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
|
|
12
|
+
from klaude_code.core.agent import Agent, ModelProfileProvider
|
|
13
|
+
from klaude_code.core.manager.llm_clients import LLMClients
|
|
14
|
+
from klaude_code.protocol import events, model
|
|
15
|
+
from klaude_code.protocol.sub_agent import SubAgentResult
|
|
16
|
+
from klaude_code.session.session import Session
|
|
17
|
+
from klaude_code.trace import DebugType, log_debug
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SubAgentManager:
|
|
21
|
+
"""Run sub-agent tasks and forward their events to the UI."""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
event_queue: asyncio.Queue[events.Event],
|
|
26
|
+
llm_clients: LLMClients,
|
|
27
|
+
model_profile_provider: ModelProfileProvider,
|
|
28
|
+
) -> None:
|
|
29
|
+
self._event_queue: asyncio.Queue[events.Event] = event_queue
|
|
30
|
+
self._llm_clients: LLMClients = llm_clients
|
|
31
|
+
self._model_profile_provider: ModelProfileProvider = model_profile_provider
|
|
32
|
+
|
|
33
|
+
async def emit_event(self, event: events.Event) -> None:
|
|
34
|
+
"""Emit an event to the shared event queue."""
|
|
35
|
+
|
|
36
|
+
await self._event_queue.put(event)
|
|
37
|
+
|
|
38
|
+
async def run_subagent(self, parent_agent: Agent, state: model.SubAgentState) -> SubAgentResult:
|
|
39
|
+
"""Run a nested sub-agent task and return its result."""
|
|
40
|
+
|
|
41
|
+
# Create a child session under the same workdir
|
|
42
|
+
parent_session = parent_agent.session
|
|
43
|
+
child_session = Session(work_dir=parent_session.work_dir)
|
|
44
|
+
child_session.sub_agent_state = state
|
|
45
|
+
|
|
46
|
+
child_profile = self._model_profile_provider.build_profile(
|
|
47
|
+
self._llm_clients.get_client(state.sub_agent_type),
|
|
48
|
+
state.sub_agent_type,
|
|
49
|
+
)
|
|
50
|
+
child_agent = Agent(session=child_session, profile=child_profile)
|
|
51
|
+
|
|
52
|
+
log_debug(
|
|
53
|
+
f"Running sub-agent {state.sub_agent_type} in session {child_session.id}",
|
|
54
|
+
style="cyan",
|
|
55
|
+
debug_type=DebugType.EXECUTION,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
# Not emit the subtask's user input since task tool call is already rendered
|
|
60
|
+
result: str = ""
|
|
61
|
+
sub_agent_input = model.UserInputPayload(text=state.sub_agent_prompt, images=None)
|
|
62
|
+
async for event in child_agent.run_task(sub_agent_input):
|
|
63
|
+
# Capture TaskFinishEvent content for return
|
|
64
|
+
if isinstance(event, events.TaskFinishEvent):
|
|
65
|
+
result = event.task_result
|
|
66
|
+
await self.emit_event(event)
|
|
67
|
+
return SubAgentResult(task_result=result, session_id=child_session.id)
|
|
68
|
+
except asyncio.CancelledError:
|
|
69
|
+
# Propagate cancellation so tooling can treat it as user interrupt
|
|
70
|
+
log_debug(
|
|
71
|
+
f"Subagent task for {state.sub_agent_type} was cancelled",
|
|
72
|
+
style="yellow",
|
|
73
|
+
debug_type=DebugType.EXECUTION,
|
|
74
|
+
)
|
|
75
|
+
raise
|
|
76
|
+
except Exception as exc: # pragma: no cover - defensive logging
|
|
77
|
+
log_debug(
|
|
78
|
+
f"Subagent task failed: [{exc.__class__.__name__}] {str(exc)}",
|
|
79
|
+
style="red",
|
|
80
|
+
debug_type=DebugType.EXECUTION,
|
|
81
|
+
)
|
|
82
|
+
return SubAgentResult(
|
|
83
|
+
task_result=f"Subagent task failed: [{exc.__class__.__name__}] {str(exc)}",
|
|
84
|
+
session_id="",
|
|
85
|
+
error=True,
|
|
86
|
+
)
|
|
@@ -6,7 +6,7 @@ from pathlib import Path
|
|
|
6
6
|
from pydantic import BaseModel
|
|
7
7
|
|
|
8
8
|
from klaude_code import const
|
|
9
|
-
from klaude_code.core.tool.shell.command_safety import is_safe_command
|
|
9
|
+
from klaude_code.core.tool.shell.command_safety import is_safe_command
|
|
10
10
|
from klaude_code.core.tool.tool_abc import ToolABC, load_desc
|
|
11
11
|
from klaude_code.core.tool.tool_registry import register
|
|
12
12
|
from klaude_code.protocol import llm_param, model, tools
|
|
@@ -57,10 +57,8 @@ class BashTool(ToolABC):
|
|
|
57
57
|
|
|
58
58
|
@classmethod
|
|
59
59
|
async def call_with_args(cls, args: BashArguments) -> model.ToolResultItem:
|
|
60
|
-
command_str = strip_bash_lc(args.command)
|
|
61
|
-
|
|
62
60
|
# Safety check: only execute commands proven as "known safe"
|
|
63
|
-
result = is_safe_command(
|
|
61
|
+
result = is_safe_command(args.command)
|
|
64
62
|
if not result.is_safe:
|
|
65
63
|
return model.ToolResultItem(
|
|
66
64
|
status="error",
|
|
@@ -69,7 +67,7 @@ class BashTool(ToolABC):
|
|
|
69
67
|
|
|
70
68
|
# Run the command using bash -lc so shell semantics work (pipes, &&, etc.)
|
|
71
69
|
# Capture stdout/stderr, respect timeout, and return a ToolMessage.
|
|
72
|
-
cmd = ["bash", "-lc",
|
|
70
|
+
cmd = ["bash", "-lc", args.command]
|
|
73
71
|
timeout_sec = max(0.0, args.timeout_ms / 1000.0)
|
|
74
72
|
|
|
75
73
|
try:
|
|
@@ -111,7 +109,7 @@ class BashTool(ToolABC):
|
|
|
111
109
|
except subprocess.TimeoutExpired:
|
|
112
110
|
return model.ToolResultItem(
|
|
113
111
|
status="error",
|
|
114
|
-
output=f"Timeout after {args.timeout_ms} ms running: {
|
|
112
|
+
output=f"Timeout after {args.timeout_ms} ms running: {args.command}",
|
|
115
113
|
)
|
|
116
114
|
except FileNotFoundError:
|
|
117
115
|
return model.ToolResultItem(
|
|
@@ -18,43 +18,6 @@ def _is_valid_sed_n_arg(s: str | None) -> bool:
|
|
|
18
18
|
return bool(re.fullmatch(r"\d+(,\d+)?p", s))
|
|
19
19
|
|
|
20
20
|
|
|
21
|
-
def _has_shell_redirection(argv: list[str]) -> bool: # pyright: ignore
|
|
22
|
-
"""Detect whether argv contains shell redirection or control operators."""
|
|
23
|
-
|
|
24
|
-
if len(argv) <= 1:
|
|
25
|
-
return False
|
|
26
|
-
|
|
27
|
-
# Heuristic detection: look for tokens that represent redirection or control operators
|
|
28
|
-
redir_prefixes = ("<>", ">>", ">", "<<<", "<<-", "<<", "<&", ">&", "|")
|
|
29
|
-
control_tokens = {"|", "||", "&&", ";"}
|
|
30
|
-
|
|
31
|
-
for token in argv[1:]:
|
|
32
|
-
if not token:
|
|
33
|
-
continue
|
|
34
|
-
|
|
35
|
-
if token in control_tokens:
|
|
36
|
-
return True
|
|
37
|
-
|
|
38
|
-
# Allow literal angle-bracket text such as <tag> by skipping tokens
|
|
39
|
-
# that contain both '<' and '>' characters.
|
|
40
|
-
if "<" in token and ">" in token:
|
|
41
|
-
continue
|
|
42
|
-
|
|
43
|
-
# Strip leading file descriptor numbers (e.g., 2>file, 1<&0)
|
|
44
|
-
stripped = token.lstrip("0123456789")
|
|
45
|
-
if not stripped:
|
|
46
|
-
continue
|
|
47
|
-
|
|
48
|
-
for prefix in redir_prefixes:
|
|
49
|
-
if stripped.startswith(prefix):
|
|
50
|
-
# Handle the pipeline-with-stderr prefix specifically
|
|
51
|
-
if prefix == "|":
|
|
52
|
-
return True
|
|
53
|
-
return True
|
|
54
|
-
|
|
55
|
-
return False
|
|
56
|
-
|
|
57
|
-
|
|
58
21
|
def _is_safe_awk_program(program: str) -> SafetyCheckResult:
|
|
59
22
|
lowered = program.lower()
|
|
60
23
|
|
|
@@ -367,236 +330,6 @@ def _is_safe_argv(argv: list[str]) -> SafetyCheckResult:
|
|
|
367
330
|
return SafetyCheckResult(True)
|
|
368
331
|
|
|
369
332
|
|
|
370
|
-
def parse_command_sequence(script: str) -> tuple[list[list[str]] | None, str]:
|
|
371
|
-
"""Parse command sequence separated by logical or pipe operators."""
|
|
372
|
-
if not script.strip():
|
|
373
|
-
return None, "Empty script"
|
|
374
|
-
|
|
375
|
-
# Tokenize with shlex so quotes/escapes are handled by the stdlib.
|
|
376
|
-
# Treat '|', '&', ';' as punctuation so they become standalone tokens.
|
|
377
|
-
try:
|
|
378
|
-
lexer = shlex.shlex(script, posix=True, punctuation_chars="|;&")
|
|
379
|
-
tokens = list(lexer)
|
|
380
|
-
except ValueError as e:
|
|
381
|
-
# Preserve error format expected by callers/tests
|
|
382
|
-
return None, f"Shell parsing error: {e}"
|
|
383
|
-
|
|
384
|
-
commands: list[list[str]] = []
|
|
385
|
-
cur: list[str] = []
|
|
386
|
-
|
|
387
|
-
i = 0
|
|
388
|
-
n = len(tokens)
|
|
389
|
-
while i < n:
|
|
390
|
-
t = tokens[i]
|
|
391
|
-
|
|
392
|
-
# Semicolon separator
|
|
393
|
-
if t == ";":
|
|
394
|
-
if not cur:
|
|
395
|
-
return None, "Empty command in sequence"
|
|
396
|
-
commands.append(cur)
|
|
397
|
-
cur = []
|
|
398
|
-
i += 1
|
|
399
|
-
continue
|
|
400
|
-
|
|
401
|
-
# Pipe or logical OR separators
|
|
402
|
-
if t == "|" or t == "||":
|
|
403
|
-
# Treat both '|' and '||' as separators between commands
|
|
404
|
-
if not cur:
|
|
405
|
-
return None, "Empty command in sequence"
|
|
406
|
-
commands.append(cur)
|
|
407
|
-
cur = []
|
|
408
|
-
# If '|' and next is also '|', consume both; if already '||', consume one
|
|
409
|
-
if t == "|" and i + 1 < n and tokens[i + 1] == "|":
|
|
410
|
-
i += 2
|
|
411
|
-
else:
|
|
412
|
-
i += 1
|
|
413
|
-
continue
|
|
414
|
-
|
|
415
|
-
# Logical AND separator or background '&'
|
|
416
|
-
if t == "&&" or t == "&":
|
|
417
|
-
if t == "&&" or (i + 1 < n and tokens[i + 1] == "&"):
|
|
418
|
-
if not cur:
|
|
419
|
-
return None, "Empty command in sequence"
|
|
420
|
-
commands.append(cur)
|
|
421
|
-
cur = []
|
|
422
|
-
# If token is single '&' but next is '&', consume both; otherwise it's '&&' already
|
|
423
|
-
if t == "&":
|
|
424
|
-
i += 2
|
|
425
|
-
else:
|
|
426
|
-
i += 1
|
|
427
|
-
continue
|
|
428
|
-
# Single '&' becomes a normal token in argv (background op)
|
|
429
|
-
cur.append(t)
|
|
430
|
-
i += 1
|
|
431
|
-
continue
|
|
432
|
-
|
|
433
|
-
# Regular argument token
|
|
434
|
-
cur.append(t)
|
|
435
|
-
i += 1
|
|
436
|
-
|
|
437
|
-
if not cur:
|
|
438
|
-
return None, "Empty command in sequence"
|
|
439
|
-
commands.append(cur)
|
|
440
|
-
return commands, ""
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
def _find_unquoted_token(command: str, token: str) -> int | None:
|
|
444
|
-
"""Locate token position ensuring it appears outside quoted regions."""
|
|
445
|
-
|
|
446
|
-
in_single = False
|
|
447
|
-
in_double = False
|
|
448
|
-
i = 0
|
|
449
|
-
length = len(command)
|
|
450
|
-
|
|
451
|
-
while i < length:
|
|
452
|
-
ch = command[i]
|
|
453
|
-
if ch == "\\":
|
|
454
|
-
i += 2
|
|
455
|
-
continue
|
|
456
|
-
if ch == "'" and not in_double:
|
|
457
|
-
in_single = not in_single
|
|
458
|
-
i += 1
|
|
459
|
-
continue
|
|
460
|
-
if ch == '"' and not in_single:
|
|
461
|
-
in_double = not in_double
|
|
462
|
-
i += 1
|
|
463
|
-
continue
|
|
464
|
-
|
|
465
|
-
if not in_single and not in_double and command.startswith(token, i):
|
|
466
|
-
before_ok = i == 0 or command[i - 1].isspace()
|
|
467
|
-
after_idx = i + len(token)
|
|
468
|
-
after_ok = after_idx >= length or command[after_idx].isspace()
|
|
469
|
-
if before_ok and after_ok:
|
|
470
|
-
return i
|
|
471
|
-
i += 1
|
|
472
|
-
|
|
473
|
-
return None
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
def _split_script_tail(tail: str) -> tuple[str | None, list[str]]:
|
|
477
|
-
"""Split the -c tail into script and remaining tokens."""
|
|
478
|
-
|
|
479
|
-
tail = tail.lstrip()
|
|
480
|
-
if not tail:
|
|
481
|
-
return None, []
|
|
482
|
-
|
|
483
|
-
if tail[0] in {'"', "'"}:
|
|
484
|
-
quote = tail[0]
|
|
485
|
-
escaped = False
|
|
486
|
-
in_single = False
|
|
487
|
-
in_double = False
|
|
488
|
-
i = 1
|
|
489
|
-
while i < len(tail):
|
|
490
|
-
ch = tail[i]
|
|
491
|
-
if escaped:
|
|
492
|
-
escaped = False
|
|
493
|
-
i += 1
|
|
494
|
-
continue
|
|
495
|
-
if ch == "\\":
|
|
496
|
-
escaped = True
|
|
497
|
-
i += 1
|
|
498
|
-
continue
|
|
499
|
-
if ch == "'" and quote == '"':
|
|
500
|
-
in_single = not in_single
|
|
501
|
-
i += 1
|
|
502
|
-
continue
|
|
503
|
-
if ch == '"' and quote == "'":
|
|
504
|
-
in_double = not in_double
|
|
505
|
-
i += 1
|
|
506
|
-
continue
|
|
507
|
-
if ch == quote and not in_single and not in_double:
|
|
508
|
-
script = tail[1:i]
|
|
509
|
-
rest = tail[i + 1 :].lstrip()
|
|
510
|
-
break
|
|
511
|
-
i += 1
|
|
512
|
-
else:
|
|
513
|
-
# Unterminated quote: treat the remainder as script
|
|
514
|
-
return tail[1:], []
|
|
515
|
-
else:
|
|
516
|
-
match = re.search(r"\s", tail)
|
|
517
|
-
if match:
|
|
518
|
-
script = tail[: match.start()]
|
|
519
|
-
rest = tail[match.end() :].lstrip()
|
|
520
|
-
else:
|
|
521
|
-
return tail, []
|
|
522
|
-
|
|
523
|
-
if not rest:
|
|
524
|
-
return script, []
|
|
525
|
-
|
|
526
|
-
try:
|
|
527
|
-
rest_tokens = shlex.split(rest, posix=True)
|
|
528
|
-
except ValueError:
|
|
529
|
-
rest_tokens = rest.split()
|
|
530
|
-
|
|
531
|
-
return script, rest_tokens
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
def _split_bash_lc_relaxed(command: str) -> list[str] | None:
|
|
535
|
-
"""Attempt relaxed parsing for bash -lc commands with inline scripts."""
|
|
536
|
-
|
|
537
|
-
idx = _find_unquoted_token(command, "-c")
|
|
538
|
-
if idx is None:
|
|
539
|
-
return None
|
|
540
|
-
|
|
541
|
-
head = command[:idx].strip()
|
|
542
|
-
try:
|
|
543
|
-
head_tokens = shlex.split(head, posix=True) if head else []
|
|
544
|
-
except ValueError:
|
|
545
|
-
return None
|
|
546
|
-
|
|
547
|
-
flag = "-c"
|
|
548
|
-
tail = command[idx + len(flag) :]
|
|
549
|
-
script, rest_tokens = _split_script_tail(tail)
|
|
550
|
-
|
|
551
|
-
result: list[str] = head_tokens + [flag]
|
|
552
|
-
if script is not None:
|
|
553
|
-
result.append(script)
|
|
554
|
-
result.extend(rest_tokens)
|
|
555
|
-
return result
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
def strip_bash_lc_argv(argv: list[str]) -> list[str]:
|
|
559
|
-
"""Extract the actual command from bash -lc format if present in argv list."""
|
|
560
|
-
if len(argv) >= 3 and argv[0] == "bash" and argv[1] == "-lc":
|
|
561
|
-
command = argv[2]
|
|
562
|
-
try:
|
|
563
|
-
parsed = shlex.split(command, posix=True)
|
|
564
|
-
except ValueError:
|
|
565
|
-
relaxed = _split_bash_lc_relaxed(command)
|
|
566
|
-
if relaxed:
|
|
567
|
-
return relaxed
|
|
568
|
-
# If parsing fails, return the original command string as single item
|
|
569
|
-
return [command]
|
|
570
|
-
if "-c" in parsed:
|
|
571
|
-
idx = parsed.index("-c")
|
|
572
|
-
if len(parsed) > idx + 2:
|
|
573
|
-
relaxed = _split_bash_lc_relaxed(command)
|
|
574
|
-
if relaxed:
|
|
575
|
-
return relaxed
|
|
576
|
-
return parsed
|
|
577
|
-
|
|
578
|
-
# If not bash -lc format, return original argv
|
|
579
|
-
return argv
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
def strip_bash_lc(command: str) -> str:
|
|
583
|
-
"""Extract the actual command from bash -lc format if present."""
|
|
584
|
-
try:
|
|
585
|
-
# Parse the command into tokens
|
|
586
|
-
argv = shlex.split(command, posix=True)
|
|
587
|
-
|
|
588
|
-
# Check if it's a bash -lc command
|
|
589
|
-
if len(argv) >= 3 and argv[0] == "bash" and argv[1] == "-lc":
|
|
590
|
-
# Return the actual command (third argument)
|
|
591
|
-
return argv[2]
|
|
592
|
-
|
|
593
|
-
# If not bash -lc format, return original command
|
|
594
|
-
return command
|
|
595
|
-
except ValueError:
|
|
596
|
-
# If parsing fails, return original command
|
|
597
|
-
return command
|
|
598
|
-
|
|
599
|
-
|
|
600
333
|
def is_safe_command(command: str) -> SafetyCheckResult:
|
|
601
334
|
"""Determine if a command is safe enough to run.
|
|
602
335
|
|
|
@@ -68,6 +68,8 @@ def load_agent_tools(
|
|
|
68
68
|
# Main agent tools
|
|
69
69
|
if "gpt-5" in model_name:
|
|
70
70
|
tool_names = [tools.BASH, tools.READ, tools.APPLY_PATCH, tools.UPDATE_PLAN]
|
|
71
|
+
elif "gemini-3" in model_name:
|
|
72
|
+
tool_names = [tools.BASH, tools.READ, tools.EDIT, tools.WRITE]
|
|
71
73
|
else:
|
|
72
74
|
tool_names = [tools.BASH, tools.READ, tools.EDIT, tools.WRITE, tools.TODO_WRITE]
|
|
73
75
|
|
|
@@ -75,7 +75,10 @@ def _user_group_to_message(group: UserGroup) -> BetaMessageParam:
|
|
|
75
75
|
|
|
76
76
|
def _tool_group_to_message(group: ToolGroup) -> BetaMessageParam:
|
|
77
77
|
tool_content: list[BetaTextBlockParam | BetaImageBlockParam] = []
|
|
78
|
-
merged_text = merge_reminder_text(
|
|
78
|
+
merged_text = merge_reminder_text(
|
|
79
|
+
group.tool_result.output or "<system-reminder>Tool ran without output or errors</system-reminder>",
|
|
80
|
+
group.reminder_texts,
|
|
81
|
+
)
|
|
79
82
|
tool_content.append({"type": "text", "text": merged_text})
|
|
80
83
|
for image in group.tool_result.images or []:
|
|
81
84
|
tool_content.append(_image_part_to_block(image))
|
|
@@ -22,9 +22,10 @@ def _user_group_to_message(group: UserGroup) -> chat.ChatCompletionMessageParam:
|
|
|
22
22
|
|
|
23
23
|
|
|
24
24
|
def _tool_group_to_message(group: ToolGroup) -> chat.ChatCompletionMessageParam:
|
|
25
|
-
merged_text = merge_reminder_text(
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
merged_text = merge_reminder_text(
|
|
26
|
+
group.tool_result.output or "<system-reminder>Tool ran without output or errors</system-reminder>",
|
|
27
|
+
group.reminder_texts,
|
|
28
|
+
)
|
|
28
29
|
return {
|
|
29
30
|
"role": "tool",
|
|
30
31
|
"content": [{"type": "text", "text": merged_text}],
|
|
@@ -37,9 +37,10 @@ def _user_group_to_message(group: UserGroup) -> chat.ChatCompletionMessageParam:
|
|
|
37
37
|
|
|
38
38
|
|
|
39
39
|
def _tool_group_to_message(group: ToolGroup) -> chat.ChatCompletionMessageParam:
|
|
40
|
-
merged_text = merge_reminder_text(
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
merged_text = merge_reminder_text(
|
|
41
|
+
group.tool_result.output or "<system-reminder>Tool ran without output or errors</system-reminder>",
|
|
42
|
+
group.reminder_texts,
|
|
43
|
+
)
|
|
43
44
|
return {
|
|
44
45
|
"role": "tool",
|
|
45
46
|
"content": [{"type": "text", "text": merged_text}],
|
|
@@ -23,7 +23,7 @@ def _build_user_content_parts(
|
|
|
23
23
|
|
|
24
24
|
def _build_tool_result_item(tool: model.ToolResultItem) -> responses.ResponseInputItemParam:
|
|
25
25
|
content_parts: list[responses.ResponseInputContentParam] = []
|
|
26
|
-
text_output = tool.output or ""
|
|
26
|
+
text_output = tool.output or "<system-reminder>Tool ran without output or errors</system-reminder>"
|
|
27
27
|
if text_output:
|
|
28
28
|
content_parts.append({"type": "input_text", "text": text_output})
|
|
29
29
|
for image in tool.images or []:
|
klaude_code/session/export.py
CHANGED
|
@@ -159,62 +159,60 @@ def _format_cost(cost: float) -> str:
|
|
|
159
159
|
|
|
160
160
|
|
|
161
161
|
def _render_metadata_item(item: model.ResponseMetadataItem) -> str:
|
|
162
|
-
#
|
|
162
|
+
# Model Name [@ Provider]
|
|
163
|
+
parts: list[str] = []
|
|
164
|
+
|
|
163
165
|
model_parts = [f'<span class="metadata-model">{_escape_html(item.model_name)}</span>']
|
|
164
166
|
if item.provider:
|
|
165
167
|
provider = _escape_html(item.provider.lower().replace(" ", "-"))
|
|
166
168
|
model_parts.append(f'<span class="metadata-provider">@{provider}</span>')
|
|
167
169
|
|
|
168
|
-
|
|
170
|
+
parts.append("".join(model_parts))
|
|
169
171
|
|
|
170
|
-
#
|
|
171
|
-
stats_parts: list[str] = []
|
|
172
|
+
# Stats
|
|
172
173
|
if item.usage:
|
|
173
174
|
u = item.usage
|
|
174
175
|
# Input with cost
|
|
175
176
|
input_stat = f"input: {_format_token_count(u.input_tokens)}"
|
|
176
177
|
if u.input_cost is not None:
|
|
177
178
|
input_stat += f"({_format_cost(u.input_cost)})"
|
|
178
|
-
|
|
179
|
+
parts.append(f'<span class="metadata-stat">{input_stat}</span>')
|
|
179
180
|
|
|
180
181
|
# Cached with cost
|
|
181
182
|
if u.cached_tokens > 0:
|
|
182
183
|
cached_stat = f"cached: {_format_token_count(u.cached_tokens)}"
|
|
183
184
|
if u.cache_read_cost is not None:
|
|
184
185
|
cached_stat += f"({_format_cost(u.cache_read_cost)})"
|
|
185
|
-
|
|
186
|
+
parts.append(f'<span class="metadata-stat">{cached_stat}</span>')
|
|
186
187
|
|
|
187
188
|
# Output with cost
|
|
188
189
|
output_stat = f"output: {_format_token_count(u.output_tokens)}"
|
|
189
190
|
if u.output_cost is not None:
|
|
190
191
|
output_stat += f"({_format_cost(u.output_cost)})"
|
|
191
|
-
|
|
192
|
+
parts.append(f'<span class="metadata-stat">{output_stat}</span>')
|
|
192
193
|
|
|
193
194
|
if u.reasoning_tokens > 0:
|
|
194
|
-
|
|
195
|
+
parts.append(
|
|
195
196
|
f'<span class="metadata-stat">thinking: {_format_token_count(u.reasoning_tokens)}</span>'
|
|
196
197
|
)
|
|
197
198
|
if u.context_usage_percent is not None:
|
|
198
|
-
|
|
199
|
+
parts.append(f'<span class="metadata-stat">context: {u.context_usage_percent:.1f}%</span>')
|
|
199
200
|
if u.throughput_tps is not None:
|
|
200
|
-
|
|
201
|
+
parts.append(f'<span class="metadata-stat">tps: {u.throughput_tps:.1f}</span>')
|
|
201
202
|
|
|
202
203
|
if item.task_duration_s is not None:
|
|
203
|
-
|
|
204
|
+
parts.append(f'<span class="metadata-stat">time: {item.task_duration_s:.1f}s</span>')
|
|
204
205
|
|
|
205
206
|
# Total cost
|
|
206
207
|
if item.usage is not None and item.usage.total_cost is not None:
|
|
207
|
-
|
|
208
|
+
parts.append(f'<span class="metadata-stat">cost: {_format_cost(item.usage.total_cost)}</span>')
|
|
208
209
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
divider = '<span class="metadata-divider">/</span>'
|
|
212
|
-
stats_html = divider.join(stats_parts)
|
|
210
|
+
divider = '<span class="metadata-divider">/</span>'
|
|
211
|
+
joined_html = divider.join(parts)
|
|
213
212
|
|
|
214
213
|
return (
|
|
215
214
|
f'<div class="response-metadata">'
|
|
216
|
-
f'<div class="metadata-line">{
|
|
217
|
-
f'<div class="metadata-line">{stats_html}</div>'
|
|
215
|
+
f'<div class="metadata-line">{joined_html}</div>'
|
|
218
216
|
f"</div>"
|
|
219
217
|
)
|
|
220
218
|
|