diff --git a/agent/tools/code_exec.py b/agent/tools/code_exec.py index ece67d97f..c6f454c2c 100644 --- a/agent/tools/code_exec.py +++ b/agent/tools/code_exec.py @@ -37,6 +37,7 @@ SYSTEM_OUTPUT_KEYS = frozenset( { "content", "actual_type", + "attachments", "_ERROR", "_ARTIFACTS", "_ATTACHMENT_CONTENT", @@ -312,7 +313,10 @@ module.exports = { main }; self.lang = Language.PYTHON.value self.script = 'def main(arg1: str, arg2: str) -> dict: return {"result": arg1 + arg2}' self.arguments = {} - self.outputs = {"result": {"value": "", "type": "object"}} + self.outputs = { + "result": {"value": "", "type": "object"}, + "attachments": {"value": [], "type": "Array"}, + } def check(self): self.check_valid_value(self.lang, "Support languages", ["python", "python3", "nodejs", "javascript"]) @@ -468,11 +472,13 @@ class CodeExec(ToolBase, ABC): self.set_output("_ARTIFACTS", artifact_urls or None) attachment_text = self._build_attachment_content(artifacts, artifact_urls) self.set_output("_ATTACHMENT_CONTENT", attachment_text) + self.set_output("attachments", self._build_attachment_markdown_list(artifact_urls)) if attachment_text: content_parts.append(attachment_text) else: self.set_output("_ARTIFACTS", None) self.set_output("_ATTACHMENT_CONTENT", "") + self.set_output("attachments", []) self.set_output("content", "\n\n".join([part for part in content_parts if part]).strip()) @@ -641,6 +647,23 @@ class CodeExec(ToolBase, ABC): return f"attachment_count: {len(sections)}\n\n" + "\n\n".join(sections) return "attachment_count: 0" + def _build_attachment_markdown_list(self, artifact_urls: list[dict]) -> list[str]: + markdown_items = [] + for art in artifact_urls: + name = _art_field(art, "name") + url = _art_field(art, "url") + mime_type = str(_art_field(art, "mime_type") or "").strip().lower() + if not name: + continue + + if mime_type.startswith("image/") and url: + markdown_items.append(f"![{name}]({url})") + elif url: + markdown_items.append(f"[Download {name}]({url})") + else: + markdown_items.append(name) + return markdown_items + def _normalize_attachment_type(self, name: str, mime_type: str) -> str: mime_type = str(mime_type or "").strip().lower() if mime_type.startswith("image/"): diff --git a/test/testcases/test_web_api/test_canvas_app/test_code_exec_contract_unit.py b/test/testcases/test_web_api/test_canvas_app/test_code_exec_contract_unit.py index ff171c3b0..199210547 100644 --- a/test/testcases/test_web_api/test_canvas_app/test_code_exec_contract_unit.py +++ b/test/testcases/test_web_api/test_canvas_app/test_code_exec_contract_unit.py @@ -140,7 +140,7 @@ def test_select_business_output_ignores_system_outputs(): "actual_type": {"value": "", "type": "string"}, "_ERROR": {"value": "", "type": "string"}, "_ARTIFACTS": {"value": [], "type": "Array"}, - "_ATTACHMENT_CONTENT": {"value": "", "type": "string"}, + "attachments": {"value": [], "type": "Array"}, "raw_result": {"value": None, "type": "Any"}, "_created_time": {"value": 1.0, "type": "Number"}, "_elapsed_time": {"value": 2.0, "type": "Number"}, @@ -297,7 +297,7 @@ def test_legacy_multi_output_schema_is_rejected(): ) -@pytest.mark.parametrize("name", ["content", "actual_type", "_ERROR", "_ARTIFACTS", "_ATTACHMENT_CONTENT", "raw_result"]) +@pytest.mark.parametrize("name", ["content", "actual_type", "attachments", "_ERROR", "_ARTIFACTS", "raw_result"]) def test_reserved_business_output_names_are_rejected(name): module = _load_module() with pytest.raises(module.ContractError, match="reserved output name"): @@ -387,7 +387,6 @@ def test_process_execution_result_returns_early_for_stderr_only_without_artifact def test_process_execution_result_appends_artifact_content_to_canonical_content(): tool = _build_code_exec("Object") tool._upload_artifacts = lambda _artifacts: [{"name": "chart.png", "url": "/artifact/chart.png", "mime_type": "image/png", "size": 12}] - tool._build_attachment_content = lambda _artifacts, _artifact_urls: "attachment_count: 1\n\nattachment1 (image): chart.png\nparsed artifact" result = tool._process_execution_result( '{"foo": "bar"}', @@ -400,8 +399,7 @@ def test_process_execution_result_appends_artifact_content_to_canonical_content( assert result["content"] == '{\n "foo": "bar"\n}\n\nattachment_count: 1\n\nattachment1 (image): chart.png\nparsed artifact' assert result["_ARTIFACTS"] == [{"name": "chart.png", "url": "/artifact/chart.png", "mime_type": "image/png", "size": 12}] assert result["_ARTIFACTS"][0]["mime_type"] == "image/png" - assert result["_ATTACHMENT_CONTENT"] == "attachment_count: 1\n\nattachment1 (image): chart.png\nparsed artifact" - assert "attachment1 (image): chart.png" in result["_ATTACHMENT_CONTENT"] + assert result["attachments"] == ["![chart.png](/artifact/chart.png)"] def test_process_execution_result_without_artifacts_clears_stale_artifacts_output(): diff --git a/web/src/pages/agent/form-sheet/single-debug-sheet/utils.ts b/web/src/pages/agent/form-sheet/single-debug-sheet/utils.ts index a17a8c64a..e01b898f7 100644 --- a/web/src/pages/agent/form-sheet/single-debug-sheet/utils.ts +++ b/web/src/pages/agent/form-sheet/single-debug-sheet/utils.ts @@ -4,6 +4,7 @@ import { CodeOutputContract } from '../../form/code-form/utils'; const SYSTEM_OUTPUT_NAMES = new Set([ '_ERROR', '_ARTIFACTS', + 'attachments', '_ATTACHMENT_CONTENT', ]); diff --git a/web/src/pages/agent/form/code-form/utils.ts b/web/src/pages/agent/form/code-form/utils.ts index 204f1f729..04505a638 100644 --- a/web/src/pages/agent/form/code-form/utils.ts +++ b/web/src/pages/agent/form/code-form/utils.ts @@ -14,6 +14,7 @@ const CodeExecReservedOutputKeys = [ 'content', 'actual_type', 'raw_result', + 'attachments', '_ERROR', '_ARTIFACTS', '_ATTACHMENT_CONTENT', @@ -30,6 +31,10 @@ export const CodeExecPanelSystemOutputs: ICodeForm['outputs'] = { type: 'String', value: '', }, + attachments: { + type: 'Array', + value: [], + }, }; const CodeExecReservedOutputKeySet = new Set( diff --git a/web/src/utils/canvas-util.tsx b/web/src/utils/canvas-util.tsx index 818dc9cf2..611a6a8a0 100644 --- a/web/src/utils/canvas-util.tsx +++ b/web/src/utils/canvas-util.tsx @@ -73,6 +73,10 @@ function getNodeOutputs(x: BaseNode) { type: JsonSchemaDataType.String, value: '', }, + attachments: outputs.attachments ?? { + type: 'Array', + value: [], + }, }; }