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 <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-07-13 10:10:51 +08:00
parent 61d54b10fd
commit 19f4d285ce
9 changed files with 346 additions and 37 deletions

View file

@ -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,

View file

@ -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()

View file

@ -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",

View file

@ -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"]