Skip to content
Open
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 @@ -99,7 +99,7 @@ export function PrivateConversationPanel() {
<div className="personal-detail-actions"><button disabled={busy || !app || !project || !executor || (role === "project" && executor === "codex" && projectGrant === "workspace_write" && projects.find(item => item.project_ref === project)?.grant === "workspace_read")} onClick={() => void act(() => connectPrivateConversation(app, project, executor, role, role === "project" && executor === "codex" ? projectGrant : "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 @@ -208,7 +208,7 @@ Duplicate events retain that snapshot instead of switching to a newer Session.
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 @@ -939,3 +939,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 @@ -139,7 +139,7 @@ executor endpoint、原生 Session/active Turn 与已持久排队数量。管家
Core request 在 provider 投递前保存带时间的观测。重复事件保留原快照,不切换到
较新的 Session;执行证据缺失和未知状态明确显示不可判定。这两个命令不会打开
Session、调用模型或创建 Goal。`/help` 按角色列出命令、既有设置 → Lark 的工作区、
执行器与解绑入口,以及目前仅支持文字的附件边界。
执行器与解绑入口,以及图片支持和暂不可用的媒体/原宿主边界。

回归使用生产原生文件 store、持久队列、bound request 与 provider 受理/投递路径,
provider 和协议执行器为合成 fixture。它验证排队、停止、读回和重复投递,不证明
Expand Down Expand Up @@ -578,3 +578,17 @@ typed Core、HTTP 与原生宿主回归覆盖默认读写、明确只读、工
旧会话拒绝和原线程恢复。真实 Codex canary 按项目规则编辑并读回合成笔记,保留原文、
不创建 Goal;这只是宿主/文件系统结果,不代表真实 Lark 写入、素材 intake 或发布完成。
维护者 review、安装与 Lark 旅程、更多 IM 交互仍未关闭。


### 飞书私聊默认复用原生 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 或默认关闭的功能开关。
3 changes: 2 additions & 1 deletion loopx/canary/module_metric_baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@
},
"loopx/chat_runtime.py": {
"any_count": 35,
"dict_any_count": 0
"dict_any_count": 0,
"lines": 2004
},
"loopx/chat_server.py": {
"any_count": 25,
Expand Down
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 @@ -33,7 +35,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 @@ -83,6 +86,7 @@ def _admit_prepared(self, path: Path, row: dict[str, Any], selected: dict[str, A
# Session check. Never move an accepted request to a new Session.
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 @@ -94,9 +98,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 @@ -166,6 +175,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 @@ -1259,6 +1259,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 @@ -1279,6 +1280,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 @@ -1295,6 +1298,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 @@ -1410,7 +1414,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 @@ -1029,6 +1029,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 @@ -1037,6 +1038,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 @@ -1051,6 +1056,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 @@ -1087,6 +1093,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 @@ -1119,6 +1126,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 @@ -257,13 +257,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