Improve file writing and activity replay
This commit is contained in:
@@ -174,9 +174,17 @@ export function ActivityPanel() {
|
||||
(activeSessionId !== replayedSessionId || hasActiveRun(liveRunStatus));
|
||||
const items = shouldUseLiveItems ? liveItems : messageItems;
|
||||
const selectedMessageId = selectedId ? activityMessageId(selectedId) : null;
|
||||
const visibleItems = selectedMessageId
|
||||
const selectedItems = selectedMessageId
|
||||
? items.filter((item) => activityMessageId(item.id) === selectedMessageId)
|
||||
: items;
|
||||
: [];
|
||||
const visibleItems =
|
||||
selectedMessageId && selectedItems.length > 0 ? selectedItems : items;
|
||||
const selectedActivityMissing = Boolean(
|
||||
selectedMessageId &&
|
||||
!shouldUseLiveItems &&
|
||||
items.length > 0 &&
|
||||
selectedItems.length === 0,
|
||||
);
|
||||
const prevLatestIdRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -248,6 +256,11 @@ export function ActivityPanel() {
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{selectedActivityMissing ? (
|
||||
<p className="rounded-md bg-muted/50 px-3 py-2 text-muted-foreground text-xs">
|
||||
刷新后未能精确定位到这条摘要,已显示当前会话可恢复的活动链路。
|
||||
</p>
|
||||
) : null}
|
||||
{visibleItems.map((item) => (
|
||||
<ActivityPanelItem
|
||||
key={item.id}
|
||||
|
||||
@@ -220,7 +220,10 @@ def get_using_your_tools_section(enabled_tool_names: set[str]) -> str:
|
||||
'创建或追加通用文本文件时,优先使用 write_file,而不是 heredoc 或 echo 重定向;默认写入当前 session 目录。'
|
||||
)
|
||||
items.append(
|
||||
'write_file 支持 content、content_lines、content_base64 三种写入方式。多行脚本、Markdown、CSV 或包含较多引号的文本,优先用 content_lines,避免把大段带转义的内容塞进单个 JSON 字符串。'
|
||||
'write_file 是通用保存文件工具。普通文本、Markdown、Python 脚本一律优先用 content_lines(一行一个字符串);content 只用于很短的简单文本,避免把多行内容或大量引号塞进单个 JSON 字符串。'
|
||||
)
|
||||
items.append(
|
||||
'保存结构化数据时,不要自己拼 JSON/JSONL/CSV 字符串:JSON 用 json_content,JSONL 用 jsonl_records,CSV 用 csv_headers + csv_rows,让工具负责序列化和转义。'
|
||||
)
|
||||
items.append(
|
||||
'需要分批写入长文件时,使用 write_file 的 append=true 追加稳定文件;不要反复覆盖同一个中间产物。'
|
||||
|
||||
@@ -1614,7 +1614,8 @@ class LocalCodingAgent:
|
||||
f'工具:{name};位置:line {exc.lineno} column {exc.colno};'
|
||||
f'原因:{exc.msg};参数片段:{preview!r}。'
|
||||
'请回复“继续”,我会要求模型重新生成合法 JSON 参数;'
|
||||
'如果是 write_file 写多行文本,应改用 content_lines 或 content_base64;'
|
||||
'如果是 write_file 写普通文本/脚本/Markdown,应改用 content_lines;'
|
||||
'如果是保存 JSON/JSONL/CSV,应改用 json_content、jsonl_records 或 csv_rows;'
|
||||
'如果 write_file 仍不稳定,应改用 python_exec 作为通用写文件兜底;'
|
||||
'如果是生成/转换结构化数据,应优先调用对应 skill 脚本或 python_exec,'
|
||||
'不要把大段 JSON/CSV/draft 直接塞进工具参数;bash 仅作为最后兜底。'
|
||||
|
||||
@@ -40,21 +40,47 @@ def build_file_tools(handlers: Mapping[str, ToolHandler]) -> list[AgentTool]:
|
||||
name='write_file',
|
||||
description=(
|
||||
'Write or append a UTF-8 text file inside the workspace. Creates parent directories when needed. '
|
||||
'For multi-line content, prefer content_lines to reduce JSON escaping errors.'
|
||||
'Use content_lines for text/scripts/markdown. Use json_content, jsonl_records, or csv_rows for structured data '
|
||||
'so the tool serializes the file instead of forcing the model to escape large strings.'
|
||||
),
|
||||
parameters={
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'path': {'type': 'string', 'description': 'Relative path. output/, scratchpad/, and input/ route to the current session.'},
|
||||
'content': {'type': 'string', 'description': 'Full text content for short or simple files.'},
|
||||
'content_lines': {
|
||||
'type': 'array',
|
||||
'items': {'type': 'string'},
|
||||
'description': 'Lines to join with "\\n"; recommended for scripts, markdown, CSV, and other multi-line text.',
|
||||
'description': 'Lines to join with "\\n"; use this for normal text, scripts, markdown, and any multi-line content.',
|
||||
},
|
||||
'content': {
|
||||
'type': 'string',
|
||||
'description': 'Short one-line/simple text only. For multi-line or quote-heavy content use content_lines or structured fields.',
|
||||
},
|
||||
'content_base64': {
|
||||
'type': 'string',
|
||||
'description': 'Base64-encoded UTF-8 content. Use only when content contains many quotes or complex escaping.',
|
||||
'description': 'Base64-encoded UTF-8 content. Use only if the content is already available as base64.',
|
||||
},
|
||||
'json_content': {
|
||||
'type': 'object',
|
||||
'description': 'JSON value to serialize with ensure_ascii=false and indent=2. Use for .json files instead of stringifying JSON manually.',
|
||||
},
|
||||
'jsonl_records': {
|
||||
'type': 'array',
|
||||
'items': {'type': 'object'},
|
||||
'description': 'Objects to serialize as compact JSONL, one object per line. Use for .jsonl files.',
|
||||
},
|
||||
'csv_headers': {
|
||||
'type': 'array',
|
||||
'items': {'type': 'string'},
|
||||
'description': 'Optional CSV header order. Use with csv_rows.',
|
||||
},
|
||||
'csv_rows': {
|
||||
'type': 'array',
|
||||
'items': {
|
||||
'type': 'array',
|
||||
'items': {'type': 'string'},
|
||||
},
|
||||
'description': 'Rows to serialize as CSV. Each row is an array of cell strings.',
|
||||
},
|
||||
'append': {
|
||||
'type': 'boolean',
|
||||
|
||||
+54
-14
@@ -1,7 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import csv
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
@@ -1872,31 +1874,69 @@ def _write_file(arguments: dict[str, Any], context: ToolExecutionContext) -> str
|
||||
def _coerce_write_file_content(arguments: dict[str, Any]) -> str:
|
||||
provided = [
|
||||
key
|
||||
for key in ('content', 'content_lines', 'content_base64')
|
||||
for key in (
|
||||
'content_lines',
|
||||
'content',
|
||||
'content_base64',
|
||||
'json_content',
|
||||
'jsonl_records',
|
||||
'csv_rows',
|
||||
)
|
||||
if key in arguments and arguments[key] is not None
|
||||
]
|
||||
if len(provided) != 1:
|
||||
raise ToolExecutionError(
|
||||
'Provide exactly one of content, content_lines, or content_base64'
|
||||
'Provide exactly one of content_lines, content, content_base64, json_content, jsonl_records, or csv_rows'
|
||||
)
|
||||
source = provided[0]
|
||||
if source == 'content':
|
||||
content = arguments['content']
|
||||
if not isinstance(content, str):
|
||||
raise ToolExecutionError('content must be a string')
|
||||
return content
|
||||
if source == 'content_lines':
|
||||
content_lines = arguments['content_lines']
|
||||
if not isinstance(content_lines, list) or not all(isinstance(line, str) for line in content_lines):
|
||||
raise ToolExecutionError('content_lines must be an array of strings')
|
||||
return '\n'.join(content_lines)
|
||||
encoded = arguments['content_base64']
|
||||
if not isinstance(encoded, str):
|
||||
raise ToolExecutionError('content_base64 must be a string')
|
||||
try:
|
||||
return base64.b64decode(encoded, validate=True).decode('utf-8')
|
||||
except (ValueError, UnicodeDecodeError) as exc:
|
||||
raise ToolExecutionError('content_base64 must be valid base64-encoded UTF-8 text') from exc
|
||||
if source == 'content':
|
||||
content = arguments['content']
|
||||
if not isinstance(content, str):
|
||||
raise ToolExecutionError('content must be a string')
|
||||
return content
|
||||
if source == 'content_base64':
|
||||
encoded = arguments['content_base64']
|
||||
if not isinstance(encoded, str):
|
||||
raise ToolExecutionError('content_base64 must be a string')
|
||||
try:
|
||||
return base64.b64decode(encoded, validate=True).decode('utf-8')
|
||||
except (ValueError, UnicodeDecodeError) as exc:
|
||||
raise ToolExecutionError('content_base64 must be valid base64-encoded UTF-8 text') from exc
|
||||
if source == 'json_content':
|
||||
return json.dumps(arguments['json_content'], ensure_ascii=False, indent=2)
|
||||
if source == 'jsonl_records':
|
||||
records = arguments['jsonl_records']
|
||||
if not isinstance(records, list) or not all(isinstance(record, dict) for record in records):
|
||||
raise ToolExecutionError('jsonl_records must be an array of objects')
|
||||
return '\n'.join(json.dumps(record, ensure_ascii=False, separators=(',', ':')) for record in records)
|
||||
csv_rows = arguments['csv_rows']
|
||||
if not isinstance(csv_rows, list) or not all(isinstance(row, list) for row in csv_rows):
|
||||
raise ToolExecutionError('csv_rows must be an array of arrays')
|
||||
csv_headers = arguments.get('csv_headers')
|
||||
if csv_headers is not None and (
|
||||
not isinstance(csv_headers, list)
|
||||
or not all(isinstance(header, str) for header in csv_headers)
|
||||
):
|
||||
raise ToolExecutionError('csv_headers must be an array of strings')
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output, lineterminator='\n')
|
||||
if csv_headers:
|
||||
writer.writerow(csv_headers)
|
||||
writer.writerows([[_stringify_csv_cell(cell) for cell in row] for row in csv_rows])
|
||||
return output.getvalue().rstrip('\n')
|
||||
|
||||
|
||||
def _stringify_csv_cell(value: Any) -> str:
|
||||
if value is None:
|
||||
return ''
|
||||
if isinstance(value, (str, int, float, bool)):
|
||||
return str(value)
|
||||
return json.dumps(value, ensure_ascii=False, separators=(',', ':'))
|
||||
|
||||
|
||||
def _edit_file(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
|
||||
|
||||
@@ -209,6 +209,72 @@ class ExtendedToolTests(unittest.TestCase):
|
||||
self.assertTrue(session_file_exists)
|
||||
self.assertFalse(root_file_exists)
|
||||
|
||||
def test_write_file_serializes_structured_payloads(self) -> None:
|
||||
registry = default_tool_registry()
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
scratchpad = workspace / '.port_sessions' / 'accounts' / 'user' / 'sessions' / 'thread' / 'scratchpad'
|
||||
scratchpad.mkdir(parents=True)
|
||||
context = build_tool_context(
|
||||
AgentRuntimeConfig(
|
||||
cwd=workspace,
|
||||
permissions=AgentPermissions(allow_file_write=True),
|
||||
),
|
||||
scratchpad_directory=scratchpad,
|
||||
tool_registry=registry,
|
||||
)
|
||||
json_result = execute_tool(
|
||||
registry,
|
||||
'write_file',
|
||||
{
|
||||
'path': 'output/data.json',
|
||||
'json_content': {'query': '导航到公司', 'target': 'Agent(tag="地图导航")'},
|
||||
'newline_at_end': True,
|
||||
},
|
||||
context,
|
||||
)
|
||||
jsonl_result = execute_tool(
|
||||
registry,
|
||||
'write_file',
|
||||
{
|
||||
'path': 'output/data.jsonl',
|
||||
'jsonl_records': [
|
||||
{'query': '附近的奶茶', 'target': 'Agent(tag="餐饮服务")'},
|
||||
{'query': '导航去海底捞', 'target': 'Agent(tag="地图导航")'},
|
||||
],
|
||||
'newline_at_end': True,
|
||||
},
|
||||
context,
|
||||
)
|
||||
csv_result = execute_tool(
|
||||
registry,
|
||||
'write_file',
|
||||
{
|
||||
'path': 'output/data.csv',
|
||||
'csv_headers': ['query', 'target'],
|
||||
'csv_rows': [
|
||||
['附近的奶茶', 'Agent(tag="餐饮服务")'],
|
||||
['导航去海底捞', 'Agent(tag="地图导航")'],
|
||||
],
|
||||
'newline_at_end': True,
|
||||
},
|
||||
context,
|
||||
)
|
||||
output_dir = scratchpad.parent / 'output'
|
||||
json_text = (output_dir / 'data.json').read_text(encoding='utf-8')
|
||||
jsonl_text = (output_dir / 'data.jsonl').read_text(encoding='utf-8')
|
||||
csv_text = (output_dir / 'data.csv').read_text(encoding='utf-8')
|
||||
|
||||
self.assertTrue(json_result.ok, json_result.content)
|
||||
self.assertTrue(jsonl_result.ok, jsonl_result.content)
|
||||
self.assertTrue(csv_result.ok, csv_result.content)
|
||||
self.assertIn('"query": "导航到公司"', json_text)
|
||||
self.assertEqual(
|
||||
jsonl_text.splitlines()[0],
|
||||
'{"query":"附近的奶茶","target":"Agent(tag=\\"餐饮服务\\")"}',
|
||||
)
|
||||
self.assertIn('query,target', csv_text)
|
||||
|
||||
def test_sleep_tool_waits_briefly_and_returns_metadata(self) -> None:
|
||||
registry = default_tool_registry()
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
|
||||
Reference in New Issue
Block a user