klaude-code 1.2.3__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,127 @@
1
+ """Agent and session manager.
2
+
3
+ This module contains :class:`AgentManager`, a helper responsible for
4
+ creating and tracking agents per session, applying model changes, and
5
+ clearing conversations. It is used by the executor context to keep
6
+ agent-related responsibilities separate from operation dispatch.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+
13
+ from klaude_code.config import load_config
14
+ from klaude_code.core.agent import Agent, DefaultModelProfileProvider, ModelProfileProvider
15
+ from klaude_code.core.manager.llm_clients import LLMClients
16
+ from klaude_code.llm.registry import create_llm_client
17
+ from klaude_code.protocol import commands, events, model
18
+ from klaude_code.session.session import Session
19
+ from klaude_code.trace import DebugType, log_debug
20
+
21
+
22
+ class AgentManager:
23
+ """Manager component that tracks agents and their sessions."""
24
+
25
+ def __init__(
26
+ self,
27
+ event_queue: asyncio.Queue[events.Event],
28
+ llm_clients: LLMClients,
29
+ model_profile_provider: ModelProfileProvider | None = None,
30
+ ) -> None:
31
+ self._event_queue: asyncio.Queue[events.Event] = event_queue
32
+ self._llm_clients: LLMClients = llm_clients
33
+ self._model_profile_provider: ModelProfileProvider = model_profile_provider or DefaultModelProfileProvider()
34
+ self._active_agents: dict[str, Agent] = {}
35
+
36
+ async def emit_event(self, event: events.Event) -> None:
37
+ """Emit an event to the shared event queue."""
38
+
39
+ await self._event_queue.put(event)
40
+
41
+ async def ensure_agent(self, session_id: str) -> Agent:
42
+ """Return an existing agent for the session or create a new one."""
43
+
44
+ agent = self._active_agents.get(session_id)
45
+ if agent is not None:
46
+ return agent
47
+
48
+ session = Session.load(session_id)
49
+ profile = self._model_profile_provider.build_profile(self._llm_clients.main)
50
+ agent = Agent(session=session, profile=profile)
51
+
52
+ async for evt in agent.replay_history():
53
+ await self.emit_event(evt)
54
+
55
+ await self.emit_event(
56
+ events.WelcomeEvent(
57
+ work_dir=str(session.work_dir),
58
+ llm_config=self._llm_clients.main.get_llm_config(),
59
+ )
60
+ )
61
+
62
+ self._active_agents[session_id] = agent
63
+ log_debug(
64
+ f"Initialized agent for session: {session_id}",
65
+ style="cyan",
66
+ debug_type=DebugType.EXECUTION,
67
+ )
68
+ return agent
69
+
70
+ async def apply_model_change(self, agent: Agent, model_name: str) -> None:
71
+ """Change the model used by an agent and notify the UI."""
72
+
73
+ config = load_config()
74
+ if config is None:
75
+ raise ValueError("Configuration must be initialized before changing model")
76
+
77
+ llm_config = config.get_model_config(model_name)
78
+ llm_client = create_llm_client(llm_config)
79
+ agent.set_model_profile(self._model_profile_provider.build_profile(llm_client))
80
+
81
+ developer_item = model.DeveloperMessageItem(
82
+ content=f"switched to model: {model_name}",
83
+ command_output=model.CommandOutput(command_name=commands.CommandName.MODEL),
84
+ )
85
+ agent.session.append_history([developer_item])
86
+
87
+ await self.emit_event(events.DeveloperMessageEvent(session_id=agent.session.id, item=developer_item))
88
+ await self.emit_event(events.WelcomeEvent(llm_config=llm_config, work_dir=str(agent.session.work_dir)))
89
+
90
+ async def apply_clear(self, agent: Agent) -> None:
91
+ """Start a new conversation for an agent and notify the UI."""
92
+
93
+ old_session_id = agent.session.id
94
+
95
+ # Create a new session instance to replace the current one
96
+ new_session = Session(work_dir=agent.session.work_dir)
97
+ new_session.model_name = agent.session.model_name
98
+
99
+ # Replace the agent's session with the new one
100
+ agent.session = new_session
101
+ agent.session.save()
102
+
103
+ # Update the active_agents mapping
104
+ self._active_agents.pop(old_session_id, None)
105
+ self._active_agents[new_session.id] = agent
106
+
107
+ developer_item = model.DeveloperMessageItem(
108
+ content="started new conversation",
109
+ command_output=model.CommandOutput(command_name=commands.CommandName.CLEAR),
110
+ )
111
+
112
+ await self.emit_event(events.DeveloperMessageEvent(session_id=agent.session.id, item=developer_item))
113
+
114
+ def get_active_agent(self, session_id: str) -> Agent | None:
115
+ """Return the active agent for a session id if present."""
116
+
117
+ return self._active_agents.get(session_id)
118
+
119
+ def active_session_ids(self) -> list[str]:
120
+ """Return a snapshot list of session ids that currently have agents."""
121
+
122
+ return list(self._active_agents.keys())
123
+
124
+ def all_active_agents(self) -> dict[str, Agent]:
125
+ """Return a snapshot of all active agents keyed by session id."""
126
+
127
+ return dict(self._active_agents)
@@ -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 []: