Skip to content

Commit ea9c245

Browse files
committed
bugfix: 修复codeexecute 执行和tool冲突的问题
- 如果执行codeexecute, 再次执行 tool,会导致tool的数据丢失
1 parent 532bbcf commit ea9c245

9 files changed

Lines changed: 77 additions & 61 deletions

tests/code_executors/container/test_container_code_executor.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -321,11 +321,12 @@ async def test_exec_run_exception_propagates(self, mock_cc_cls, mock_ctx):
321321
class TestCodeBlockDelimiter:
322322

323323
@patch("trpc_agent_sdk.code_executors.container._container_code_executor.ContainerClient")
324-
def test_returns_correct_delimiter(self, mock_cc_cls):
324+
def test_returns_default_delimiters(self, mock_cc_cls):
325325
mock_cc_cls.return_value = Mock()
326326
executor = ContainerCodeExecutor(image="img")
327-
delim = executor.code_block_delimiter()
327+
delims = executor.code_block_delimiters
328328

329-
assert isinstance(delim, CodeBlockDelimiter)
330-
assert delim.start == "```tool_code\n"
331-
assert delim.end == "\n```"
329+
assert isinstance(delims, list)
330+
assert all(isinstance(d, CodeBlockDelimiter) for d in delims)
331+
assert delims[0].start == "```tool_code\n"
332+
assert delims[0].end == "\n```"

tests/code_executors/local/test_unsafe_local_code_executor.py

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -62,27 +62,22 @@ def test_custom_clean_temp_files(self):
6262
executor = UnsafeLocalCodeExecutor(clean_temp_files=False)
6363
assert executor.clean_temp_files is False
6464

65-
def test_custom_delimiter(self):
66-
delim = CodeBlockDelimiter(start="<<<", end=">>>")
67-
executor = UnsafeLocalCodeExecutor(delimiter=delim)
68-
assert executor.delimiter.start == "<<<"
69-
assert executor.delimiter.end == ">>>"
70-
7165

7266
class TestCodeBlockDelimiter:
73-
"""Tests for code_block_delimiter method."""
67+
"""Tests for code block delimiter configuration."""
7468

75-
def test_returns_default_delimiter(self):
69+
def test_returns_default_delimiters(self):
7670
executor = UnsafeLocalCodeExecutor()
77-
delimiter = executor.code_block_delimiter()
78-
assert isinstance(delimiter, CodeBlockDelimiter)
79-
assert delimiter.start == "```"
80-
assert delimiter.end == "```"
71+
delimiters = executor.code_block_delimiters
72+
assert isinstance(delimiters, list)
73+
assert all(isinstance(d, CodeBlockDelimiter) for d in delimiters)
74+
assert delimiters[0].start == "```tool_code\n"
75+
assert delimiters[0].end == "\n```"
8176

82-
def test_returns_custom_delimiter(self):
77+
def test_returns_custom_delimiters(self):
8378
custom = CodeBlockDelimiter(start="---", end="---")
84-
executor = UnsafeLocalCodeExecutor(delimiter=custom)
85-
assert executor.code_block_delimiter() == custom
79+
executor = UnsafeLocalCodeExecutor(code_block_delimiters=[custom])
80+
assert executor.code_block_delimiters == [custom]
8681

8782

8883
class TestPrepareWorkDir:

tests/code_executors/test_container_container_code_executor.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -193,16 +193,17 @@ async def test_execute_code_exception_handling(self, mock_container_client_class
193193
await executor.execute_code(self.mock_ctx, code_input)
194194

195195
@patch('trpc_agent_sdk.code_executors.container._container_code_executor.ContainerClient')
196-
def test_code_block_delimiter(self, mock_container_client_class):
197-
"""Test code_block_delimiter method."""
196+
def test_code_block_delimiters(self, mock_container_client_class):
197+
"""Test default code_block_delimiters value."""
198198
mock_container_client = Mock()
199199
mock_container_client_class.return_value = mock_container_client
200200

201201
executor = ContainerCodeExecutor(image="python:3-slim")
202-
delimiter = executor.code_block_delimiter()
202+
delimiters = executor.code_block_delimiters
203203

204-
assert delimiter.start == "```tool_code\n"
205-
assert delimiter.end == "\n```"
204+
assert isinstance(delimiters, list)
205+
assert delimiters[0].start == "```tool_code\n"
206+
assert delimiters[0].end == "\n```"
206207

207208
@patch('trpc_agent_sdk.code_executors.container._container_code_executor.ContainerClient')
208209
async def test_execute_code_empty_language_defaults_to_python(self, mock_container_client_class):

tests/code_executors/test_local_unsafe_local_code_executor.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,12 +59,13 @@ def test_init_with_custom_values(self):
5959
assert executor.timeout == 30.0
6060
assert executor.clean_temp_files is False
6161

62-
def test_code_block_delimiter(self):
63-
"""Test code_block_delimiter method."""
62+
def test_code_block_delimiters(self):
63+
"""Test default code_block_delimiters value."""
6464
executor = UnsafeLocalCodeExecutor()
65-
delimiter = executor.code_block_delimiter()
65+
delimiters = executor.code_block_delimiters
6666

67-
assert isinstance(delimiter, CodeBlockDelimiter)
67+
assert isinstance(delimiters, list)
68+
assert all(isinstance(delimiter, CodeBlockDelimiter) for delimiter in delimiters)
6869

6970
@patch('trpc_agent_sdk.code_executors.local._unsafe_local_code_executor.async_execute_command')
7071
async def test_execute_code_python(self, mock_async_execute):

trpc_agent_sdk/agents/core/_code_execution_processor.py

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -248,19 +248,20 @@ async def _run_post_processor(
248248
if not llm_response or not llm_response.content:
249249
return
250250

251-
# For container and unsafe local executors, we handle execution in post-processing
252-
if isinstance(code_executor, (ContainerCodeExecutor, UnsafeLocalCodeExecutor)):
253-
# Continue with post-processing for these executors
254-
pass
255-
256251
code_executor_context = CodeExecutorContext(invocation_context.session.state)
252+
if (code_executor.execute_once_per_invocation
253+
and code_executor_context.has_executed_in_invocation(invocation_context.invocation_id)):
254+
return
255+
257256
# Skip if the error count exceeds the max retry attempts.
258257
if code_executor_context.get_error_count(invocation_context.invocation_id) >= code_executor.error_retry_attempts:
259258
return
260259

261-
# [Step 1] Extract code from the model predict response and truncate the
262-
# content to the part with the first code block.
263-
response_content = llm_response.content
260+
# [Step 1] Extract code from a cloned response content.
261+
# IMPORTANT: extract_code_and_truncate_content mutates the input Content in place.
262+
# Clone first to avoid mutating shared references that are later used by
263+
# telemetry tracing and session persistence.
264+
response_content = copy.deepcopy(llm_response.content)
264265
code_blocks = CodeExecutionUtils.extract_code_and_truncate_content(response_content,
265266
code_executor.code_block_delimiters,
266267
code_executor.ignore_codes)
@@ -284,6 +285,7 @@ async def _run_post_processor(
284285
code_blocks,
285286
code_execution_result,
286287
)
288+
code_executor_context.mark_executed_in_invocation(invocation_context.invocation_id)
287289

288290
# Generate events for code execution results
289291
# Event 1: Code execution event
@@ -302,9 +304,27 @@ async def _run_post_processor(
302304
code_execution_result)
303305
yield result_event
304306

305-
# [Step 3] Skip processing the original model response
306-
# to continue code generation loop.
307-
llm_response.content = None
307+
# [Step 3] Skip executable code parts to continue the code generation loop,
308+
# while preserving:
309+
# 1) text parts (after truncation/extraction) for conversation memory
310+
# 2) function_call parts for downstream function_call/function_response pairing
311+
retained_parts: list[Part] = []
312+
313+
# Keep text parts from the transformed response content (code stripped out).
314+
if response_content and response_content.parts:
315+
retained_parts.extend([copy.deepcopy(part) for part in response_content.parts if part.text])
316+
317+
# Keep original function_call parts from the original response payload.
318+
if llm_response.content and llm_response.content.parts:
319+
retained_parts.extend([copy.deepcopy(part) for part in llm_response.content.parts if part.function_call])
320+
321+
if retained_parts:
322+
llm_response.content = Content(
323+
role=llm_response.content.role if llm_response.content else "model",
324+
parts=retained_parts,
325+
)
326+
else:
327+
llm_response.content = None
308328

309329

310330
def _extract_and_replace_inline_files(

trpc_agent_sdk/code_executors/_base_code_executor.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,14 @@ class BaseCodeExecutor(BaseModel):
5959
error_retry_attempts: int = 2
6060
"""The number of attempts to retry on consecutive code execution errors. Default to 2."""
6161

62+
execute_once_per_invocation: bool = False
63+
"""Whether to execute model-extracted code at most once per invocation.
64+
65+
When enabled, post-processing code execution runs only for the first
66+
detected code block in a single ``invocation_id`` and skips subsequent
67+
auto-execution attempts for that invocation.
68+
"""
69+
6270
code_block_delimiters: list[CodeBlockDelimiter] = [
6371
CodeBlockDelimiter(start="```tool_code\n", end="\n```"),
6472
CodeBlockDelimiter(start="```python\n", end="\n```"),
@@ -100,10 +108,10 @@ async def execute_code(
100108
The code execution result.
101109
"""
102110

103-
@abc.abstractmethod
104-
def code_block_delimiter(self) -> CodeBlockDelimiter:
111+
def code_block_delimiter(self) -> list[CodeBlockDelimiter]:
105112
"""Return the code block delimiter used by this executor.
106113
107114
Returns:
108115
CodeBlockDelimiter instance
109116
"""
117+
return self.code_block_delimiters

trpc_agent_sdk/code_executors/_code_executor_context.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ def _ensure_code_execution_state(self) -> None:
3939
"execution_id": None,
4040
"error_counts": {},
4141
"code_execution_results": {},
42+
"executed_invocations": {},
4243
}
4344

4445
def get_input_files(self) -> List[CodeFile]:
@@ -138,6 +139,14 @@ def update_code_execution_result(self, invocation_id: str, code_blocks: List[Cod
138139
code_execution_result.model_dump(),
139140
})
140141

142+
def has_executed_in_invocation(self, invocation_id: str) -> bool:
143+
"""Whether code has already been executed in a given invocation."""
144+
return bool(self.session_state["code_execution"]["executed_invocations"].get(invocation_id, False))
145+
146+
def mark_executed_in_invocation(self, invocation_id: str) -> None:
147+
"""Mark that code execution has happened in a given invocation."""
148+
self.session_state["code_execution"]["executed_invocations"][invocation_id] = True
149+
141150
def get_state_delta(self) -> Dict:
142151
"""Get state delta for the current execution.
143152

trpc_agent_sdk/code_executors/container/_container_code_executor.py

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@
1818
from trpc_agent_sdk.context import InvocationContext
1919

2020
from .._base_code_executor import BaseCodeExecutor
21-
from .._types import CodeBlockDelimiter
2221
from .._types import CodeExecutionInput
2322
from .._types import CodeExecutionResult
2423
from .._types import create_code_execution_result
@@ -149,12 +148,3 @@ async def execute_code(
149148
output = "".join(all_output)
150149
err_str = "".join(all_errors)
151150
return create_code_execution_result(stdout=output, stderr=err_str)
152-
153-
@override
154-
def code_block_delimiter(self) -> CodeBlockDelimiter:
155-
"""Return the code block delimiter used by this executor.
156-
157-
Returns:
158-
CodeBlockDelimiter instance
159-
"""
160-
return CodeBlockDelimiter(start="```tool_code\n", end="\n```")

trpc_agent_sdk/code_executors/local/_unsafe_local_code_executor.py

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@
2222

2323
from .._base_code_executor import BaseCodeExecutor
2424
from .._types import CodeBlock
25-
from .._types import CodeBlockDelimiter
2625
from .._types import CodeExecutionInput
2726
from .._types import CodeExecutionResult
2827
from .._types import create_code_execution_result
@@ -48,9 +47,6 @@ class UnsafeLocalCodeExecutor(BaseCodeExecutor):
4847
clean_temp_files: bool = Field(default=True,
4948
description="Whether to clean temporary files after the code execution.")
5049

51-
delimiter: CodeBlockDelimiter = Field(default_factory=CodeBlockDelimiter,
52-
description="The delimiter for the code execution.")
53-
5450
def __init__(self, **data):
5551
"""Initialize the UnsafeLocalCodeExecutor."""
5652
if "stateful" in data and data["stateful"]:
@@ -97,11 +93,6 @@ async def execute_code(self, invocation_context: InvocationContext,
9793
return create_code_execution_result(stdout="\n".join(output_parts) if output_parts else "",
9894
stderr="\n".join(error_parts) if error_parts else "")
9995

100-
@override
101-
def code_block_delimiter(self) -> CodeBlockDelimiter:
102-
"""Return the code block delimiter used by this executor."""
103-
return self.delimiter
104-
10596
def _prepare_work_dir(self, execution_id: str) -> tuple[Path, bool]:
10697
"""Prepare working directory for execution.
10798

0 commit comments

Comments
 (0)