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.
@@ -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, strip_bash_lc
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(command_str)
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", command_str]
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: {command_str}",
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(group.tool_result.output, group.reminder_texts)
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(group.tool_result.output, group.reminder_texts)
26
- if not merged_text:
27
- merged_text = "<system-reminder>Tool ran without output or errors</system-reminder>"
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(group.tool_result.output, group.reminder_texts)
41
- if not merged_text:
42
- merged_text = "<system-reminder>Tool ran without output or errors</system-reminder>"
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 []:
@@ -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
- # Line 1: Model Name [@ Provider]
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
- line1 = "".join(model_parts)
170
+ parts.append("".join(model_parts))
169
171
 
170
- # Line 2: Stats
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
- stats_parts.append(f'<span class="metadata-stat">{input_stat}</span>')
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
- stats_parts.append(f'<span class="metadata-stat">{cached_stat}</span>')
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
- stats_parts.append(f'<span class="metadata-stat">{output_stat}</span>')
192
+ parts.append(f'<span class="metadata-stat">{output_stat}</span>')
192
193
 
193
194
  if u.reasoning_tokens > 0:
194
- stats_parts.append(
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
- stats_parts.append(f'<span class="metadata-stat">context: {u.context_usage_percent:.1f}%</span>')
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
- stats_parts.append(f'<span class="metadata-stat">tps: {u.throughput_tps:.1f}</span>')
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
- stats_parts.append(f'<span class="metadata-stat">time: {item.task_duration_s:.1f}s</span>')
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
- stats_parts.append(f'<span class="metadata-stat">cost: {_format_cost(item.usage.total_cost)}</span>')
208
+ parts.append(f'<span class="metadata-stat">cost: {_format_cost(item.usage.total_cost)}</span>')
208
209
 
209
- stats_html = ""
210
- if stats_parts:
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">{line1}</div>'
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