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
9 changes: 9 additions & 0 deletions apps/presentation/dashboard/src/data/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2379,11 +2379,14 @@ export async function updateGoalOwnership(body: { goal_id: string; mode: Executi
}


const privateAgentSessionSchema = z.object({session_id: z.string(), goal_id: z.string(), agent_id: z.string(), executor_endpoint_id: z.string()});
const privateAgentTargetSchema = privateAgentSessionSchema.extend({target_ref: z.string(), host_ref: z.string(), goal_instance_id: z.string().nullable()});
const privateConversationSchema = z.object({
binding_id: z.string(), app_ref: z.string(), context_kind: z.enum(["project", "steward"]),
project_ref: z.string(), project_title: z.string(), context_available: z.boolean(), executor_endpoint_id: z.string(),
grant: z.enum(["workspace_read", "portfolio_read"]), goal_count: z.number().int().default(0), listener_status: z.string(),
pending_count: z.number().int(), recovery_count: z.number().int(),
agent_candidates: z.array(privateAgentSessionSchema).default([]), agent_targets: z.array(privateAgentTargetSchema).default([]),
});
const privateConversationsSchema = z.object({ok: z.literal(true), revision: z.number().int(),
connections: z.array(privateConversationSchema)});
Expand All @@ -2402,3 +2405,9 @@ export async function disconnectPrivateConversation(bindingId: string, revision:
method: "DELETE", headers: {"Content-Type": "application/json"}, body: JSON.stringify({binding_id: bindingId, revision}),
}));
}

export async function changePrivateAgentTarget(bindingId: string, revision: number, target: {session_id: string} | {target_ref: string}) {
return privateConversationsSchema.parse(await requestJson<unknown>("/api/chat/lark/private-conversations/agent-targets", {
method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({binding_id: bindingId, revision, ...target}),
}));
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {useEffect, useState} from "react";
import {connectPrivateConversation, disconnectPrivateConversation, fetchPrivateConversations,
fetchChatProjects, fetchChatCapabilities, fetchLarkApps, type PrivateConversation,
changePrivateAgentTarget, fetchChatProjects, fetchChatCapabilities, fetchLarkApps, type PrivateConversation,
type ChatProject, type LarkApp} from "../../data/chat";
import {useWorkspaceI18n} from "./i18n";
import "./private-conversation.css";
Expand Down Expand Up @@ -64,6 +64,7 @@ export function PrivateConversationPanel() {
<p>{zh ? `待处理或回复:${row.pending_count}` : `Pending execution or reply: ${row.pending_count}`}</p>
{row.recovery_count > 0 ? <p role="status">{zh ? "存在尚未确认的发送回执。服务会读取原回执恢复;不要重新发送同一任务。检查 App 登录、权限和原会话后刷新状态。" : "A send receipt is unconfirmed. The service reads the original receipt to recover; avoid resending the same task. Check this App login, permissions and original Session, then refresh status."}</p> : null}
{!row.context_available ? <p role="alert">{zh ? "工作区授权已失效;请恢复原工作区或重新选择。旧会话不会移到其它工作区。" : "The workspace grant is unavailable. Restore the original workspace or select a new one; the old Session will not move."}</p> : null}
{row.context_kind === "project" ? <PrivateAgentTargets row={row} revision={revision} zh={zh} busy={busy} act={act}/> : null}
<button disabled={busy} onClick={() => void act(() => disconnectPrivateConversation(row.binding_id, revision))} type="button">{zh ? "解绑" : "Disconnect"}</button>
</article>)}
{rows.length === 0 ? <p>{zh ? "尚未连接本人私聊。" : "No owner private Chat connected."}</p> : null}
Expand All @@ -89,3 +90,21 @@ export function PrivateConversationPanel() {
{error ? <p role="alert">{error}</p> : null}
</section>;
}

function PrivateAgentTargets({row, revision, zh, busy, act}: {row: PrivateConversation; revision: number; zh: boolean;
busy: boolean; act: (operation: () => Promise<unknown>) => Promise<void>}) {
const [session, setSession] = useState("");
const candidates = row.agent_candidates.filter(item => !row.agent_targets.some(target => target.session_id === item.session_id));
return <div>
<p>{zh ? "直连注册 Agent:只授权确切的已有 attached Session。消息进入原宿主队列;此处不创建 Agent、不继承其它目标或提高宿主权限。一个 Session 的 App 受众固定,撤销后也不能换给另一 App。" : "Direct registered Agent: grant an exact existing attached Session. Messages enter its original host queue; this creates no Agent, inherits no other Goals and raises no host permission. The Session audience remains fixed even after revocation."}</p>
{row.agent_targets.map(target => <p key={target.target_ref}>{target.agent_id} · {target.goal_id}<br/>
<code>/agent {target.target_ref}</code> <button type="button" disabled={busy} onClick={() => void act(() => changePrivateAgentTarget(row.binding_id, revision, {target_ref: target.target_ref}))}>{zh ? "撤销直连授权" : "Revoke direct access"}</button></p>)}
<label>{zh ? "已有 Agent 会话" : "Existing Agent Session"}<select value={session} disabled={busy} onChange={event => setSession(event.target.value)}>
<option value="">{zh ? "选择确切会话" : "Select exact Session"}</option>
{candidates.map(item => <option key={item.session_id} value={item.session_id}>{item.agent_id} · {item.goal_id} · {item.session_id.slice(-6)}</option>)}
</select></label>
<button type="button" disabled={busy || !candidates.some(item => item.session_id === session)} onClick={() => void act(() => changePrivateAgentTarget(row.binding_id, revision, {session_id: session}))}>{zh ? "授权此 App 直连" : "Grant this App direct access"}</button>
{row.agent_candidates.length === 0 ? <p>{zh ? "暂无此工作区的可用 attached Agent 会话。请先在原宿主完成注册和 attached-session-bind;普通聊天仍可直接使用,不需要创建 Goal。" : "No eligible attached Agent Session in this workspace. Register and bind it in its original host first; ordinary Chat remains available without a Goal."}</p> : null}
<p>{zh ? "私聊 /agents 查看当前可用授权,复制完整 /agent 命令选择;/project 返回原项目对话。实时停止或新建 Agent 会话请在原宿主处理。" : "Use /agents to list usable grants, select with the full /agent command, and /project to return to project Chat. Stop or create Agent Sessions in their original host."}</p>
</div>;
}
46 changes: 45 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 @@ -135,6 +135,9 @@ native execution ending is not Goal acceptance or proof of result delivery.

The Core request persists a timestamped observation before provider delivery.
Duplicate events retain that snapshot instead of switching to a newer Session.
A selected Agent target keeps its exact bound Session: observation reads that
Session even when it is failed or closed, and never falls back to a newer
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,
Expand All @@ -144,7 +147,48 @@ Regression coverage uses the production native filesystem store, durable queue,
bound request and provider admission/reconciliation paths with a synthetic
provider and protocol executor. It qualifies queue/stop/readback and duplicate
delivery, not live provider, mobile or installed-service acceptance of this delta.
Registered Agent selection and broader coordination remain open.
The explicit attached-target selection checkpoint below is source-qualified; broader coordination and live acceptance remain open.

## Explicit registered Agent selection: original attached host

Settings → Lark can grant a project App access to an exact existing registered
Agent's attached Session in that authorized workspace. The typed binding owner
checks Goal, Agent, Session, executor, Goal lifetime and exact host identity,
with an independently verified App/owner and revision-fenced publication. Up to
sixteen explicit target grants fit in one App binding. Executor names alone do
not select a registered Agent. This creates no Agent or Goal and imports no
host history, prior bindings or portfolio.

In private Chat, `/agents` lists only currently usable grants. Copy its complete
`/agent <target-ref>` command to select one; subsequent text enters the original
canonical Session queue for that existing host to claim and complete.
`/project` returns to the original ordinary project Session. Accepted messages
and their replies retain their original target and App/source even after that
switch or a crash. No model adapter is started or resumed for an attached target.
The host must already consume its [native attached broker](../../integrations/attached-agent-session-broker.md).

The queue stores its frozen audience and exact target. Host claim revalidates
the local grant, current registration, workspace, lifetime and host under the
grant/queue fences. Revocation prevents pending claims and private result
return; a previously claimed execution still belongs to its host. Session and
host audience stamps prohibit assigning the same ongoing host conversation to
another App, including after grant revocation. Inbound and outbound provider
checks independently verify that App's owner/source. Granting message delivery
preserves the original host's permissions and claim/completion authority.

`/status` names the actual registered recipient and durable queue. The current
attached broker has no push interrupt or new-session capability: `/stop` and
`/new` return an explicit unavailable response and preserve the original host.
Handle those actions at that host, or use `/project` for ordinary Chat. Settings
can revoke a target or disconnect the App; stale roots/grants remain unavailable.
An empty candidate list does not bootstrap roles or fabricate a Goal.

Synthetic provider/native-store journeys qualify selection, follow-up, original
result return, replay/recovery, revoked pending claims, exact registration and
cross-App/host refusal. Source type checks and packaged Settings build qualify
the companion. Live attached-host automation, mobile acceptance, incremental
media/permission interactions and broader steward coordination remain open.
This source delta requires maintainer review before installed promotion.

## Decision: make the App the place where work conversations continue

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,14 +100,45 @@ executor endpoint、原生 Session/active Turn 与已持久排队数量。管家
不代表 Goal 验收,也不证明结果已经投递。

Core request 在 provider 投递前保存带时间的观测。重复事件保留原快照,不切换到
较新的 Session;执行证据缺失和未知状态明确显示不可判定。这两个命令不会打开
较新的 Session。已选定注册 Agent 时固定观测其确切绑定 Session:即使该会话已
失败或关闭也如实读取,不退回更新的会话;执行证据缺失和未知状态明确显示不可判定。这两个命令不会打开
Session、调用模型或创建 Goal。`/help` 按角色列出命令、既有设置 → Lark 的工作区、
执行器与解绑入口,以及目前仅支持文字的附件边界。

回归使用生产原生文件 store、持久队列、bound request 与 provider 受理/投递路径,
provider 和协议执行器为合成 fixture。它验证排队、停止、读回和重复投递,不证明
本增量的真实 provider、手机或正式安装验收。注册 Agent 选择及更广协调仍开放。

## 显式注册 Agent 选择:沿用原 attached 宿主

设置 → Lark 可以为项目 App 授权确切的已有注册 Agent attached Session。
共享类型化 binding owner 核验工作区、Goal、Agent、Session、执行器、Goal lifetime
和确切宿主;App/本人独立核验,配置通过 revision fence 发布。每个 App 最多
16 个明确目标授权;执行器名称不等于注册 Agent 身份。此操作不创建 Agent 或
Goal,不导入宿主历史、旧绑定或 portfolio。

私聊 `/agents` 只列当前可用授权;复制完整 `/agent <target-ref>` 选择后,后续
文字进入原 canonical Session 持久队列,由已有宿主领取并写回。`/project`
返回原普通项目会话。切换或崩溃后,已受理消息仍保持原目标与 App/source,结果
返回原私聊;不启动或恢复另一 model adapter。原宿主须已接入
[原生 attached broker](../../integrations/attached-agent-session-broker.md)。

队列保存冻结受众与确切目标。宿主领取前在 grant/queue fences 内重新核验本地
授权、注册、工作区、lifetime 与宿主;撤销拒绝尚未领取的消息和私聊结果回传,
已被领取的执行仍归原宿主。Session 与宿主受众 stamp 阻止将同一持续对话分给
另一 App,包括撤销后重新授权。入站与出站另行核验原 App 的本人/source。
消息授权保留原宿主的执行策略与领取/完成 authority。

`/status` 展示真实 Agent 接收者与持久队列。当前 attached broker 不支持 push
interrupt 或新建原宿主会话:`/stop`、`/new` 明确回应不可用,保留原执行;
请在原宿主处理,或 `/project` 回到普通聊天。设置可撤销目标或解绑 App;
缺失工作区与失效授权保持不可用。候选空态不会自动建立角色或虚构 Goal。

合成 provider/native-store 旅程覆盖选择、追问、原结果回传、重放/恢复、撤销后
领取拒绝、确切注册和跨 App/宿主隔离;类型检查与 Settings 打包覆盖产品伴随面。
真实 attached 宿主自动领取、手机验收、增量媒体/权限交互和更广的管家协调仍开放。
本源码增量经维护者评审后才能进入正式安装。

## 决策:让 App 成为工作会话持续进行的地方

用户应能在 LoopX 中说“接着做,结果给我” / “Keep going and bring me the result”,
Expand Down
43 changes: 41 additions & 2 deletions loopx/attached_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -646,14 +646,15 @@ def resume_attached_agent_session(
)


def enqueue_attached_agent_turn(
def _enqueue_attached_agent_turn_under_grant(
*,
store: ChatSessionStore,
registry_path: Path | None,
session_id: str,
client_turn_id: str,
message: str,
origin: str,
external_agent_target: dict[str, Any] | None = None,
) -> tuple[dict[str, Any], bool]:
"""Enqueue work while the exact attached Session is current."""

Expand All @@ -662,12 +663,20 @@ def enqueue_attached_agent_turn(
registry_path=registry_path,
session_id=session_id,
) as (session, registry, current_ref):
if external_agent_target is not None:
from .registry import load_registry
from .capabilities.native_chat.conversation_bindings import validate_external_agent_turn
observed_registry = registry or (load_registry(registry_path) if registry_path else {})
validate_external_agent_turn(store=store, session=session,
goal=find_registry_goal(observed_registry, session["goal_id"]),
turn={"origin": origin, "external_agent_target": external_agent_target})
if registry is None or current_ref is None:
return store.create_queued_turn(
session_id,
client_turn_id=client_turn_id,
message=message,
origin=origin,
external_agent_target=external_agent_target,
)
decision = _lifecycle_decision(
operation="admit",
Expand All @@ -684,10 +693,23 @@ def enqueue_attached_agent_turn(
message=message,
goal_instance_id=str(goal_ref["goal_instance_id"]),
origin=origin,
external_agent_target=external_agent_target,
)


def _claim_attached_turn_once(
def enqueue_attached_agent_turn(*, store: ChatSessionStore, registry_path: Path | None,
session_id: str, client_turn_id: str, message: str, origin: str,
external_agent_target: dict[str, Any] | None = None) -> tuple[dict[str, Any], bool]:
kwargs = dict(store=store, registry_path=registry_path, session_id=session_id,
client_turn_id=client_turn_id, message=message, origin=origin,
external_agent_target=external_agent_target)
if external_agent_target is None:
return _enqueue_attached_agent_turn_under_grant(**kwargs)
with exclusive_file_lock(store.root / "conversation-bindings.json", operation="admit_attached_audience"):
return _enqueue_attached_agent_turn_under_grant(**kwargs)


def _claim_attached_turn_under_grant(
*,
store: ChatSessionStore,
registry_path: Path | None,
Expand All @@ -713,10 +735,17 @@ def _claim_attached_turn_once(
host_surface=host_surface,
host_session_id=host_session_id,
)
from .registry import load_registry
from .capabilities.native_chat.conversation_bindings import validate_external_agent_turn
observed_registry = registry or (load_registry(registry_path) if registry_path and registry_path.exists() else {})
goal = find_registry_goal(observed_registry, session["goal_id"])
def validate(turn: dict[str, Any]) -> None:
validate_external_agent_turn(store=store, session=session, goal=goal, turn=turn)
if registry is None or current_ref is None:
return store.claim_next_queued_turn(
session_id,
host_claim_id=claim_id,
admission_validator=validate,
)
active_turn_id = str(session.get("active_turn_id") or "")
active_turn = (
Expand All @@ -741,9 +770,18 @@ def _claim_attached_turn_once(
session_id,
host_claim_id=claim_id,
admitted_goal_instance_id=str(goal_ref["goal_instance_id"]),
admission_validator=validate,
)


def _claim_attached_turn_once(**kwargs: Any) -> dict[str, Any] | None:
# Lock order: audience grants -> source Goal lifetime -> canonical queue.
# A revocation cannot race a not-yet-claimed private message into execution.
store = kwargs["store"]
with exclusive_file_lock(store.root / "conversation-bindings.json", operation="claim_attached_audience"):
return _claim_attached_turn_under_grant(**kwargs)


def claim_attached_agent_turn(
*,
store: ChatSessionStore,
Expand Down Expand Up @@ -796,6 +834,7 @@ def claim_attached_agent_turn(
"client_turn_id": str(turn.get("client_turn_id") or ""),
"claim_id": str(turn.get("host_claim_id") or ""),
"origin": str(turn.get("origin") or "external"),
**({"external_audience": turn["external_agent_target"]["context"]} if turn.get("external_agent_target") else {}),
"message": str(turn.get("message") or ""),
"created_at": turn.get("created_at"),
"expires_at": turn.get("expires_at"),
Expand Down
Loading
Loading