diff --git a/frontend/app/components/assistant-ui/activity-panel.tsx b/frontend/app/components/assistant-ui/activity-panel.tsx
index e3339db..1edea5f 100644
--- a/frontend/app/components/assistant-ui/activity-panel.tsx
+++ b/frontend/app/components/assistant-ui/activity-panel.tsx
@@ -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(null);
useEffect(() => {
@@ -248,6 +256,11 @@ export function ActivityPanel() {
) : (
+ {selectedActivityMissing ? (
+
+ 刷新后未能精确定位到这条摘要,已显示当前会话可恢复的活动链路。
+
+ ) : null}
{visibleItems.map((item) => (
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 追加稳定文件;不要反复覆盖同一个中间产物。'
diff --git a/src/agent_runtime.py b/src/agent_runtime.py
index 43c1983..426b83d 100644
--- a/src/agent_runtime.py
+++ b/src/agent_runtime.py
@@ -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 仅作为最后兜底。'
diff --git a/src/agent_tool_specs/files.py b/src/agent_tool_specs/files.py
index 0415045..5c82215 100644
--- a/src/agent_tool_specs/files.py
+++ b/src/agent_tool_specs/files.py
@@ -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',
diff --git a/src/agent_tools.py b/src/agent_tools.py
index f945bc6..fa752ad 100644
--- a/src/agent_tools.py
+++ b/src/agent_tools.py
@@ -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:
diff --git a/tests/test_extended_tools.py b/tests/test_extended_tools.py
index f58516d..6704f94 100644
--- a/tests/test_extended_tools.py
+++ b/tests/test_extended_tools.py
@@ -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: