From 19f4d285ce46b23f4c5f8db9482ba35cc0093fa5 Mon Sep 17 00:00:00 2001 From: oliver Date: Mon, 13 Jul 2026 10:10:51 +0800 Subject: [PATCH] feat(channel): deliver generated files on WhatsApp and honor user tool policy Add save_deliverable_attachment for explicit document outbound, pass channel user_id into tool risk gating so write_file/run_command respect per-user allow_high settings, and document the workflow in channel-file-delivery skill. Co-authored-by: Cursor --- .../application/gateway/inbound_service.py | 46 ++++++++--- runtime/chat/tool_runtime.py | 70 +++++++++------- runtime/orchestration/group_ingest.py | 15 ++++ .../save_deliverable_attachment_tool.py | 81 +++++++++++++++++++ .../public/channel-file-delivery/SKILL.md | 51 ++++++++++++ tests/test_channel_tool_policy.py | 44 ++++++++++ tests/test_inbound_service_reply_suppress.py | 31 +++++++ .../test_save_deliverable_attachment_tool.py | 30 +++++++ tests/test_tool_runtime_attachments.py | 15 ++++ 9 files changed, 346 insertions(+), 37 deletions(-) create mode 100644 runtime/tools/public/save_deliverable_attachment_tool.py create mode 100644 skills/_workspace/public/channel-file-delivery/SKILL.md create mode 100644 tests/test_channel_tool_policy.py create mode 100644 tests/test_save_deliverable_attachment_tool.py diff --git a/runtime/application/gateway/inbound_service.py b/runtime/application/gateway/inbound_service.py index 4be5ce81..6fda6088 100644 --- a/runtime/application/gateway/inbound_service.py +++ b/runtime/application/gateway/inbound_service.py @@ -74,19 +74,29 @@ def _resolve_channel_dispatch(store: Any, *, channel: str, account: dict[str, An return interaction_mode, specialist, lang -def _build_admin_gateway_executor(store: Any, *, tenant_id: str, specialist: str, session_id: str, lang: str) -> Any: +def _build_admin_gateway_executor( + store: Any, + *, + tenant_id: str, + user_id: str | None = None, + viewer_username: str | None = None, + specialist: str, + session_id: str, + lang: str, +) -> Any: from runtime.agents.factory import build_gateway_executor + uid = str(user_id or "").strip() or None return build_gateway_executor( store, lang=str(lang or "zh"), specialist=specialist, - viewer_user_id=None, - viewer_username="administrator", + viewer_user_id=uid, + viewer_username=str(viewer_username or "administrator").strip() or "administrator", viewer_tenant_id=(tenant_id or None), policy_session_id=session_id, path_policy_tenant_id=(tenant_id or None), - path_policy_user_id=None, + path_policy_user_id=uid, ) @@ -559,13 +569,25 @@ def _rows_since_last_user_message(rows: list[Any]) -> list[Any]: _CHANNEL_DELIVERABLE_ATTACHMENT_TYPES = frozenset( {"image_ref", "video_ref", "image", "input_image", "image_url"} ) +_CHANNEL_EXPLICIT_DELIVERABLE_TYPES = frozenset({"binary_ref", "text_ref"}) + + +def _is_channel_deliverable_attachment(att: dict[str, Any]) -> bool: + if not isinstance(att, dict): + return False + t = str(att.get("type") or "").strip().lower() + if t in _CHANNEL_DELIVERABLE_ATTACHMENT_TYPES: + return True + if t in _CHANNEL_EXPLICIT_DELIVERABLE_TYPES: + return att.get("deliverable") is True + return False def _collect_recent_tool_attachments(*, store: Any, session_id: str) -> list[dict[str, Any]]: """Fallback for channel delivery: reuse tool media produced during the current user turn only. Avoids re-sending images from earlier conversation turns when the latest assistant row has no attachments. - Only visual outbound media is eligible — not text_ref/binary_ref from read/query tools. + Visual media is always eligible; documents need explicit deliverable=true on the attachment ref. """ sid = str(session_id or "").strip() if not sid: @@ -582,16 +604,13 @@ def _collect_recent_tool_attachments(*, store: Any, session_id: str) -> list[dic atts = _parse_message_attachments(getattr(row, "attachments", None)) if not atts: continue - ok = False deliverable: list[dict[str, Any]] = [] for a in atts: if not isinstance(a, dict): continue - t = str(a.get("type") or "").strip().lower() - if t in _CHANNEL_DELIVERABLE_ATTACHMENT_TYPES: - ok = True + if _is_channel_deliverable_attachment(a): deliverable.append(a) - if ok: + if deliverable: return deliverable return [] @@ -1064,6 +1083,11 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]: if dispatch_lang in {"zh", "en"} else resolve_runtime_lang(store=store, user_text=user_text) ) + if str(inbound.channel or "").strip().lower() in {"whatsapp", "wechat", "weixin"}: + from runtime.orchestration.group_ingest import build_channel_file_delivery_instruction + + ch_hint = build_channel_file_delivery_instruction(lang=lang) + user_text = f"{ch_hint}\n{user_text}" if user_text else ch_hint gw = OclawGateway(store=store) msg = StandardMessage( session_id=str(session_id), @@ -1090,6 +1114,7 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]: manager = _build_admin_gateway_executor( store, tenant_id=tenant_id, + user_id=user_id, specialist="generalist", session_id=str(session_id), lang=lang, @@ -1097,6 +1122,7 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]: specialist_factory = lambda sid: _build_admin_gateway_executor( store, tenant_id=tenant_id, + user_id=user_id, specialist=sid, session_id=str(session_id), lang=lang, diff --git a/runtime/chat/tool_runtime.py b/runtime/chat/tool_runtime.py index 73c48106..bbecb947 100644 --- a/runtime/chat/tool_runtime.py +++ b/runtime/chat/tool_runtime.py @@ -59,20 +59,30 @@ def _attachments_from_tool_result(result: Any) -> list[dict[str, Any]]: if not isinstance(result, dict): return [] out: list[dict[str, Any]] = [] + root_deliverable = result.get("deliverable") is True + + def _with_deliverable(item: dict[str, Any], src: dict[str, Any] | None = None) -> dict[str, Any]: + if root_deliverable or (isinstance(src, dict) and src.get("deliverable") is True): + item["deliverable"] = True + return item + aid = str(result.get("attachment_id") or "").strip() root_mime = str(result.get("mime") or "").strip() if aid: ref_type = _ref_type_for_mime(root_mime) out.append( - { - "type": ref_type, - "attachment_id": aid, - "name": str(result.get("name") or "generated-image"), - "mime": root_mime or "application/octet-stream", - "bytes": result.get("bytes"), - "width": result.get("width"), - "height": result.get("height"), - } + _with_deliverable( + { + "type": ref_type, + "attachment_id": aid, + "name": str(result.get("name") or "generated-image"), + "mime": root_mime or "application/octet-stream", + "bytes": result.get("bytes"), + "width": result.get("width"), + "height": result.get("height"), + }, + result, + ) ) refs = result.get("attachments") if isinstance(refs, list): @@ -100,15 +110,18 @@ def _attachments_from_tool_result(result: Any) -> list[dict[str, Any]]: if r_typ not in {"image_ref", "video_ref", "text_ref", "binary_ref"}: r_typ = _ref_type_for_mime(r_mime) out.append( - { - "type": r_typ, - "attachment_id": r_aid, - "name": str(r.get("name") or "generated-image"), - "mime": r_mime or "application/octet-stream", - "bytes": r.get("bytes"), - "width": r.get("width"), - "height": r.get("height"), - } + _with_deliverable( + { + "type": r_typ, + "attachment_id": r_aid, + "name": str(r.get("name") or "generated-image"), + "mime": r_mime or "application/octet-stream", + "bytes": r.get("bytes"), + "width": r.get("width"), + "height": r.get("height"), + }, + r, + ) ) inner = result.get("result") if isinstance(inner, dict): @@ -122,15 +135,18 @@ def _attachments_from_tool_result(result: Any) -> list[dict[str, Any]]: a_id = str(item.get("attachment_id") or "").strip() if a_id: out.append( - { - "type": typ, - "attachment_id": a_id, - "mime": str(item.get("mime_type") or item.get("mime") or "application/octet-stream"), - "name": str(item.get("name") or "tool-attachment"), - "bytes": item.get("bytes"), - "width": item.get("width"), - "height": item.get("height"), - } + _with_deliverable( + { + "type": typ, + "attachment_id": a_id, + "mime": str(item.get("mime_type") or item.get("mime") or "application/octet-stream"), + "name": str(item.get("name") or "tool-attachment"), + "bytes": item.get("bytes"), + "width": item.get("width"), + "height": item.get("height"), + }, + item, + ) ) elif typ == "image_url": src = str(item.get("url") or item.get("image_url") or "").strip() diff --git a/runtime/orchestration/group_ingest.py b/runtime/orchestration/group_ingest.py index 7ca47bb7..f8c1653c 100644 --- a/runtime/orchestration/group_ingest.py +++ b/runtime/orchestration/group_ingest.py @@ -370,6 +370,20 @@ def build_group_focus_instruction(*, lang: str = "zh") -> str: return "[群聊规则:只回答当前发言人的问题;除非本条消息明确引用或承接前文,否则不要默认继承其他群成员的上下文。]" +def build_channel_file_delivery_instruction(*, lang: str = "zh") -> str: + if str(lang or "").strip().lower().startswith("en"): + return ( + "[Channel rule: to send a generated file back to the user on WhatsApp/WeChat, " + "call save_deliverable_attachment after creating the file. write_file or run_command alone " + "does not attach files to the outbound message.]" + ) + return ( + "[渠道规则:若要把生成的文件发回用户(WhatsApp/微信)," + "在 write_file 或 run_command 生成文件后必须调用 save_deliverable_attachment;" + "仅 write_file 不会随消息发送附件。]" + ) + + def build_whatsapp_group_reply_metadata( *, inbound: Any, @@ -400,6 +414,7 @@ def build_whatsapp_group_reply_metadata( __all__ = [ "GROUP_SESSION_USER_SENTINEL", "GroupPolicyConfig", + "build_channel_file_delivery_instruction", "build_group_sender_context", "build_group_focus_instruction", "build_group_quoted_context_block", diff --git a/runtime/tools/public/save_deliverable_attachment_tool.py b/runtime/tools/public/save_deliverable_attachment_tool.py new file mode 100644 index 00000000..4fab4137 --- /dev/null +++ b/runtime/tools/public/save_deliverable_attachment_tool.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import mimetypes +from pathlib import Path +from typing import Any + +from runtime.tools.base import ToolSpec +from runtime.tools.path_guard import resolve_workspace_path +from svc.files.attachment_assets import AttachmentAssetStore + + +def _guess_mime(path: Path, override: str) -> str: + if override: + return override + guessed, _ = mimetypes.guess_type(str(path)) + return guessed or "application/octet-stream" + + +def save_deliverable_attachment_tool() -> ToolSpec: + def _handler(args: dict[str, Any]) -> dict[str, Any]: + raw = str(args.get("path") or "").strip().strip('"').strip("'") + if not raw: + return {"ok": False, "error": "path_required"} + display_name = str(args.get("name") or "").strip() + mime_override = str(args.get("mime") or "").strip() + try: + p = resolve_workspace_path(raw) + except ValueError as exc: + return {"ok": False, "error": str(exc)} + if not p.exists() or not p.is_file(): + return {"ok": False, "error": "file_not_found", "path": str(p)} + filename = display_name or p.name + mime = _guess_mime(p, mime_override) + try: + data = p.read_bytes() + except Exception as exc: + return {"ok": False, "error": "read_failed", "detail": str(exc)} + meta = AttachmentAssetStore().save_bytes(data, filename=filename, mime=mime) + return { + "ok": True, + "attachment_id": meta.attachment_id, + "name": meta.name, + "mime": meta.mime, + "bytes": meta.bytes, + "deliverable": True, + } + + return ToolSpec( + name="save_deliverable_attachment", + description=( + "Register a workspace file for outbound channel delivery (WhatsApp/WeChat). " + "Call this after generating a file with write_file or run_command when the user should receive it as an attachment. " + "write_file alone does not send files to messaging channels." + ), + parameters={ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Workspace file path (relative to workspace root or allowed absolute path).", + }, + "name": { + "type": "string", + "description": "Optional download filename shown to the user.", + }, + "mime": { + "type": "string", + "description": "Optional MIME type override (e.g. application/vnd.openxmlformats-officedocument.spreadsheetml.sheet).", + }, + }, + "required": ["path"], + "additionalProperties": False, + }, + handler=_handler, + tags=frozenset({"public", "workspace", "attachment", "channel"}), + read_only=False, + risk_level="low", + ) + + +__all__ = ["save_deliverable_attachment_tool"] diff --git a/skills/_workspace/public/channel-file-delivery/SKILL.md b/skills/_workspace/public/channel-file-delivery/SKILL.md new file mode 100644 index 00000000..b788da04 --- /dev/null +++ b/skills/_workspace/public/channel-file-delivery/SKILL.md @@ -0,0 +1,51 @@ +--- +name: channel-file-delivery +description: "在 WhatsApp/微信等渠道会话中,把生成的文件作为附件发回用户的流程。适用于:导出 Excel/CSV/TXT、run_command 生成报告后需要让用户在聊天里收到文件。" +--- + +# 渠道文件发送 — Channel File Delivery + +## 何时使用 + +用户在 **WhatsApp / 微信** 等渠道对话中,要求你生成并**把文件发给他**(不仅是文字说明路径)。 + +## 关键规则 + +1. **`write_file` / `run_command` 不会自动发送附件** + 文件只会落在 workspace 磁盘上,渠道出站看不到。 + +2. **必须调用 `save_deliverable_attachment`** + 在文件生成完成后,用该工具把 workspace 文件注册到 attachment store,并标记 `deliverable`。 + +3. **再用简短文字回复** + 说明文件名与要点;系统会把标记为 deliverable 的附件随本条回复发到渠道。 + +## 推荐流程 + +```text +1. run_command 或 write_file → 生成 data/workspace/tmp/report.xlsx +2. save_deliverable_attachment(path="data/workspace/tmp/report.xlsx", name="report.xlsx") +3. 文字回复:「已附上 report.xlsx,共 N 行…」 +``` + +## Excel 示例 + +```text +用户:帮我把统计结果导出 Excel 发我 + +步骤: +1. run_command:用 pandas 写入 data/workspace/tmp/summary.xlsx +2. save_deliverable_attachment(path="data/workspace/tmp/summary.xlsx") +3. 回复摘要(行数、主要结论) +``` + +## 限制 + +- 每轮回复通常只发送**第一个**附件(约 8MB 上限)。 +- 超大文件应压缩、抽样或只发摘要。 +- 用户**上传**的文件不要用 `save_deliverable_attachment` 回传;那是分析输入,不是生成输出。 + +## 相关 + +- 路径规范见 `path-convention` skill(`data/workspace/`)。 +- 分析用户上传的表格用 `query_tabular_attachment` 等读工具,与出站发送无关。 diff --git a/tests/test_channel_tool_policy.py b/tests/test_channel_tool_policy.py new file mode 100644 index 00000000..4df44187 --- /dev/null +++ b/tests/test_channel_tool_policy.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from runtime.tools.catalog import materialize_tool_specs +from runtime.tools.public_registry import clear_public_tool_cache + + +def test_materialize_tools_respects_user_allow_high_risk_public_tools(monkeypatch) -> None: + clear_public_tool_cache() + monkeypatch.delenv("AIA_PUBLIC_TOOLS_ALLOW_HIGH", raising=False) + + class _Store: + def get_user_workspace_path_allowlist(self, *, tenant_id: str, user_id: str): + assert tenant_id == "tenant-a" + assert user_id == "user-a" + return {"allow_high_risk_public_tools": True} + + names = { + t.name + for t in materialize_tool_specs( + store=_Store(), + path_policy_tenant_id="tenant-a", + path_policy_user_id="user-a", + ) + } + assert "write_file" in names + assert "run_command" in names + assert "save_deliverable_attachment" in names + + +def test_materialize_tools_blocks_high_risk_without_user_policy(monkeypatch) -> None: + clear_public_tool_cache() + monkeypatch.delenv("AIA_PUBLIC_TOOLS_ALLOW_HIGH", raising=False) + + names = { + t.name + for t in materialize_tool_specs( + store=None, + path_policy_tenant_id="tenant-a", + path_policy_user_id=None, + ) + } + assert "write_file" not in names + assert "run_command" not in names + assert "save_deliverable_attachment" in names diff --git a/tests/test_inbound_service_reply_suppress.py b/tests/test_inbound_service_reply_suppress.py index dbed3938..8852e72c 100644 --- a/tests/test_inbound_service_reply_suppress.py +++ b/tests/test_inbound_service_reply_suppress.py @@ -188,6 +188,37 @@ def test_collect_recent_tool_attachments_ignores_text_ref_from_lookup_tools() -> assert out == [] +def test_collect_recent_tool_attachments_includes_deliverable_binary_ref() -> None: + rows = [ + _Row(role="user", content="export", attachments=None), + _Row( + role="tool", + content="{}", + attachments='[{"type":"binary_ref","attachment_id":"gen-xlsx","name":"report.xlsx","mime":"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet","deliverable":true}]', + ), + _Row(role="assistant", content="done", attachments=None), + ] + out = _collect_recent_tool_attachments(store=_FakeStore(rows), session_id="s1") + assert len(out) == 1 + assert out[0].get("attachment_id") == "gen-xlsx" + assert out[0].get("deliverable") is True + + +def test_collect_recent_tool_attachments_includes_deliverable_text_ref() -> None: + rows = [ + _Row(role="user", content="export", attachments=None), + _Row( + role="tool", + content="{}", + attachments='[{"type":"text_ref","attachment_id":"gen-txt","name":"out.txt","mime":"text/plain","deliverable":true}]', + ), + _Row(role="assistant", content="done", attachments=None), + ] + out = _collect_recent_tool_attachments(store=_FakeStore(rows), session_id="s1") + assert len(out) == 1 + assert out[0].get("attachment_id") == "gen-txt" + + def test_maybe_add_media_path_for_wechat_reply_sets_media_path(monkeypatch) -> None: # Avoid touching disk: stub AttachmentAssetStore.get_local_path. from pathlib import Path diff --git a/tests/test_save_deliverable_attachment_tool.py b/tests/test_save_deliverable_attachment_tool.py new file mode 100644 index 00000000..706959a6 --- /dev/null +++ b/tests/test_save_deliverable_attachment_tool.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from runtime.tools.public.save_deliverable_attachment_tool import save_deliverable_attachment_tool + + +def test_save_deliverable_attachment_registers_file(tmp_path, monkeypatch) -> None: + from svc.files.attachment_assets import AttachmentAssetStore + + store = AttachmentAssetStore(root_dir=tmp_path / "att") + monkeypatch.setattr( + "runtime.tools.public.save_deliverable_attachment_tool.AttachmentAssetStore", + lambda root_dir=None: store if root_dir is None else AttachmentAssetStore(root_dir=root_dir), + ) + monkeypatch.setattr( + "runtime.tools.public.save_deliverable_attachment_tool.resolve_workspace_path", + lambda raw: tmp_path / "report.txt", + ) + report = tmp_path / "report.txt" + report.write_text("hello deliverable\n", encoding="utf-8") + + spec = save_deliverable_attachment_tool() + out = spec.handler({"path": "report.txt"}) + assert out.get("ok") is True + assert out.get("deliverable") is True + assert out.get("attachment_id") + assert out.get("mime") == "text/plain" + blob, meta = store.load_bytes(str(out["attachment_id"])) + assert blob is not None + assert blob.decode("utf-8").replace("\r\n", "\n") == "hello deliverable\n" + assert meta is not None diff --git a/tests/test_tool_runtime_attachments.py b/tests/test_tool_runtime_attachments.py index 56f3a7dd..a93b6aaa 100644 --- a/tests/test_tool_runtime_attachments.py +++ b/tests/test_tool_runtime_attachments.py @@ -3,6 +3,21 @@ from __future__ import annotations from runtime.chat.tool_runtime import _attachments_from_tool_result +def test_attachments_from_tool_result_preserves_deliverable_flag() -> None: + result = { + "ok": True, + "attachment_id": "att-doc-1", + "mime": "text/plain", + "name": "out.txt", + "bytes": 12, + "deliverable": True, + } + out = _attachments_from_tool_result(result) + assert len(out) == 1 + assert out[0]["type"] == "text_ref" + assert out[0].get("deliverable") is True + + def test_attachments_from_tool_result_preserves_non_image_ref_types() -> None: result = { "attachments": [