From 126f35ae463c0cb71f35c129add90747d0a1537c Mon Sep 17 00:00:00 2001 From: wuyang6 Date: Tue, 12 May 2026 20:48:25 +0800 Subject: [PATCH] Improve file writing and activity replay --- .../assistant-ui/activity-panel.tsx | 17 ++++- src/agent_prompting.py | 5 +- src/agent_runtime.py | 3 +- src/agent_tool_specs/files.py | 34 ++++++++-- src/agent_tools.py | 68 +++++++++++++++---- tests/test_extended_tools.py | 66 ++++++++++++++++++ 6 files changed, 171 insertions(+), 22 deletions(-) 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: