Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ export function PrivateConversationPanel() {
<div className="personal-detail-actions"><button disabled={busy || !app || !project || !executor} onClick={() => void act(() => connectPrivateConversation(app, project, executor, role, role === "project" ? effectiveProjectGrant : "workspace_read"))} type="button">
{busy ? (zh ? "正在核验" : "Verifying") : (zh ? "连接本人私聊" : "Connect owner private Chat")}</button>
<button disabled={busy} onClick={() => void act(refresh)} type="button">{zh ? "刷新状态" : "Refresh status"}</button></div>
<p>{zh ? "从手机发送文字开始;后续消息进入原会话队列。/status 查看工作区、角色与持久排队状态,/help 查看用法与解绑入口,/stop 停止当前聊天执行,/new 开启新会话。图片、文件会明确提示暂不支持。" : "Send text from your phone to begin; follow-ups queue in the same Session. /status shows the workspace, role and durable queue, /help explains commands and where to unbind, /stop stops the current Chat Turn, /new starts a new conversation. Images and files receive an explicit unsupported response."}</p>
<p>{zh ? "发送文字、图片或图文消息开始;后续消息进入原会话队列。/status 查看工作区、角色与持久排队状态,/help 查看用法与解绑入口,/stop 停止当前聊天执行,/new 开启新会话。文件、音视频、附在控制命令或已选择 Agent 上的图片会明确提示暂不支持。" : "Send text, images or image/text posts to begin; follow-ups queue in the same Session. /status shows the workspace, role and durable queue, /help explains commands and where to unbind, /stop stops the current Chat Turn, /new starts a new conversation. Files, audio/video, and images sent with control commands or to a selected attached Agent receive an explicit unsupported response."}</p>
<p>{zh ? "管家新委托:/delegate --tokens N 具体目标。先读预览,再用原私聊的完整 /confirm 命令确认;15 分钟过期。原生执行保持只读,总 token 上限可能被运行中的请求超过;没有默认定时调度。回执提供 /stop-commission 停止和 /resume-commission 恢复命令;恢复保留原线程及累计用量。" : "Steward commission: /delegate --tokens N objective. Read the preview, then use its full /confirm command in the original private Chat within 15 minutes. Native execution remains read-only; in-flight requests can exceed the total token allowance. No default schedule. Receipts provide /stop-commission and /resume-commission commands; recovery retains the original thread and cumulative usage."}</p>
{error ? <p role="alert">{error}</p> : null}
</section>;
Expand Down
22 changes: 21 additions & 1 deletion docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ conversation.
Missing execution evidence and unknown states stay explicitly unavailable.
These commands open no Session, invoke no model and create no Goal. `/help`
shows role-specific commands and the existing Settings → Lark entry for workspace,
executor and revocation, including the text-only attachment boundary.
executor and revocation, including supported images and unavailable media/attached-host boundaries.

Regression coverage uses the production native filesystem store, durable queue,
bound request and provider admission/reconciliation paths with a synthetic
Expand Down Expand Up @@ -921,3 +921,23 @@ historical cards and rejected-draft recovery. These fixtures establish transport
and interface behavior, not live model quality, public posting or installed-host
acceptance. GQ06's material entry and GQ07–09's continuity remain subject to their
full delivery and recovery acceptance.


### Default Lark private images reuse native Turn attachments

Ordinary project and steward private conversations accept images and image/text
posts by default. The provider verifies the canonical message under its receiving
App, downloads only that message's resources as that App, and passes bounded
PNG/JPEG/GIF/WebP data into the existing Core request and durable Session queue.
Limits remain four images, 5 MiB each and 12 MiB total. Captions survive; resource
keys and private image bytes do not enter typed routing observations. Duplicate
events reuse downloaded input and the original Turn; restart drains that same
Turn and upstream thread. Grants are checked again after download and on return.

Failed downloads, unsupported files/audio/video, and images sent with control
commands or to an attached host receive an explicit non-execution notice. The
provider does not execute only the text of a partially supported post. Attached
host media and file delivery remain separate gaps. Regression covers model-wire
image input, unchanged Session, replay, durable restart and download-time
revocation; live provider/model acceptance is reported separately. No new
Session authority, queue, worker or feature toggle is introduced.
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ Core request 在 provider 投递前保存带时间的观测。重复事件保留
较新的 Session。已选定注册 Agent 时固定观测其确切绑定 Session:即使该会话已
失败或关闭也如实读取,不退回更新的会话;执行证据缺失和未知状态明确显示不可判定。这两个命令不会打开
Session、调用模型或创建 Goal。`/help` 按角色列出命令、既有设置 → Lark 的工作区、
执行器与解绑入口,以及目前仅支持文字的附件边界。
执行器与解绑入口,以及图片支持和暂不可用的媒体/原宿主边界。

回归使用生产原生文件 store、持久队列、bound request 与 provider 受理/投递路径,
provider 和协议执行器为合成 fixture。它验证排队、停止、读回和重复投递,不证明
Expand Down Expand Up @@ -562,3 +562,16 @@ Turn HTTP 预算包含既有附件额度的 base64 编码:最多四张图片
源码验收覆盖 HTTP 准入、会话持久化读回和合成 Codex 协议进程,以及打包后的桌面/窄屏
图片发送、历史操作卡与拒绝后的草稿恢复。该证据只证明传输和界面行为,不证明真实模型质量、
公开发布或已安装宿主验收。GQ06 的材料入口及 GQ07–09 的连续性仍须完成各自的交付与恢复验收。

### 飞书私聊默认复用原生 Turn 图片附件

普通项目与管家私聊默认接收图片和图文消息。provider 在接收 App 下核验 canonical
message,仅以该 App 身份下载属于这条消息的资源,再将 PNG/JPEG/GIF/WebP 交给
既有 Core request 与持久 Session queue。沿用四张、单张 5 MiB、合计 12 MiB 上限。
保留配文;资源 key 和私有图片字节不进入 typed routing 观测。重复事件复用原输入
和 Turn;重启后仍由原 Turn、原 upstream thread 执行。下载后及回复前重新核验授权。

下载失败、文件/音视频、携图控制命令或原宿主 Agent 图片请求均明确告知未提交执行,
不会只执行混合消息的文字部分。原宿主媒体与文件交付仍待补齐。回归覆盖图片模型输入、
原 Session、重复投递、持久重启与下载中撤权;真实 provider/model 验收另行记录。
本增量不新增 Session authority、queue、worker 或默认关闭的功能开关。
18 changes: 14 additions & 4 deletions loopx/capabilities/native_chat/external_conversations.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from typing import Any

from ...chat_store import _atomic_write_json, _read_json
from ...chat_attachments import normalize_chat_image_attachments
from ...file_lock import exclusive_file_lock


Expand All @@ -24,7 +25,8 @@ def __init__(self, controller: Any) -> None:
self.actions: Any | None = None

def admit(self, *, binding_id: str, source: dict[str, Any], request_ref: str,
message: str, command: str | None = None) -> dict[str, Any]:
message: str, command: str | None = None,
attachments: list[dict[str, Any]] | None = None) -> dict[str, Any]:
import re
if not re.fullmatch(r"[a-f0-9]{24}", request_ref):
raise ValueError("invalid external request reference")
Expand All @@ -34,7 +36,8 @@ def admit(self, *, binding_id: str, source: dict[str, Any], request_ref: str,
path = self.root / f"{request_ref}.json"
with exclusive_file_lock(self.root / "source-fences" / f"{binding_id}.{source['source_ref']}.json", operation="route_external_chat_request"), exclusive_file_lock(path, operation="admit_external_chat_request"):
selected = self.bindings.resolve(binding_id=binding_id, **source)
expected = {"binding_id": binding_id, "source": source, "message": message, "command": command}
expected = {"binding_id": binding_id, "source": source, "message": message, "command": command,
"attachments": normalize_chat_image_attachments(attachments) or None}
if path.exists():
row = _read_json(path)
if any(row.get(key) != value for key, value in expected.items()):
Expand Down Expand Up @@ -98,6 +101,7 @@ def _admit_prepared(self, path: Path, row: dict[str, Any], selected: dict[str, A
self._record_steward_ingress(row, selected, current, client_id)
turn, _ = controller.store.create_queued_turn(current["session_id"],
client_turn_id=client_id, message=row["message"], origin="lark",
attachments=row.get("attachments"),
external_agent_target={"target": target, "context": selected["context"]} if target else None)
row.update(status="accepted", turn_id=turn["turn_id"])
_atomic_write_json(path, row)
Expand All @@ -109,9 +113,14 @@ def _admit_prepared(self, path: Path, row: dict[str, Any], selected: dict[str, A
observations = {"context": selected["context"],
"observed_at": datetime.now(timezone.utc).isoformat(),
"queued_count": len(controller.store.queued_turns(current["session_id"])) if current else 0,
"active_turn": controller.store.load_turn(current["session_id"], active_id) if active_id else None}
"active_turn": ({key: value for key, value in controller.store.load_turn(current["session_id"], active_id).items()
if key != "attachments"} if active_id else None)}
# Routing needs presence, not private image bytes. Persisted attachments
# remain in the native request/Turn and never enter the effect bridge.
plan = effect_runtime_result("collaboration.conversation.request", {
"request": row, "current_session": current, "binding": selected["binding"],
"request": {key: value for key, value in row.items() if key != "attachments"},
"attachment_count": len(row.get("attachments") or []),
"current_session": current, "binding": selected["binding"],
"agent_target": target, **observations})
operation = plan["operation"]
if operation == "select_recipient":
Expand Down Expand Up @@ -182,6 +191,7 @@ def _admit_prepared(self, path: Path, row: dict[str, Any], selected: dict[str, A
try:
turn, _ = controller.enqueue_turn(session_id=current["session_id"],
client_turn_id=plan["client_turn_id"], message=row["message"],
attachments=row.get("attachments"),
work_dir=Path("."), objective="", origin="lark",
external_agent_target={"target": target, "context": selected["context"]} if target else None)
row.update(status="accepted", turn_id=turn["turn_id"])
Expand Down
6 changes: 5 additions & 1 deletion loopx/chat_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -1267,6 +1267,7 @@ def enqueue_turn(
session_id: str,
client_turn_id: str,
message: str,
attachments: list[AttachmentPayload] | None = None,
work_dir: Path,
objective: str,
origin: str = "external",
Expand All @@ -1287,6 +1288,8 @@ def enqueue_turn(
context = self.project_contexts.session_context(session)
work_dir, objective = context["project"], context["objective"]
if session.get("session_mode") == CHAT_SESSION_MODE_ATTACHED:
if attachments:
raise ValueError("attached host session queue does not yet accept attachments")
turn, created = enqueue_attached_agent_turn(
store=self.store,
registry_path=self.registry_path,
Expand All @@ -1303,6 +1306,7 @@ def enqueue_turn(
session_id,
client_turn_id=client_turn_id,
message=message,
attachments=attachments,
origin=origin,
)
self.resume_session_queue(
Expand Down Expand Up @@ -1418,7 +1422,7 @@ def _drain_session_queue(
session_id=session_id,
turn_id=turn_id,
message=str(turn.get("message") or ""),
attachments=[],
attachments=turn.get("attachments") or [],
adapter=adapter,
done_event=done_event,
)
Expand Down
8 changes: 8 additions & 0 deletions loopx/chat_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -1053,6 +1053,7 @@ def create_queued_turn(
*,
client_turn_id: str,
message: str,
attachments: list[dict[str, Any]] | None = None,
goal_instance_id: str | None = None,
ttl_seconds: int = SESSION_QUEUE_TTL_SECONDS,
origin: str = "external",
Expand All @@ -1061,6 +1062,10 @@ def create_queued_turn(
"""Persist one bounded follow-up without replacing the active Turn."""

client_id = _opaque_id(client_turn_id, field="client_turn_id")
from .chat_attachments import normalize_chat_image_attachments, validate_chat_turn_envelope
normalized_attachments = normalize_chat_image_attachments(attachments) or None
if normalized_attachments:
validate_chat_turn_envelope({"message": message, "attachments": normalized_attachments})
session_path = self._session_path(session_id)
with self._session_lock(session_id):
with exclusive_file_lock(
Expand All @@ -1075,6 +1080,7 @@ def create_queued_turn(
identity="client_turn_id",
request={
"message": str(message),
"attachments": normalized_attachments,
"origin": _opaque_id(origin, field="origin"),
"external_agent_target": external_agent_target,
},
Expand Down Expand Up @@ -1111,6 +1117,7 @@ def create_queued_turn(
"status": "queued",
**({"external_agent_target": external_agent_target} if external_agent_target is not None else {}),
"message": str(message),
**({"attachments": normalized_attachments} if normalized_attachments else {}),
"origin": _opaque_id(origin, field="origin"),
"upstream_turn_id": None,
"response": None,
Expand Down Expand Up @@ -1143,6 +1150,7 @@ def create_queued_turn(
text=message,
turn_id=turn_id,
origin=origin,
attachments=normalized_attachments,
)
self.append_event(
session_id,
Expand Down
9 changes: 9 additions & 0 deletions loopx/control_plane/collaboration/conversation_binding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,13 +266,22 @@ export function planBoundConversationRequest(params: JsonObject): JsonObject {
const row = requireJsonObject(params.request, "external request");
const request = ref(row.request_ref, "external request identity");
const command = row.command;
const imageCount = params.attachment_count ?? 0;
if (!Number.isSafeInteger(imageCount) || Number(imageCount) < 0 || Number(imageCount) > 4) {
throw new EffectRuntimeRequestError("invalid external image attachment count");
}
if (![null, "agents", "select_agent", "select_project", "status", "help", "new", "stop", "unsupported", "commission", "confirm_commission", "cancel_commission", "stop_commission", "resume_commission"].includes(command as null | string)) {
throw new EffectRuntimeRequestError("unsupported external conversation command");
}
const current = params.current_session === null ? null : requireJsonObject(params.current_session, "current Session");
const target = row.target_recorded === true ? row : current;
const session = target?.session_id ?? null;
const turn = row.target_recorded === true ? row.turn_id ?? null : current?.active_turn_id ?? null;
if (Number(imageCount) > 0 && (command !== null || params.agent_target != null)) {
// Preserve the existing attached-host capability boundary and never drop
// images while executing a control command or handing off to that host.
return {operation: "reply", session_id: session, turn_id: null, response_code: "unsupported_attachment"};
}
if (["agents", "select_agent", "select_project"].includes(String(command))) {
return {operation: "select_recipient", session_id: null, turn_id: null};
}
Expand Down
Loading
Loading