feat(channel): unify deliverable outbound and WeChat attachments

Require deliverable:true for all channel attachments including images; extend save_deliverable_attachment with attachment_id; add WeChat proactive attachment delivery; inject channel file rules via system prompt instead of repeating on every user message.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-07-13 15:56:50 +08:00
parent dc5da21f5a
commit cbe68a54cd
14 changed files with 440 additions and 125 deletions

View file

@ -222,8 +222,10 @@ class _IlinkBridge:
chat_id: str, chat_id: str,
text: str, text: str,
context_token: str | None, context_token: str | None,
attachments: list[dict[str, Any]] | None = None,
media_path: str | None = None,
) -> None: ) -> None:
payload = { payload: dict[str, Any] = {
"channel": channel, "channel": channel,
"account_id": account_id, "account_id": account_id,
"chat_id": chat_id, "chat_id": chat_id,
@ -234,6 +236,12 @@ class _IlinkBridge:
"context_token": (context_token or "").strip(), "context_token": (context_token or "").strip(),
"ts": _now_ms(), "ts": _now_ms(),
} }
mp = str(media_path or "").strip()
if mp:
payload["media_path"] = mp
atts = [a for a in (attachments or []) if isinstance(a, dict)]
if atts:
payload["attachments"] = atts
with self._lock: with self._lock:
self._seq += 1 self._seq += 1
event = { event = {
@ -381,7 +389,9 @@ async def _process_inbound_and_enqueue(
ctx_token = _extract_context_token(payload) ctx_token = _extract_context_token(payload)
reply_text = str(item.get("text") or "").strip() reply_text = str(item.get("text") or "").strip()
reply_chat_id = str(item.get("chat_id") or chat_id or user_id).strip() or user_id reply_chat_id = str(item.get("chat_id") or chat_id or user_id).strip() or user_id
if not reply_text: reply_attachments = item.get("attachments") if isinstance(item.get("attachments"), list) else []
reply_media_path = str(item.get("media_path") or item.get("mediaPath") or "").strip()
if not reply_text and not reply_attachments and not reply_media_path:
continue continue
_BRIDGE.enqueue_reply( _BRIDGE.enqueue_reply(
token=token, token=token,
@ -390,6 +400,8 @@ async def _process_inbound_and_enqueue(
chat_id=reply_chat_id, chat_id=reply_chat_id,
text=reply_text, text=reply_text,
context_token=ctx_token, context_token=ctx_token,
attachments=[a for a in reply_attachments if isinstance(a, dict)],
media_path=reply_media_path or None,
) )
@ -570,6 +582,8 @@ def enqueue_weixin_outbound_reply(
text: str, text: str,
context_token: str = "", context_token: str = "",
token: str = "", token: str = "",
attachments: list[dict[str, Any]] | None = None,
media_path: str | None = None,
) -> str: ) -> str:
"""Enqueue a proactive Weixin/WeChat outbound message for the sidecar poll loop.""" """Enqueue a proactive Weixin/WeChat outbound message for the sidecar poll loop."""
tok = str( tok = str(
@ -585,6 +599,8 @@ def enqueue_weixin_outbound_reply(
chat_id=str(chat_id or "").strip(), chat_id=str(chat_id or "").strip(),
text=str(text or ""), text=str(text or ""),
context_token=str(context_token or "").strip(), context_token=str(context_token or "").strip(),
attachments=attachments,
media_path=media_path,
) )
return str(_BRIDGE._seq) return str(_BRIDGE._seq)

View file

@ -566,28 +566,18 @@ def _rows_since_last_user_message(rows: list[Any]) -> list[Any]:
return list(rows[last_user_idx + 1 :]) return list(rows[last_user_idx + 1 :])
_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: def _is_channel_deliverable_attachment(att: dict[str, Any]) -> bool:
"""Channel outbound only sends attachments explicitly marked deliverable=true."""
if not isinstance(att, dict): if not isinstance(att, dict):
return False return False
t = str(att.get("type") or "").strip().lower() return att.get("deliverable") is True
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]]: 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. """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. Avoids re-sending attachments from earlier conversation turns when the latest assistant row has none.
Visual media is always eligible; documents need explicit deliverable=true on the attachment ref. All attachment types (image, video, document) require explicit deliverable=true.
""" """
sid = str(session_id or "").strip() sid = str(session_id or "").strip()
if not sid: if not sid:
@ -1083,11 +1073,6 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]:
if dispatch_lang in {"zh", "en"} if dispatch_lang in {"zh", "en"}
else resolve_runtime_lang(store=store, user_text=user_text) 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) gw = OclawGateway(store=store)
msg = StandardMessage( msg = StandardMessage(
session_id=str(session_id), session_id=str(session_id),

View file

@ -467,6 +467,17 @@ class OclawGateway:
return True return True
return False return False
@staticmethod
def _is_channel_delivery_channel(msg: StandardMessage) -> bool:
ch = str(getattr(msg, "channel", "") or "").strip().lower()
return ch in {"whatsapp", "wechat", "weixin"}
@staticmethod
def _channel_file_delivery_system_hint(lang: str) -> str:
from runtime.orchestration.group_ingest import build_channel_file_delivery_instruction
return str(build_channel_file_delivery_instruction(lang=lang) or "").strip()
@staticmethod @staticmethod
def _tabular_query_system_hint(lang: str) -> str: def _tabular_query_system_hint(lang: str) -> str:
limits = OclawGateway._tabular_limits_from_config() limits = OclawGateway._tabular_limits_from_config()
@ -1159,6 +1170,10 @@ class OclawGateway:
if model is None or tools is None: if model is None or tools is None:
raise RuntimeError("executor missing model/tools") raise RuntimeError("executor missing model/tools")
sys_prompt = system_prompt_override or str(getattr(selected_executor, "system_prompt", "") or "") sys_prompt = system_prompt_override or str(getattr(selected_executor, "system_prompt", "") or "")
if self._is_channel_delivery_channel(msg):
ch_hint = self._channel_file_delivery_system_hint(lang)
if ch_hint:
sys_prompt = f"{sys_prompt}\n\n{ch_hint}".strip()
if self._has_tabular_ref_attachments(msg): if self._has_tabular_ref_attachments(msg):
sys_prompt = f"{sys_prompt}\n\n{self._tabular_query_system_hint(lang)}".strip() sys_prompt = f"{sys_prompt}\n\n{self._tabular_query_system_hint(lang)}".strip()
if self._has_text_ref_attachments(msg): if self._has_text_ref_attachments(msg):

View file

@ -235,7 +235,10 @@ async function flushWeixinDbOutbound(args: {
const toUser = String(item.chat_id || "").trim(); const toUser = String(item.chat_id || "").trim();
const text = String(item.text || "").trim(); const text = String(item.text || "").trim();
const msgId = String(item.id || "").trim(); const msgId = String(item.id || "").trim();
if (!toUser || !text || !msgId) continue; const mediaPath = String(item.media_path || item.mediaPath || "").trim();
const attachments = Array.isArray(item.attachments) ? (item.attachments as Json[]) : [];
if (!toUser || !msgId) continue;
if (!text && !mediaPath && !attachments.length) continue;
const contextToken = String( const contextToken = String(
(item.context_token as string) || (item.context_token as string) ||
args.modules.getContextToken(args.accountId, toUser) || args.modules.getContextToken(args.accountId, toUser) ||
@ -247,14 +250,20 @@ async function flushWeixinDbOutbound(args: {
continue; continue;
} }
try { try {
await args.modules.sendMessageWeixin({ await deliverWeixinReply(args.modules, {
to: toUser, reply: {
text, chat_id: toUser,
opts: { text,
baseUrl: args.cloudBaseUrl, media_path: mediaPath,
token: args.token, attachments,
contextToken, context_token: contextToken,
}, },
defaultTo: toUser,
cloudBaseUrl: args.cloudBaseUrl,
token: args.token,
accountId: args.accountId,
defaultContextToken: contextToken,
userContextTokens: args.userContextTokens,
}); });
await postLocalJson(args.token, "weixin/outbound/ack", { id: msgId, ok: true }, 8000); await postLocalJson(args.token, "weixin/outbound/ack", { id: msgId, ok: true }, 8000);
log(`db proactive reply sent: id=${msgId} to=${toUser} textLen=${text.length}`); log(`db proactive reply sent: id=${msgId} to=${toUser} textLen=${text.length}`);
@ -307,7 +316,10 @@ async function flushLocalProactiveReplies(args: {
for (const r of msgs) { for (const r of msgs) {
const toUser = String(r.chat_id || "").trim(); const toUser = String(r.chat_id || "").trim();
const text = String(r.text || "").trim(); const text = String(r.text || "").trim();
if (!toUser || !text) continue; const mediaPath = String(r.media_path || r.mediaPath || "").trim();
const attachments = Array.isArray(r.attachments) ? (r.attachments as Json[]) : [];
if (!toUser) continue;
if (!text && !mediaPath && !attachments.length) continue;
const contextToken = String( const contextToken = String(
(r.context_token as string) || (r.context_token as string) ||
args.modules.getContextToken(args.accountId, toUser) || args.modules.getContextToken(args.accountId, toUser) ||
@ -320,14 +332,20 @@ async function flushLocalProactiveReplies(args: {
continue; continue;
} }
try { try {
await args.modules.sendMessageWeixin({ await deliverWeixinReply(args.modules, {
to: toUser, reply: {
text, chat_id: toUser,
opts: { text,
baseUrl: args.cloudBaseUrl, media_path: mediaPath,
token: args.token, attachments,
contextToken, context_token: contextToken,
}, },
defaultTo: toUser,
cloudBaseUrl: args.cloudBaseUrl,
token: args.token,
accountId: args.accountId,
defaultContextToken: contextToken,
userContextTokens: args.userContextTokens,
}); });
log(`proactive reply sent: to=${toUser} textLen=${text.length}`); log(`proactive reply sent: to=${toUser} textLen=${text.length}`);
} catch (err) { } catch (err) {
@ -450,16 +468,45 @@ function pickDownloadableMedia(msg: Json): Json | null {
return null; return null;
} }
function buildAttachmentsFromMedia(mediaOpts: Json): Json[] { function attachmentNameFromMediaItem(item: Json | null, kind: string): string {
if (!item || typeof item !== "object") return "";
if (kind === "file") {
const fileItem = item.file_item;
if (fileItem && typeof fileItem === "object") {
return String((fileItem as Json).file_name || (fileItem as Json).filename || (fileItem as Json).name || "").trim();
}
}
if (kind === "image") {
const imageItem = item.image_item;
if (imageItem && typeof imageItem === "object") {
return String((imageItem as Json).file_name || (imageItem as Json).filename || "").trim();
}
}
return "";
}
function buildAttachmentsFromMedia(mediaOpts: Json, mediaItem?: Json | null): Json[] {
const out: Json[] = []; const out: Json[] = [];
const pushIf = (key: string, mimeKey: string, kind: string): void => { const pushIf = (key: string, mimeKey: string, kind: string): void => {
const filePath = String(mediaOpts[key] || "").trim(); const filePath = String(mediaOpts[key] || "").trim();
if (!filePath) return; if (!filePath) return;
out.push({ const name = String(
mediaOpts.fileOriginalName ||
mediaOpts.originalFilename ||
mediaOpts.file_name ||
attachmentNameFromMediaItem(mediaItem || null, kind) ||
"",
).trim();
const att: Json = {
kind, kind,
local_path: filePath, local_path: filePath,
media_type: String(mediaOpts[mimeKey] || "").trim(), media_type: String(mediaOpts[mimeKey] || "").trim(),
}); };
if (name) {
att.name = name;
att.filename = name;
}
out.push(att);
}; };
pushIf("decryptedPicPath", "", "image"); pushIf("decryptedPicPath", "", "image");
pushIf("decryptedVideoPath", "", "video"); pushIf("decryptedVideoPath", "", "video");
@ -468,6 +515,70 @@ function buildAttachmentsFromMedia(mediaOpts: Json): Json[] {
return out; return out;
} }
async function deliverWeixinReply(
modules: OfficialModules,
params: {
reply: Json;
defaultTo: string;
cloudBaseUrl: string;
token: string;
accountId: string;
defaultContextToken?: string;
userContextTokens?: TokenMap;
},
): Promise<boolean> {
const reply = params.reply;
const text = String(reply.text || "").trim();
const mediaPath = String(reply.media_path || reply.mediaPath || "").trim();
const mediaUrl = String(reply.media_url || reply.mediaUrl || "").trim();
const deliverTo = String(reply.chat_id || reply.to || params.defaultTo).trim() || params.defaultTo;
const replyContextToken = String(
reply.context_token ||
modules.getContextToken(params.accountId, deliverTo) ||
params.defaultContextToken ||
params.userContextTokens?.[deliverTo] ||
"",
).trim();
const replyAtts = Array.isArray(reply.attachments) ? (reply.attachments as Json[]) : [];
let inlineMediaPath = "";
for (const att of replyAtts) {
if (!att || typeof att !== "object") continue;
const decoded = _decodeReplyBase64Attachment(att);
if (decoded?.filePath) {
inlineMediaPath = decoded.filePath;
break;
}
}
if (mediaPath || mediaUrl || inlineMediaPath) {
const filePath = mediaPath || mediaUrl || inlineMediaPath;
await modules.sendWeixinMediaFile({
filePath,
to: deliverTo,
text,
opts: {
baseUrl: params.cloudBaseUrl,
token: params.token,
contextToken: replyContextToken || undefined,
},
cdnBaseUrl: DEFAULT_CDN_BASE_URL,
});
log(`reply media sent to=${deliverTo} textLen=${text.length}`);
return true;
}
if (!text) return false;
await modules.sendMessageWeixin({
to: deliverTo,
text,
opts: {
baseUrl: params.cloudBaseUrl,
token: params.token,
contextToken: replyContextToken || undefined,
},
});
log(`reply text sent to=${deliverTo} textLen=${text.length}`);
return true;
}
function _decodeReplyBase64Attachment(att: Json): { filePath: string; mime: string } | null { function _decodeReplyBase64Attachment(att: Json): { filePath: string; mime: string } | null {
const b64Raw = String( const b64Raw = String(
att.data_base64 || att.media_base64 || att.image_base64 || att.video_base64 || att.audio_base64 || att.data || "", att.data_base64 || att.media_base64 || att.image_base64 || att.video_base64 || att.audio_base64 || att.data || "",
@ -551,7 +662,8 @@ async function handleInboundMessage(
} }
const ctx = modules.weixinMessageToMsgContext(params.full, params.accountId, mediaOpts); const ctx = modules.weixinMessageToMsgContext(params.full, params.accountId, mediaOpts);
const bodyText = String((ctx as Json).Body || "").trim(); const bodyText = String((ctx as Json).Body || "").trim();
const attachCount = buildAttachmentsFromMedia(mediaOpts).length; const inboundAttachments = buildAttachmentsFromMedia(mediaOpts, mediaItem);
const attachCount = inboundAttachments.length;
log(`native call bodyLen=${bodyText.length} attachments=${attachCount}`); log(`native call bodyLen=${bodyText.length} attachments=${attachCount}`);
let replies: Json[] = []; let replies: Json[] = [];
const tNative0 = Date.now(); const tNative0 = Date.now();
@ -560,7 +672,7 @@ async function handleInboundMessage(
channel: "wechat", channel: "wechat",
account_id: params.accountId, account_id: params.accountId,
ctx, ctx,
attachments: buildAttachmentsFromMedia(mediaOpts), attachments: inboundAttachments,
metadata: { metadata: {
source: "weixin_official_native", source: "weixin_official_native",
raw: { raw: {
@ -586,50 +698,15 @@ async function handleInboundMessage(
return params.localCursor; return params.localCursor;
} }
for (const reply of replies) { for (const reply of replies) {
const text = String(reply.text || "").trim(); await deliverWeixinReply(modules, {
const mediaPath = String(reply.media_path || reply.mediaPath || "").trim(); reply,
const mediaUrl = String(reply.media_url || reply.mediaUrl || "").trim(); defaultTo: fromUser,
const deliverTo = String(reply.chat_id || reply.to || fromUser).trim() || fromUser; cloudBaseUrl: params.cloudBaseUrl,
const replyContextToken = String( token: params.token,
reply.context_token || modules.getContextToken(params.accountId, deliverTo) || contextToken || "", accountId: params.accountId,
).trim(); defaultContextToken: contextToken || undefined,
const replyAtts = Array.isArray(reply.attachments) ? (reply.attachments as Json[]) : []; userContextTokens: params.userContextTokens,
let inlineMediaPath = "";
for (const att of replyAtts) {
if (!att || typeof att !== "object") continue;
const decoded = _decodeReplyBase64Attachment(att);
if (decoded?.filePath) {
inlineMediaPath = decoded.filePath;
break;
}
}
if (mediaPath || mediaUrl || inlineMediaPath) {
const filePath = mediaPath || mediaUrl || inlineMediaPath;
await modules.sendWeixinMediaFile({
filePath,
to: deliverTo,
text,
opts: {
baseUrl: params.cloudBaseUrl,
token: params.token,
contextToken: replyContextToken || undefined,
},
cdnBaseUrl: DEFAULT_CDN_BASE_URL,
});
log(`reply media sent to=${deliverTo} textLen=${text.length}`);
continue;
}
if (!text) continue;
await modules.sendMessageWeixin({
to: deliverTo,
text,
opts: {
baseUrl: params.cloudBaseUrl,
token: params.token,
contextToken: replyContextToken || undefined,
},
}); });
log(`reply text sent to=${deliverTo} textLen=${text.length}`);
} }
return params.localCursor; return params.localCursor;
} }

View file

@ -373,14 +373,14 @@ def build_group_focus_instruction(*, lang: str = "zh") -> str:
def build_channel_file_delivery_instruction(*, lang: str = "zh") -> str: def build_channel_file_delivery_instruction(*, lang: str = "zh") -> str:
if str(lang or "").strip().lower().startswith("en"): if str(lang or "").strip().lower().startswith("en"):
return ( return (
"[Channel rule: to send a generated file back to the user on WhatsApp/WeChat, " "[Channel rule: to send any generated attachment back to the user on WhatsApp/WeChat "
"call save_deliverable_attachment after creating the file. write_file or run_command alone " "(documents, images, videos), call save_deliverable_attachment with path or attachment_id. "
"does not attach files to the outbound message.]" "write_file, run_command, and image generation alone do not attach files to the outbound message.]"
) )
return ( return (
"[渠道规则:若要把生成的文件发回用户(WhatsApp/微信)," "[渠道规则:若要把生成的附件发回用户(WhatsApp/微信,含文档/图片/视频),"
"在 write_file 或 run_command 生成文件后必须调用 save_deliverable_attachment;" "必须调用 save_deliverable_attachment(path 或 attachment_id);"
"仅 write_file 不会随消息发送附件。]" "write_file、run_command、生图工具 alone 不会随消息发送附件。]"
) )

View file

@ -8,11 +8,23 @@ from runtime.orchestration.group_ingest import is_nonsend_channel_reply_text, sh
from runtime.scheduler.session_resolver import parse_delivery_json from runtime.scheduler.session_resolver import parse_delivery_json
def _encode_weixin_outbound_source(*, context_token: str) -> str: def _encode_weixin_outbound_source(
return json.dumps( *,
{"kind": "scheduled_job", "context_token": str(context_token or "").strip()}, context_token: str,
ensure_ascii=False, attachments: list[dict[str, Any]] | None = None,
) media_path: str | None = None,
) -> str:
payload: dict[str, Any] = {
"kind": "scheduled_job",
"context_token": str(context_token or "").strip(),
}
atts = [a for a in (attachments or []) if isinstance(a, dict)]
if atts:
payload["attachments"] = atts
mp = str(media_path or "").strip()
if mp:
payload["media_path"] = mp
return json.dumps(payload, ensure_ascii=False)
def _decode_weixin_outbound_source(raw: str) -> dict[str, Any]: def _decode_weixin_outbound_source(raw: str) -> dict[str, Any]:
@ -35,6 +47,8 @@ def enqueue_weixin_reply(
context_token: str = "", context_token: str = "",
store: Any = None, store: Any = None,
tenant_id: str = "", tenant_id: str = "",
attachments: list[dict[str, Any]] | None = None,
media_path: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
from runtime.scheduler.weixin_delivery import normalize_weixin_channel from runtime.scheduler.weixin_delivery import normalize_weixin_channel
@ -57,7 +71,11 @@ def enqueue_weixin_reply(
text=str(text or ""), text=str(text or ""),
tenant_id=str(tenant_id or ""), tenant_id=str(tenant_id or ""),
account_id=str(account_id or "").strip(), account_id=str(account_id or "").strip(),
source=_encode_weixin_outbound_source(context_token=ctx_tok), source=_encode_weixin_outbound_source(
context_token=ctx_tok,
attachments=attachments,
media_path=media_path,
),
) )
or "" or ""
).strip() ).strip()
@ -84,6 +102,8 @@ def enqueue_weixin_reply(
chat_id=str(chat_id or "").strip(), chat_id=str(chat_id or "").strip(),
text=text, text=text,
context_token=ctx_tok, context_token=ctx_tok,
attachments=attachments,
media_path=media_path,
) )
return { return {
"ok": True, "ok": True,

View file

@ -18,13 +18,32 @@ def _guess_mime(path: Path, override: str) -> str:
def save_deliverable_attachment_tool() -> ToolSpec: def save_deliverable_attachment_tool() -> ToolSpec:
def _handler(args: dict[str, Any]) -> dict[str, Any]: def _handler(args: dict[str, Any]) -> dict[str, Any]:
raw = str(args.get("path") or "").strip().strip('"').strip("'") attachment_id = str(args.get("attachment_id") or "").strip().lower()
if not raw: raw_path = str(args.get("path") or "").strip().strip('"').strip("'")
return {"ok": False, "error": "path_required"}
display_name = str(args.get("name") or "").strip() display_name = str(args.get("name") or "").strip()
mime_override = str(args.get("mime") or "").strip() mime_override = str(args.get("mime") or "").strip()
if attachment_id:
store = AttachmentAssetStore()
meta = store.get_meta(attachment_id)
if meta is None:
return {"ok": False, "error": "attachment_not_found", "attachment_id": attachment_id}
name = display_name or meta.name
return {
"ok": True,
"attachment_id": meta.attachment_id,
"name": name,
"mime": mime_override or meta.mime,
"bytes": meta.bytes,
"width": meta.width,
"height": meta.height,
"deliverable": True,
}
if not raw_path:
return {"ok": False, "error": "path_or_attachment_id_required"}
try: try:
p = resolve_workspace_path(raw) p = resolve_workspace_path(raw_path)
except ValueError as exc: except ValueError as exc:
return {"ok": False, "error": str(exc)} return {"ok": False, "error": str(exc)}
if not p.exists() or not p.is_file(): if not p.exists() or not p.is_file():
@ -48,9 +67,11 @@ def save_deliverable_attachment_tool() -> ToolSpec:
return ToolSpec( return ToolSpec(
name="save_deliverable_attachment", name="save_deliverable_attachment",
description=( description=(
"Register a workspace file for outbound channel delivery (WhatsApp/WeChat). " "Mark an attachment 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. " "Required before the user receives any generated file, image, or video in a messaging channel. "
"write_file alone does not send files to messaging channels." "Use path for workspace files (after write_file or run_command), or attachment_id for assets "
"already in the attachment store (e.g. after cloudflare_image_generate). "
"Generating content alone does not send attachments to the channel."
), ),
parameters={ parameters={
"type": "object", "type": "object",
@ -59,16 +80,19 @@ def save_deliverable_attachment_tool() -> ToolSpec:
"type": "string", "type": "string",
"description": "Workspace file path (relative to workspace root or allowed absolute path).", "description": "Workspace file path (relative to workspace root or allowed absolute path).",
}, },
"attachment_id": {
"type": "string",
"description": "Existing attachment_id from image/video generation tools.",
},
"name": { "name": {
"type": "string", "type": "string",
"description": "Optional download filename shown to the user.", "description": "Optional download filename shown to the user.",
}, },
"mime": { "mime": {
"type": "string", "type": "string",
"description": "Optional MIME type override (e.g. application/vnd.openxmlformats-officedocument.spreadsheetml.sheet).", "description": "Optional MIME type override.",
}, },
}, },
"required": ["path"],
"additionalProperties": False, "additionalProperties": False,
}, },
handler=_handler, handler=_handler,

View file

@ -1,34 +1,42 @@
--- ---
name: channel-file-delivery name: channel-file-delivery
description: "在 WhatsApp/微信等渠道会话中,把生成的文件作为附件发回用户。区分:write_file 仅文本(csv/txt);xlsx 等二进制用 run_command + save_deliverable_attachment。" description: "在 WhatsApp/微信等渠道会话中,把生成的附件发回用户。所有类型(文档/图片/视频)均需 save_deliverable_attachment(path 或 attachment_id)。"
--- ---
# 渠道文件发送 — Channel File Delivery # 渠道文件发送 — Channel File Delivery
## 何时使用 ## 何时使用
用户在 **WhatsApp / 微信** 等渠道对话中,要求你生成并**把文件发给他**(不仅是文字说明路径)。 用户在 **WhatsApp / 微信(wechat/weixin)** 等渠道对话中,要求你生成并**把附件发给他**(文档、图片、视频等,不仅是文字说明路径)。渠道附件规则在系统提示中已注入,无需在每轮用户消息里重复。
## 关键规则 ## 关键规则(统一)
1. **`write_file` / `run_command` 不会自动发送附件** 1. **生成工具不会自动发送附件**
文件只会落在 workspace 磁盘上,渠道出站看不到。 `write_file`、`run_command`、`cloudflare_image_generate` 等只产生内容,渠道出站看不到。
2. **必须调用 `save_deliverable_attachment`** 2. **必须调用 `save_deliverable_attachment`**
在文件生成完成后,用该工具把 workspace 文件注册到 attachment store,并标记 `deliverable`。 生成完成后用该工具标记 `deliverable`,系统才会随回复发送。
3. **再用简短文字回复** 3. **再用简短文字回复**
说明文件名与要点;系统会把标记为 deliverable 的附件随本条回复发到渠道。 说明文件名或要点。
| 生成方式 | `save_deliverable_attachment` 参数 |
|----------|--------------------------------------|
| workspace 文件(csv/txt/xlsx) | `path="data/workspace/..."` |
| 生图/生视频(已有 attachment_id) | `attachment_id="..."` |
**用户上传的文件不要回传**;那是分析输入,不是生成输出。
## 工具选择(文本 vs 二进制) ## 工具选择(文本 vs 二进制)
| 目标格式 | 用哪个工具 | 说明 | | 目标格式 | 用哪个工具 | 说明 |
|----------|------------|------| |----------|------------|------|
| `.csv`、`.txt`、`.md`、`.json` 等纯文本 | `write_file` | 只能写文本内容,不能生成二进制 | | `.csv`、`.txt`、`.md`、`.json` 等纯文本 | `write_file` | 只能写文本内容 |
| `.xlsx`、`.xls` 等 Excel | `run_command` | 用 Python(`openpyxl` 已安装)在 workspace 里生成 | | `.xlsx`、`.xls` 等 Excel | `run_command` | 用 Python(`openpyxl` 已安装) |
| 其他需脚本/命令产出的文件 | `run_command` | 生成后再走 deliverable 流程 | | 图片 | `cloudflare_image_generate` 等 | 生成后用 `attachment_id` 标记 |
| 视频 | 对应生成工具 | 生成后用 `attachment_id` 标记 |
**不要声称没有 `run_command`。** 若用户要真 `.xlsx`,优先用 `run_command` 生成,不要只用 `write_file` 写 CSV 再说是「限制」。 **不要声称没有 `run_command`。** 用户要真 `.xlsx` 时优先用 `run_command`,不要只写 CSV 代替。
## 推荐流程 ## 推荐流程
@ -37,7 +45,7 @@ description: "在 WhatsApp/微信等渠道会话中,把生成的文件作为
```text ```text
1. write_file → data/workspace/tmp/report.csv 1. write_file → data/workspace/tmp/report.csv
2. save_deliverable_attachment(path="data/workspace/tmp/report.csv", name="report.csv") 2. save_deliverable_attachment(path="data/workspace/tmp/report.csv", name="report.csv")
3. 文字回复:「已附上 report.csv…」 3. 文字回复
``` ```
**Excel(xlsx):** **Excel(xlsx):**
@ -45,7 +53,15 @@ description: "在 WhatsApp/微信等渠道会话中,把生成的文件作为
```text ```text
1. run_command → python 写入 data/workspace/tmp/report.xlsx 1. run_command → python 写入 data/workspace/tmp/report.xlsx
2. save_deliverable_attachment(path="data/workspace/tmp/report.xlsx", name="report.xlsx") 2. save_deliverable_attachment(path="data/workspace/tmp/report.xlsx", name="report.xlsx")
3. 文字回复:「已附上 report.xlsx…」 3. 文字回复
```
**图片:**
```text
1. cloudflare_image_generate → 得到 attachment_id
2. save_deliverable_attachment(attachment_id="...")
3. 文字回复
``` ```
## Excel 示例 ## Excel 示例
@ -57,16 +73,13 @@ description: "在 WhatsApp/微信等渠道会话中,把生成的文件作为
1. run_command(示例): 1. run_command(示例):
python -c "from openpyxl import Workbook; wb=Workbook(); ws=wb.active; ws.append(['姓名','数量']); ws.append(['A',10]); wb.save('data/workspace/tmp/summary.xlsx')" python -c "from openpyxl import Workbook; wb=Workbook(); ws=wb.active; ws.append(['姓名','数量']); ws.append(['A',10]); wb.save('data/workspace/tmp/summary.xlsx')"
2. save_deliverable_attachment(path="data/workspace/tmp/summary.xlsx", name="summary.xlsx") 2. save_deliverable_attachment(path="data/workspace/tmp/summary.xlsx", name="summary.xlsx")
3. 回复摘要(行数、主要结论) 3. 回复摘要
``` ```
用户明确要 `.xlsx` 时,不要退化成只发 CSV,除非用户同意用 CSV 代替。
## 限制 ## 限制
- 每轮回复通常只发送**第一个**附件(约 8MB 上限)。 - 每轮回复通常只发送**第一个** deliverable 附件(约 8MB 上限)。
- 超大文件应压缩、抽样或只发摘要。 - 超大文件应压缩、抽样或只发摘要。
- 用户**上传**的文件不要用 `save_deliverable_attachment` 回传;那是分析输入,不是生成输出。
## 相关 ## 相关

View file

@ -6458,6 +6458,8 @@ class SqliteStore(ScheduledJobStoreMixin):
"text": str(r["text"] or ""), "text": str(r["text"] or ""),
"source": source, "source": source,
"context_token": str(meta.get("context_token") or ""), "context_token": str(meta.get("context_token") or ""),
"attachments": [a for a in (meta.get("attachments") or []) if isinstance(a, dict)],
"media_path": str(meta.get("media_path") or ""),
"created_at": str(r["created_at"] or ""), "created_at": str(r["created_at"] or ""),
} }
) )

View file

@ -151,15 +151,30 @@ def test_collect_reply_attachments_does_not_reuse_stale_images_on_text_only_repl
assert out == [] assert out == []
def test_collect_recent_tool_attachments_falls_back_to_tool_media() -> None: def test_collect_recent_tool_attachments_ignores_undeliverable_image_ref() -> None:
rows = [ rows = [
_Row(role="user", content="draw", attachments=None), _Row(role="user", content="draw", attachments=None),
_Row(role="tool", content="{}", attachments='[{"type":"image_ref","attachment_id":"a1"}]'), _Row(role="tool", content="{}", attachments='[{"type":"image_ref","attachment_id":"a1"}]'),
_Row(role="assistant", content="x", attachments=None), _Row(role="assistant", content="x", attachments=None),
] ]
out = _collect_recent_tool_attachments(store=_FakeStore(rows), session_id="s1") out = _collect_recent_tool_attachments(store=_FakeStore(rows), session_id="s1")
assert out == []
def test_collect_recent_tool_attachments_includes_deliverable_image_ref() -> None:
rows = [
_Row(role="user", content="draw", attachments=None),
_Row(
role="tool",
content="{}",
attachments='[{"type":"image_ref","attachment_id":"a1","mime":"image/png","deliverable":true}]',
),
_Row(role="assistant", content="x", attachments=None),
]
out = _collect_recent_tool_attachments(store=_FakeStore(rows), session_id="s1")
assert len(out) == 1 assert len(out) == 1
assert out[0].get("attachment_id") == "a1" assert out[0].get("attachment_id") == "a1"
assert out[0].get("deliverable") is True
def test_collect_recent_tool_attachments_ignores_media_from_prior_turn() -> None: def test_collect_recent_tool_attachments_ignores_media_from_prior_turn() -> None:
@ -280,3 +295,37 @@ def test_maybe_expand_reply_attachments_for_channel_works_for_whatsapp(monkeypat
assert isinstance(out, list) and len(out) == 1 assert isinstance(out, list) and len(out) == 1
assert out[0].get("data_base64") == base64.b64encode(b"wa").decode("ascii") assert out[0].get("data_base64") == base64.b64encode(b"wa").decode("ascii")
def test_maybe_expand_deliverable_xlsx_for_weixin_channel(monkeypatch) -> None:
import base64
class _Meta:
mime = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
name = "report.xlsx"
def _fake_load_bytes(self, attachment_id: str): # noqa: ANN001
assert attachment_id == "x1"
return b"xlsx-bytes", _Meta()
monkeypatch.setattr(
"svc.files.attachment_assets.AttachmentAssetStore.load_bytes",
_fake_load_bytes,
)
r = {
"channel": "weixin",
"attachments": [
{
"type": "binary_ref",
"attachment_id": "x1",
"name": "report.xlsx",
"mime": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"deliverable": True,
}
],
}
_maybe_expand_reply_attachments_for_channel(r)
out = r.get("attachments")
assert isinstance(out, list) and len(out) == 1
assert out[0].get("data_base64") == base64.b64encode(b"xlsx-bytes").decode("ascii")
assert out[0].get("name") == "report.xlsx"

View file

@ -925,3 +925,32 @@ def test_tabular_system_hint_uses_configured_preview_and_rows_read(
assert "5000行" in zh_hint assert "5000行" in zh_hint
assert "first 30 preview rows" in en_hint assert "first 30 preview rows" in en_hint
assert "capped at 5000 rows" in en_hint assert "capped at 5000 rows" in en_hint
def test_channel_file_delivery_hint_goes_to_system_not_user_message() -> None:
from runtime.types import StandardMessage
zh_hint = OclawGateway._channel_file_delivery_system_hint("zh")
assert "save_deliverable_attachment" in zh_hint
msg = StandardMessage(
session_id="s1",
tenant_id="t1",
user_id="u1",
role="member",
channel="weixin",
text="hi",
attachments=[],
metadata={},
)
assert OclawGateway._is_channel_delivery_channel(msg)
msg_admin = StandardMessage(
session_id="s1",
tenant_id="t1",
user_id="u1",
role="member",
channel="admin",
text="hi",
attachments=[],
metadata={},
)
assert not OclawGateway._is_channel_delivery_channel(msg_admin)

View file

@ -28,3 +28,28 @@ def test_save_deliverable_attachment_registers_file(tmp_path, monkeypatch) -> No
assert blob is not None assert blob is not None
assert blob.decode("utf-8").replace("\r\n", "\n") == "hello deliverable\n" assert blob.decode("utf-8").replace("\r\n", "\n") == "hello deliverable\n"
assert meta is not None assert meta is not None
def test_save_deliverable_attachment_marks_existing_attachment_id(tmp_path, monkeypatch) -> None:
from svc.files.attachment_assets import AttachmentAssetStore
store = AttachmentAssetStore(root_dir=tmp_path / "att")
meta = store.save_bytes(b"png-bytes", filename="gen.png", mime="image/png")
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),
)
spec = save_deliverable_attachment_tool()
out = spec.handler({"attachment_id": meta.attachment_id})
assert out.get("ok") is True
assert out.get("deliverable") is True
assert out.get("attachment_id") == meta.attachment_id
assert out.get("mime") == "image/png"
def test_save_deliverable_attachment_requires_path_or_attachment_id() -> None:
spec = save_deliverable_attachment_tool()
out = spec.handler({})
assert out.get("ok") is False
assert out.get("error") == "path_or_attachment_id_required"

View file

@ -0,0 +1,49 @@
from __future__ import annotations
import json
import tempfile
import unittest
from pathlib import Path
from runtime.scheduler.channel_delivery import _encode_weixin_outbound_source, _decode_weixin_outbound_source
from svc.persistence.sqlite_store import SqliteStore
class WeixinAttachmentDeliveryTests(unittest.TestCase):
def test_encode_decode_weixin_outbound_source_with_attachments(self) -> None:
atts = [{"name": "report.xlsx", "mime": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "data_base64": "abc"}]
raw = _encode_weixin_outbound_source(context_token="ctx-1", attachments=atts, media_path="")
data = _decode_weixin_outbound_source(raw)
self.assertEqual(data.get("context_token"), "ctx-1")
self.assertEqual(data.get("attachments"), atts)
def test_list_pending_weixin_outbound_includes_attachments_from_source(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
db = Path(tmp) / "t.db"
store = SqliteStore(str(db))
source = json.dumps(
{
"kind": "scheduled_job",
"context_token": "ctx-9",
"attachments": [{"name": "a.txt", "data_base64": "dGVzdA=="}],
"media_path": "D:/tmp/a.txt",
},
ensure_ascii=False,
)
store.enqueue_channel_outbound_message(
channel="weixin",
chat_id="wx-user-1",
text="file attached",
tenant_id="tenant-a",
account_id="acct-1",
source=source,
)
items = store.list_pending_weixin_outbound_messages(account_id="acct-1", limit=5)
self.assertEqual(len(items), 1)
self.assertEqual(items[0].get("context_token"), "ctx-9")
self.assertEqual(len(items[0].get("attachments") or []), 1)
self.assertEqual(items[0].get("media_path"), "D:/tmp/a.txt")
if __name__ == "__main__":
unittest.main()

View file

@ -57,3 +57,14 @@ def test_official_runner_supports_reply_attachments_base64() -> None:
assert "data_base64" in text assert "data_base64" in text
assert "media_base64" in text assert "media_base64" in text
assert "reply.attachments" in text assert "reply.attachments" in text
assert "deliverWeixinReply" in text
assert "buildAttachmentsFromMedia" in text
assert "attachmentNameFromMediaItem" in text
def test_official_runner_proactive_outbound_supports_attachments() -> None:
text = _read("runtime/operations/weixin_bridge/official_runner.ts")
assert "flushWeixinDbOutbound" in text
assert "flushLocalProactiveReplies" in text
assert "item.attachments" in text
assert "item.media_path" in text