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.
- klaude_code/cli/runtime.py +5 -4
- klaude_code/command/__init__.py +2 -0
- klaude_code/command/export_cmd.py +3 -1
- klaude_code/command/status_cmd.py +111 -0
- 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/protocol/commands.py +1 -0
- klaude_code/protocol/model.py +7 -0
- klaude_code/session/export.py +16 -18
- klaude_code/session/templates/export_session.html +221 -10
- klaude_code/ui/renderers/developer.py +56 -1
- {klaude_code-1.2.3.dist-info → klaude_code-1.2.5.dist-info}/METADATA +1 -1
- {klaude_code-1.2.3.dist-info → klaude_code-1.2.5.dist-info}/RECORD +26 -20
- {klaude_code-1.2.3.dist-info → klaude_code-1.2.5.dist-info}/WHEEL +0 -0
- {klaude_code-1.2.3.dist-info → klaude_code-1.2.5.dist-info}/entry_points.txt +0 -0
|
@@ -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
|
|
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 []:
|