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
8 changes: 5 additions & 3 deletions apps/presentation/dashboard/src/data/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2181,7 +2181,7 @@ export type LarkPermissionGuidance = {

const larkGroupChatsSchema = z.object({
ok: z.literal(true),
chats: z.array(z.object({ chat_id: z.string(), chat_name: z.string() })),
chats: z.array(z.object({ chat_id: z.string(), chat_name: z.string(), selected: z.boolean().default(false) })),
});

export async function fetchLarkGroupChats(appRef: string, query?: string) {
Expand Down Expand Up @@ -2402,6 +2402,7 @@ const privateConversationSchema = z.object({
project_ref: z.string(), project_title: z.string(), context_available: z.boolean(), executor_endpoint_id: z.string(),
grant: z.enum(["workspace_read", "workspace_write", "portfolio_read"]), goal_count: z.number().int().default(0), listener_status: z.string(),
goal_scope: z.enum(["all_registered", "selected"]).nullable().optional(),
audience: z.enum(["owner", "group"]).default("owner"), group_count: z.number().int().default(0),
pending_count: z.number().int(), recovery_count: z.number().int(),
agent_candidates: z.array(privateAgentSessionSchema).default([]), agent_targets: z.array(privateAgentTargetSchema).default([]),
});
Expand All @@ -2411,11 +2412,12 @@ export type PrivateConversation = z.infer<typeof privateConversationSchema>;
export async function fetchPrivateConversations() {
return privateConversationsSchema.parse(await requestJson<unknown>("/api/chat/lark/private-conversations"));
}
export async function connectPrivateConversation(appRef: string, projectRef: string, executor: string, contextKind: "project" | "steward" = "project", projectGrant: "workspace_read" | "workspace_write" = "workspace_write") {
export async function connectPrivateConversation(appRef: string, projectRef: string, executor: string, contextKind: "project" | "steward" = "project", projectGrant: "workspace_read" | "workspace_write" = "workspace_write", groupIds?: string[]) {
return privateConversationsSchema.parse(await requestJson<unknown>("/api/chat/lark/private-conversations", {
method: "POST", headers: {"Content-Type": "application/json"},
body: JSON.stringify({app_ref: appRef, project_ref: projectRef, executor_endpoint_id: executor, context_kind: contextKind, project_grant: projectGrant,
...(contextKind === "steward" ? {goal_scope: "all_registered"} : {})}),
...(contextKind === "steward" ? {goal_scope: "all_registered"} : {}),
...(groupIds !== undefined ? {audience: "group", group_ids: groupIds} : {})}),
}));
}
export async function disconnectPrivateConversation(bindingId: string, revision: number) {
Expand Down

Large diffs are not rendered by default.

32 changes: 32 additions & 0 deletions docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,38 @@ Synthetic product previews: [desktop](../../assets/personal-workspace/private-pr
[narrow](../../assets/personal-workspace/private-project-conversations-narrow.png),
[revoked workspace](../../assets/personal-workspace/private-project-workspace-revoked.png).

## Selected group topics: isolated ordinary project Chat

Settings → Lark also connects a project App to explicitly selected groups. The
list comes from that App's provider-observed group membership; Core validates
the allowlist at configuration time. This is an audience option on the existing
binding, not a new Goal Channel, listener, queue or model runner. One App retains
one binding owner. Existing private bindings keep their audience and host grants.

A human starts a topic by mentioning the Bot. Admission reads the exact provider
message and, for a continuation, its original root; event text cannot substitute
the root or mention. Members in that topic share its Session and FIFO; different
topics, groups and Apps have independent Session identities. Status, stop and new
target that topic. Admission feedback, streamed posts, terminal edits and receipt
recovery use the existing delivery path with `source_thread` placement. A revoked
binding or unreadable source prevents later result delivery.

Group contexts always use `workspace_only` filesystem isolation, even when the
owner's private project uses `host_default`. Only the selected read/write workspace
grant is available. Personal portfolio, attached Agent selection, global skills,
MCP, shell profiles and inherited account environment are unavailable. The native
host must verify the exact permissions profile and workspace root; its independent
store needs separate login and is never seeded from personal credentials/history.
`/status` exposes the project title, not the host's absolute workspace path.

Core, HTTP, provider-readback and native-protocol fixtures exercise these boundaries.
They are synthetic transport/model evidence, not a live community rollout. The
[community golden queries](../../product/use-cases/community/golden-queries.md)
still require an independently authenticated public workspace and actual pilot
group journeys before either developer group is enabled. Public-source reading
needs its separately qualified scoped tool; this change does not enable unrestricted
network or personal browser access.

## Ordinary workspace writes: default and revocation checkpoint

Ordinary project Chat defaults to `workspace_write` for host-declared roots. The
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,30 @@ extension,typed binding owner 继续保持 provider-neutral。
![窄屏私聊设置](../../assets/personal-workspace/private-project-conversations-narrow.png)
![工作区撤权读回](../../assets/personal-workspace/private-project-workspace-revoked.png)

## 指定群话题:隔离的普通项目 Chat

设置 → Lark 可将项目 App 接到明确选定的群。群列表来自当前 App 的 provider 成员
观测,Core 在配置时核验白名单;这是现有 binding 的受众选项,不新增 Goal Channel、
listener、队列或模型 runner。一个 App 仍只有一个 binding owner,已有私聊绑定保留
原受众及宿主权限。

成员 @Bot 开始话题。受理时读取 provider 原消息,续聊还读取原话题根;事件文字不能
替换根消息或提及证据。同一话题的成员共享 Session 与 FIFO,不同话题、群和 App
使用独立 Session。status、stop、new 只作用于当前话题。受理反馈、流式消息、最终
编辑和回执恢复复用现有投递路径,使用 `source_thread` 落点。解绑或原消息不可读后,
即使执行完成,也不得继续发送结果。

群上下文强制使用 `workspace_only`,不受本人私聊 `host_default` 影响。只保留选定
工作区的读写 grant,不提供个人 portfolio、直连 Agent 选择、全局 skills、MCP、
shell profile 或继承的账号环境。原生宿主必须确认确切 permissions profile 和工作区
根;独立存储需另行登录,不能复制个人凭据或历史。`/status` 只显示项目标题,不展示
宿主绝对工作区路径。

Core、HTTP、provider 读回及原生协议 fixture 验证上述边界,属于合成传输/模型证据。
[社区黄金查询](../../product/use-cases/community/golden-queries.md) 仍需独立登录的公开
工作区和真实调试群旅程通过后,才能接入两个正式开发群。公开来源读取另需完成其
限定范围工具的验收;本改动不启用无限制网络或个人浏览器。

## 普通工作区读写:默认值与撤权检查点

普通项目 Chat 对宿主声明的工作区默认使用 `workspace_write`。Core context owner
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export const privateStewardScopeScenario = {
await page.locator(".personal-settings-tabs").getByRole("button", {name: "Lark", exact: true}).click();
}
await settings();
const panel = page.getByRole("region", {name: "本人飞书私聊"});
const panel = page.getByRole("region", {name: "飞书项目助手与管家"});
await panel.getByText("LoopX 管家 · 已选范围 · 1 个 Goal", {exact: true}).waitFor();
const upgrade = panel.getByRole("button", {name: "授权全部已注册工作", exact: true});
await upgrade.scrollIntoViewIfNeeded();
Expand Down
22 changes: 18 additions & 4 deletions loopx/capabilities/native_chat/conversation_bindings.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@ def _core(operation: str, params: dict[str, Any]) -> dict[str, Any]:

def configure(self, *, transport_ref: str, project_ref: str,
executor_endpoint_id: str, context_kind: str = "project",
project_grant: str | None = None, goal_scope: str | None = None) -> dict[str, Any]:
project_grant: str | None = None, goal_scope: str | None = None,
audience: str | None = None, group_refs: list[str] | None = None,
available_group_refs: list[str] | None = None) -> dict[str, Any]:
if goal_scope is not None and (context_kind != "steward" or goal_scope not in {"selected", "all_registered"}):
raise ValueError("select a supported steward Goal scope")
if project_grant is None:
Expand All @@ -59,11 +61,13 @@ def configure(self, *, transport_ref: str, project_ref: str,
"executor_endpoint_id": executor_endpoint_id,
"grant": project_grant if context_kind == "project" else "portfolio_read", "enabled": True,
**({"goal_ids": [], "goal_scope": goal_scope or "all_registered"} if context_kind == "steward" else {}),
**({"audience": audience} if audience is not None else {}),
**({"group_refs": group_refs} if group_refs is not None else {}),
}
with exclusive_file_lock(self.path, operation="configure_chat_conversation_binding"):
current = self.read()
previous = next((row for row in current["bindings"] if row["transport_ref"] == transport_ref), None)
if previous and all(previous.get(key) == candidate.get(key) for key in ["context_kind", "project_ref", "executor_endpoint_id", "provider_ref", "operator_ref", "grant"]):
if previous and all(previous.get(key) == candidate.get(key) for key in ["context_kind", "project_ref", "executor_endpoint_id", "provider_ref", "operator_ref", "grant", "audience", "group_refs"]):
if previous.get("agent_targets"):
candidate["agent_targets"] = previous["agent_targets"]
if (context_kind == "steward" and previous and all(previous.get(key) == candidate.get(key)
Expand All @@ -79,6 +83,7 @@ def configure(self, *, transport_ref: str, project_ref: str,
result = self._core("collaboration.conversation.binding", {
"current": current, "expected_revision": current.get("revision"), "operation": "configure",
"binding": candidate, "observation": observation, "available_projects": self.projects.available(),
**({"available_group_refs": available_group_refs} if audience == "group" else {}),
})
proposed = next(row for row in result["state"]["bindings"] if row["transport_ref"] == transport_ref)
channels = self.delivery_channels(proposed) if goal_scope is not None else []
Expand Down Expand Up @@ -111,7 +116,9 @@ def disconnect(self, binding_id: str, *, expected_revision: int) -> dict[str, An
return dict(result["state"])

def resolve(self, *, binding_id: str, source_ref: str, sender_ref: str,
private_human_message: bool, session_context: dict[str, Any] | None = None) -> dict[str, Any]:
private_human_message: bool, session_context: dict[str, Any] | None = None,
group_ref: str | None = None, topic_ref: str | None = None,
group_human_message: bool = False) -> dict[str, Any]:
current = self.read()
row = next((item for item in current.get("bindings", []) if item.get("binding_id") == binding_id), None)
if row is None:
Expand All @@ -125,6 +132,8 @@ def resolve(self, *, binding_id: str, source_ref: str, sender_ref: str,
"available_projects": self.projects.available(),
**registry_scope,
**({"session_context": session_context} if session_context is not None else {}),
**({"group_ref": group_ref, "topic_ref": topic_ref,
"group_human_message": group_human_message} if group_ref is not None or topic_ref is not None or group_human_message else {}),
})

def goal_scope_ids(self, binding: dict[str, Any]) -> list[str]:
Expand Down Expand Up @@ -160,6 +169,11 @@ def ensure_delivery_scope(self, selected: dict[str, Any], *, if_absent: bool = T
sender_id=binding["operator_ref"], execute=execute, if_absent=if_absent)

def session_context(self, saved: dict[str, Any]) -> dict[str, Any]:
if saved.get("audience") == "bound_group":
return self.resolve(binding_id=saved["binding_id"], source_ref=saved["source_ref"],
sender_ref=saved["operator_ref"], private_human_message=False,
group_ref=saved["group_ref"], topic_ref=saved["topic_ref"], group_human_message=True,
session_context=saved)
return self.resolve(binding_id=saved["binding_id"], source_ref=saved["source_ref"],
sender_ref=saved["operator_ref"], private_human_message=True, session_context=saved)

Expand Down Expand Up @@ -221,7 +235,7 @@ def agent_observation(self, session_id: str) -> dict[str, Any]:

def agent_candidates(self, binding_id: str) -> list[dict[str, Any]]:
row = next(item for item in self.read()["bindings"] if item["binding_id"] == binding_id)
if row["context_kind"] != "project" or self.controller is None:
if row["context_kind"] != "project" or row.get("audience") == "group" or self.controller is None:
return []
project = next((item for item in self.projects.available() if item["project_ref"] == row["project_ref"]), None)
if project is None:
Expand Down
2 changes: 1 addition & 1 deletion loopx/capabilities/native_chat/project_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ def session_context(self, session: dict[str, Any]) -> dict[str, Any]:
saved = session.get("project_context")
if not isinstance(saved, dict) or session.get("goal_id") is not None:
raise ValueError("invalid ordinary project Session")
if saved.get("audience") == "bound_owner":
if saved.get("audience") in {"bound_owner", "bound_group"}:
if self.conversation_bindings is None:
raise ValueError("bound project conversation authority is unavailable")
selected = self.conversation_bindings.session_context(saved)
Expand Down
6 changes: 5 additions & 1 deletion loopx/chat_lark_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -521,11 +521,15 @@ def _lark_chats(self) -> None:
error_code=exc.error_code,
)
return
from .extensions.lark.conversation_identity import identity_ref
bindings = self.server.runtime_controller.project_contexts.conversation_bindings
binding = next((row for row in bindings.read()["bindings"] if row["transport_ref"] == app_ref), None) if bindings else None
self._send_json(
{
"ok": True,
"schema_version": "loopx_lark_group_chats_v0",
"chats": chats,
"chats": [{**chat, "selected": identity_ref(binding["provider_ref"], chat["chat_id"]) in binding.get("group_refs", [])}
for chat in chats] if binding else chats,
}
)

Expand Down
6 changes: 4 additions & 2 deletions loopx/chat_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -1011,7 +1011,7 @@ def submit_turn(
if session.get("steward_context") is not None:
raise ValueError("bound steward Chat requires its external source admission")
if session.get("project_context") is not None:
if session["project_context"].get("audience") == "bound_owner":
if session["project_context"].get("audience") in {"bound_owner", "bound_group"}:
raise ValueError("bound project Chat requires its external source admission")
context = self.project_contexts.session_context(session)
project_context_policy.validate_project_executor_scope(str(session["agent_id"]), session["project_context"], self.capabilities)
Expand Down Expand Up @@ -1143,7 +1143,7 @@ def steer_active_turn(
if session.get("steward_context") is not None:
raise ValueError("bound steward Chat requires its external source admission")
if session.get("project_context") is not None:
if session["project_context"].get("audience") == "bound_owner":
if session["project_context"].get("audience") in {"bound_owner", "bound_group"}:
raise ValueError("bound project steering requires its external source admission")
self.project_contexts.session_context(session)
if session.get("session_mode") == CHAT_SESSION_MODE_ATTACHED:
Expand Down Expand Up @@ -1658,6 +1658,8 @@ def event_sink(kind: str, payload: dict[str, Any]) -> None:
event_buffer.close()
return
if response.get("context_handoff") is not None:
if scope["kind"] not in {"owner_goal", "owner_portfolio", "external_audience"}:
raise ValueError("This workspace conversation cannot hand off to a Goal or Agent.")
response = apply_context_handoff(
self, scope, session,
self.store.load_turn(session_id, turn_id) or {}, response, context,
Expand Down
Loading
Loading