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

@ -566,28 +566,18 @@ def _rows_since_last_user_message(rows: list[Any]) -> list[Any]:
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:
"""Channel outbound only sends attachments explicitly marked deliverable=true."""
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
return att.get("deliverable") is True
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.
Visual media is always eligible; documents need explicit deliverable=true on the attachment ref.
Avoids re-sending attachments from earlier conversation turns when the latest assistant row has none.
All attachment types (image, video, document) require explicit deliverable=true.
"""
sid = str(session_id or "").strip()
if not sid:
@ -1083,11 +1073,6 @@ 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),

View file

@ -467,6 +467,17 @@ class OclawGateway:
return True
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
def _tabular_query_system_hint(lang: str) -> str:
limits = OclawGateway._tabular_limits_from_config()
@ -1159,6 +1170,10 @@ class OclawGateway:
if model is None or tools is None:
raise RuntimeError("executor missing model/tools")
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):
sys_prompt = f"{sys_prompt}\n\n{self._tabular_query_system_hint(lang)}".strip()
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 text = String(item.text || "").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(
(item.context_token as string) ||
args.modules.getContextToken(args.accountId, toUser) ||
@ -247,14 +250,20 @@ async function flushWeixinDbOutbound(args: {
continue;
}
try {
await args.modules.sendMessageWeixin({
to: toUser,
text,
opts: {
baseUrl: args.cloudBaseUrl,
token: args.token,
contextToken,
await deliverWeixinReply(args.modules, {
reply: {
chat_id: toUser,
text,
media_path: mediaPath,
attachments,
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);
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) {
const toUser = String(r.chat_id || "").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(
(r.context_token as string) ||
args.modules.getContextToken(args.accountId, toUser) ||
@ -320,14 +332,20 @@ async function flushLocalProactiveReplies(args: {
continue;
}
try {
await args.modules.sendMessageWeixin({
to: toUser,
text,
opts: {
baseUrl: args.cloudBaseUrl,
token: args.token,
contextToken,
await deliverWeixinReply(args.modules, {
reply: {
chat_id: toUser,
text,
media_path: mediaPath,
attachments,
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}`);
} catch (err) {
@ -450,16 +468,45 @@ function pickDownloadableMedia(msg: Json): Json | 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 pushIf = (key: string, mimeKey: string, kind: string): void => {
const filePath = String(mediaOpts[key] || "").trim();
if (!filePath) return;
out.push({
const name = String(
mediaOpts.fileOriginalName ||
mediaOpts.originalFilename ||
mediaOpts.file_name ||
attachmentNameFromMediaItem(mediaItem || null, kind) ||
"",
).trim();
const att: Json = {
kind,
local_path: filePath,
media_type: String(mediaOpts[mimeKey] || "").trim(),
});
};
if (name) {
att.name = name;
att.filename = name;
}
out.push(att);
};
pushIf("decryptedPicPath", "", "image");
pushIf("decryptedVideoPath", "", "video");
@ -468,6 +515,70 @@ function buildAttachmentsFromMedia(mediaOpts: Json): Json[] {
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 {
const b64Raw = String(
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 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}`);
let replies: Json[] = [];
const tNative0 = Date.now();
@ -560,7 +672,7 @@ async function handleInboundMessage(
channel: "wechat",
account_id: params.accountId,
ctx,
attachments: buildAttachmentsFromMedia(mediaOpts),
attachments: inboundAttachments,
metadata: {
source: "weixin_official_native",
raw: {
@ -586,50 +698,15 @@ async function handleInboundMessage(
return params.localCursor;
}
for (const reply of replies) {
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 || fromUser).trim() || fromUser;
const replyContextToken = String(
reply.context_token || modules.getContextToken(params.accountId, deliverTo) || contextToken || "",
).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}`);
continue;
}
if (!text) continue;
await modules.sendMessageWeixin({
to: deliverTo,
text,
opts: {
baseUrl: params.cloudBaseUrl,
token: params.token,
contextToken: replyContextToken || undefined,
},
await deliverWeixinReply(modules, {
reply,
defaultTo: fromUser,
cloudBaseUrl: params.cloudBaseUrl,
token: params.token,
accountId: params.accountId,
defaultContextToken: contextToken || undefined,
userContextTokens: params.userContextTokens,
});
log(`reply text sent to=${deliverTo} textLen=${text.length}`);
}
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:
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.]"
"[Channel rule: to send any generated attachment back to the user on WhatsApp/WeChat "
"(documents, images, videos), call save_deliverable_attachment with path or attachment_id. "
"write_file, run_command, and image generation alone do not attach files to the outbound message.]"
)
return (
"[渠道规则:若要把生成的文件发回用户(WhatsApp/微信),"
"在 write_file 或 run_command 生成文件后必须调用 save_deliverable_attachment;"
"仅 write_file 不会随消息发送附件。]"
"[渠道规则:若要把生成的附件发回用户(WhatsApp/微信,含文档/图片/视频),"
"必须调用 save_deliverable_attachment(path 或 attachment_id);"
"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
def _encode_weixin_outbound_source(*, context_token: str) -> str:
return json.dumps(
{"kind": "scheduled_job", "context_token": str(context_token or "").strip()},
ensure_ascii=False,
)
def _encode_weixin_outbound_source(
*,
context_token: str,
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]:
@ -35,6 +47,8 @@ def enqueue_weixin_reply(
context_token: str = "",
store: Any = None,
tenant_id: str = "",
attachments: list[dict[str, Any]] | None = None,
media_path: str | None = None,
) -> dict[str, Any]:
from runtime.scheduler.weixin_delivery import normalize_weixin_channel
@ -57,7 +71,11 @@ def enqueue_weixin_reply(
text=str(text or ""),
tenant_id=str(tenant_id or ""),
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 ""
).strip()
@ -84,6 +102,8 @@ def enqueue_weixin_reply(
chat_id=str(chat_id or "").strip(),
text=text,
context_token=ctx_tok,
attachments=attachments,
media_path=media_path,
)
return {
"ok": True,

View file

@ -18,13 +18,32 @@ def _guess_mime(path: Path, override: str) -> str:
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"}
attachment_id = str(args.get("attachment_id") or "").strip().lower()
raw_path = str(args.get("path") or "").strip().strip('"').strip("'")
display_name = str(args.get("name") 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:
p = resolve_workspace_path(raw)
p = resolve_workspace_path(raw_path)
except ValueError as exc:
return {"ok": False, "error": str(exc)}
if not p.exists() or not p.is_file():
@ -48,9 +67,11 @@ def save_deliverable_attachment_tool() -> ToolSpec:
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."
"Mark an attachment for outbound channel delivery (WhatsApp/WeChat). "
"Required before the user receives any generated file, image, or video in a messaging channel. "
"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={
"type": "object",
@ -59,16 +80,19 @@ def save_deliverable_attachment_tool() -> ToolSpec:
"type": "string",
"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": {
"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).",
"description": "Optional MIME type override.",
},
},
"required": ["path"],
"additionalProperties": False,
},
handler=_handler,