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
4 changes: 4 additions & 0 deletions apps/presentation/dashboard/src/data/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1942,6 +1942,7 @@ export async function fetchLarkGroupChats(appRef: string, query?: string) {

export type LarkGoalConnection = {
conversation_kind?: "goal" | "manager";
turn_trigger?: "addressed" | "human_messages";
agent_id: string | null;
connection_id: string;
app_label: string;
Expand Down Expand Up @@ -1973,6 +1974,7 @@ const larkConnectionsSchema = z.object({
ok: z.literal(true),
connections: z.array(z.object({
conversation_kind: z.enum(["goal", "manager"]).default("goal"),
turn_trigger: z.enum(["addressed", "human_messages"]).default("addressed"),
agent_id: z.string().nullable().default(null),
connection_id: z.string(),
app_label: z.string(),
Expand Down Expand Up @@ -2019,6 +2021,7 @@ export async function fetchLarkConnections() {

export async function connectLarkGoalTopic(options: {
conversationKind?: "goal" | "manager";
turnTrigger?: "addressed" | "human_messages";
agentBindings?: Array<{ agentId: string; appRef: string }>;
agentId?: string;
appRef?: string;
Expand Down Expand Up @@ -2046,6 +2049,7 @@ export async function connectLarkGoalTopic(options: {
...(options.appRef ? { app_ref: options.appRef } : {}),
...(options.connectionId ? { connection_id: options.connectionId } : {}),
conversation_kind: options.conversationKind ?? "goal",
...(options.turnTrigger ? { turn_trigger: options.turnTrigger } : {}),
capture_scope: options.captureScope,
chat_id: options.chatId,
chat_name: options.chatName,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,10 @@ const en = {
"lark.conversationKind": "Connection purpose",
"lark.managerConversation": "Manager · live conversation",
"lark.workerConversation": "Worker Agent · background tasks",
"lark.turnTrigger": "When to respond",
"lark.triggerAddressed": "Only when mentioned or replied to",
"lark.triggerHuman": "Respond to group members without @",
"lark.triggerDescription": "Everyone in this connected group can start a conversation. Bot messages and historical backfill stay as context. Requires the Lark app permission to receive all group messages; tool and delegation permissions stay unchanged.",
"lark.managerConversationDescription": "The built-in manager responds as you chat and delegates long-running work to background Agents. Group and private conversations keep separate histories.",
"lark.ingressLegacy": "Upgrade available",
"lark.ingressLegacyDescription": "Save this connection to upgrade to the Agent inbox. Existing messages remain in the same Topic.",
Expand Down Expand Up @@ -1728,6 +1732,10 @@ const zhCN: Record<WorkspaceMessageKey, string> = {
"lark.conversationKind": "连接用途",
"lark.managerConversation": "管家 · 同步对话",
"lark.workerConversation": "工作 Agent · 后台任务",
"lark.turnTrigger": "何时回应",
"lark.triggerAddressed": "仅 @ 或回复管家时",
"lark.triggerHuman": "群成员直接发消息,无需 @",
"lark.triggerDescription": "此群所有成员都可以发起对话;机器人消息和补拉历史只作背景。需要飞书应用具备接收群内所有消息的权限;工具权限和委派授权保持不变。",
"lark.managerConversationDescription": "内置管家即时回应对话,长任务交给后台 Agent。群聊与私聊分别保存上下文。",
"lark.ingressLegacy": "待升级",
"lark.ingressLegacyDescription": "保存连接即可升级为 Agent 异步收件箱,已有消息保留在原 Topic 中。",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ export function LarkSettingsPage({
const [chatId, setChatId] = useState("");
const [chatLoading, setChatLoading] = useState(false);
const [chatLoadError, setChatLoadError] = useState<string | null>(null);
const [turnTrigger, setTurnTrigger] = useState<"addressed" | "human_messages">("addressed");
const [captureScope, setCaptureScope] = useState<LarkCaptureScope>("addressed_only");
const [conversationKind, setConversationKind] = useState<"goal" | "manager">(focusGoalConnection && initialGoalId ? "goal" : "manager");
const [ingressMode, setIngressMode] = useState<LarkIngressMode>("async_inbox");
Expand Down Expand Up @@ -375,6 +376,7 @@ export function LarkSettingsPage({
setConnectAllAgents(false);
setAgentAppRefs({});
setCaptureScope("addressed_only");
setTurnTrigger("addressed");
setIngressMode("async_inbox");
setReplyMode("topic_reply");
setChatQuery("");
Expand All @@ -394,6 +396,7 @@ export function LarkSettingsPage({
setConnectAllAgents(false);
setAgentAppRefs({});
setCaptureScope(connection.capture_scope);
setTurnTrigger(connection.turn_trigger ?? "addressed");
setIngressMode(connection.ingress_mode === "direct_session" ? "async_inbox" : connection.ingress_mode);
setReplyMode(connection.reply_mode);
setChatQuery(connection.chat_name);
Expand Down Expand Up @@ -457,6 +460,7 @@ export function LarkSettingsPage({
chatName: selectedChat!.chat_name,
}),
conversationKind,
...(conversationKind === "manager" ? { turnTrigger } : {}),
captureScope: conversationKind === "manager" ? "addressed_only" as const : captureScope,
goalId,
incomingMode: captureScope === "configured_chat_all" ? "all" as const : "mentions" as const,
Expand Down Expand Up @@ -554,7 +558,7 @@ export function LarkSettingsPage({
) : null}
</span>
<span><strong>{connection.goal_title}</strong><small># {connection.topic_name}</small></span>
<span>{connection.capture_scope === "addressed_only" ? t("lark.mentionsOnly") : t("lark.allTopicMessages")}</span>
<span>{connection.conversation_kind === "manager" ? t(connection.turn_trigger === "human_messages" ? "lark.triggerHuman" : "lark.triggerAddressed") : connection.capture_scope === "addressed_only" ? t("lark.mentionsOnly") : t("lark.allTopicMessages")}</span>
<span><strong>{connection.conversation_kind === "manager" ? t("lark.managerConversation") : ingressPresentation(connection.ingress_mode, t).label}</strong><small>{connection.conversation_kind === "manager" ? t("lark.managerConversationDescription") : connection.agent_id ?? ingressPresentation(connection.ingress_mode, t).detail}</small></span>
<span className="personal-lark-row-actions">
<button aria-label={t("lark.settingsConfigure", { goal: connection.goal_title })} onClick={() => openConnectionEditor(connection)} type="button"><Settings2 size={15} /></button>
Expand Down Expand Up @@ -593,6 +597,7 @@ export function LarkSettingsPage({
</>}
<label className="personal-lark-check"><input checked readOnly type="checkbox" /><span><strong>{t("lark.createAutomatically")}</strong><small>{t("lark.createAutomaticallyDescription")}</small></span></label>
<label><span>{t("lark.topicPreview")}</span><div className="personal-lark-topic-preview"><MessageSquareText size={15} /># {selectedGoal?.title ?? selectedGoal?.goalId ?? "Goal"}</div></label>
{conversationKind === "manager" ? <label><span>{t("lark.turnTrigger")}</span><select aria-label={t("lark.turnTrigger")} value={turnTrigger} onChange={(event) => setTurnTrigger(event.target.value as "addressed" | "human_messages")}><option value="addressed">{t("lark.triggerAddressed")}</option><option value="human_messages">{t("lark.triggerHuman")}</option></select><small>{t("lark.triggerDescription")}</small></label> : null}
{conversationKind === "goal" ? <>
<label><span>{t("lark.captureScope")}</span><select aria-label={t("lark.captureScope")} disabled={editingConnection?.ingress_mode === "direct_session"} onChange={(event) => setCaptureScope(event.target.value as LarkCaptureScope)} value={captureScope}><option value="addressed_only">{t("lark.captureAddressed")}</option><option value="configured_chat_all">{t("lark.captureAll")}</option></select><small>{t("lark.captureScopeDescription")}</small></label>
<fieldset aria-label={t("lark.agentIngress")} className="personal-lark-ingress"><legend>{t("lark.agentIngress")}</legend><div>{(["live_steering", "session_queue", "async_inbox"] as const).map((mode) => { const presentation = ingressPresentation(mode, t); return <label className={ingressMode === mode ? "is-active" : ""} key={mode}><input aria-label={presentation.label} checked={ingressMode === mode} name="lark-agent-ingress" onChange={() => setIngressMode(mode)} type="radio" value={mode} /><span><strong>{presentation.label}</strong><small>{presentation.detail}</small></span></label>; })}</div></fieldset>
Expand Down
7 changes: 7 additions & 0 deletions docs/architecture/rfcs/loopx-overall-roadmap-v0.md
Original file line number Diff line number Diff line change
Expand Up @@ -621,6 +621,13 @@ callback; neither may appear as work completed or a second user-visible reply.
This uses the R3 collaboration owner and current managed Turn wake instead of
a separate steward scheduler.

R3 entry qualification also covers ordinary human messages in an explicitly
configured steward group without a mention. The shared TS admission rule owns
trigger semantics; Lark supplies provider identity and the existing inbox owns
deduplication and return. App settings select and read back the trigger per
connection. External host-tool permission and sender-bound delegation remain
separate gaps; receiving a request does not establish execution authority.

### R4: Shared Goal Alignment and Evolution

- **Owner:** alignment RFC Stage 3–5 and TS Goal/work-graph owners.
Expand Down
5 changes: 5 additions & 0 deletions docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,11 @@ R3 还需验证 handoff RFC 的分层 A6/A8/A13 扩展:普通 managed worker
fixture 在未决工具、取消、迟到结果下区分 inbox 收件、后续 queue 工作和已采用
steer;传输成功不关闭请求。复用现有 R2/R3 后继,不另开平行团队编排项目。

R3 入口验收也覆盖显式配置的管家群内无需 @ 的普通用户消息。触发语义由共享
TS 对话准入规则负责;Lark 提供消息身份,现有收件箱负责去重和回传。App 设置
按连接选择并回读触发方式。外部渠道的工具权限、发信人绑定的委派授权仍需单独
验收;收到请求不代表已经具备执行授权。

### R4:共享目标对齐与演化

- **Owner:** alignment RFC Stage 3–5;TS Goal/work-graph owner。
Expand Down
14 changes: 14 additions & 0 deletions docs/product/use-cases/steward/golden-queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,20 @@ must finish before work on the next layer can start. GQ17 repeats the ordinary
parallel-work intent at larger fixture sizes; basic parallel work is already P0
in GQ11, and the steward should not overstaff a small task.

### Direct group conversation / 群里直接说话

For the Lark variant of P0 reliable entry, configure the steward connection to
respond without mentions. Send “帮我总结一下当前进展。” / “Summarize the current
progress” as an ordinary new human message, then a concise correction. Require
one retained request and one checked answer through the existing conversation;
replay the same provider message and prove there is no second execution/reply.
Disable the option and verify that another unaddressed message remains context
while a mention or verified bot reply still works. Bots, missing sender evidence,
historical backfill (even with an old mention), an unrelated group and a worker's
own Topic are negative cases. This admission probe does not qualify autonomous
execution: the external read-only profile and recipient grants must be evaluated
separately. Passing transport fixtures is not evidence of a deployed group run.

### App-first execution profiles and ordinary questions

Qualify the installed App first; Lark is independently scored, not required to
Expand Down
4 changes: 3 additions & 1 deletion examples/personal-workspace-browser-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@ import { newestDraftScenario } from "./personal-workspace-browser/newest-draft.m
import { conversationInputScenario } from "./personal-workspace-browser/conversation-input.mjs";
import { goalActivityScenario } from "./personal-workspace-browser/goal-activity.mjs";

const scenarioCatalog = [conversationInputScenario, goalActivityScenario, conversationActivityScenario, navigationSortingScenario, automationCadenceScenario, chatRecoveryScenario, conversationReturnContinuityScenario, answerPresentationScenario, loopxModeScenario, teamEvidenceScenario, managedGoalResultsScenario, typedActionsScenario, teamPlanScenario, stewardJourneyScenario, executionChipScenario, stewardModelSettingsScenario, progressiveLoadingScenario, workspaceLocaleScenario, newestDraftScenario];
import {stewardGroupTriggerScenario} from "./personal-workspace-browser/steward-group-trigger.mjs";

const scenarioCatalog = [stewardGroupTriggerScenario, conversationInputScenario, goalActivityScenario, conversationActivityScenario, navigationSortingScenario, automationCadenceScenario, chatRecoveryScenario, conversationReturnContinuityScenario, answerPresentationScenario, loopxModeScenario, teamEvidenceScenario, managedGoalResultsScenario, typedActionsScenario, teamPlanScenario, stewardJourneyScenario, executionChipScenario, stewardModelSettingsScenario, progressiveLoadingScenario, workspaceLocaleScenario, newestDraftScenario];
const requestedScenario = process.env.LOOPX_PERSONAL_WORKSPACE_SCENARIO;
const scenarios = requestedScenario
? scenarioCatalog.filter((scenario) => scenario.id === requestedScenario)
Expand Down
3 changes: 2 additions & 1 deletion examples/personal-workspace-browser/fixture.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1326,7 +1326,7 @@ export async function installApi(page, { goalSubagentConfigurationEnabled = true
const existing = runtime.larkConnections.find((item) => item.connection_id === body.connection_id && item.goal_id === body.goal_id);
if (!existing || body.app_ref || body.chat_id || body.agent_bindings) throw new Error("Editing must select the stored connection without replacing its identity");
if (body.execute) {
Object.assign(existing, { agent_id: body.agent_id, ingress_mode: body.ingress_mode, capture_scope: body.capture_scope });
Object.assign(existing, { turn_trigger: body.turn_trigger ?? existing.turn_trigger, agent_id: body.agent_id, ingress_mode: body.ingress_mode, capture_scope: body.capture_scope });
state.larkWrites.push({ ...body });
}
await route.fulfill({ contentType: "application/json", json: { ok: true, status: body.execute ? "connected" : "preview_ready" }, status: 200 });
Expand All @@ -1344,6 +1344,7 @@ export async function installApi(page, { goalSubagentConfigurationEnabled = true
runtime.larkConnections.push({
agent_id: body.conversation_kind === "manager" ? "loopx-manager" : binding.agent_id ?? null,
conversation_kind: body.conversation_kind ?? "goal",
turn_trigger: body.turn_trigger ?? "addressed",
connection_id: connectionId,
app_label: binding.app_ref === "mew-research" ? "LoopX Research" : "LoopX Mew", app_ref: binding.app_ref, chat_name: body.chat_name, enabled: true,
capture_scope: body.capture_scope,
Expand Down
41 changes: 41 additions & 0 deletions examples/personal-workspace-browser/steward-group-trigger.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import {resolve} from "node:path";
import {outputDir} from "./fixture.mjs";
import {openWorkspacePage} from "./scenario-context.mjs";

export const stewardGroupTriggerScenario = {
id: "steward-group-trigger",
async run({browser, collectCoverage, url}) {
const context = await openWorkspacePage(browser, url, {collectCoverage});
const {page, api} = context;
try {
await page.getByRole("button", {name: "设置", exact: true}).click();
await page.locator(".personal-settings-tabs").getByRole("button", {name: "Lark", exact: true}).click();
await page.getByRole("button", {name: "连接 Lark App", exact: true}).click();
const dialog = page.getByRole("dialog", {name: "连接 Lark App"});
await dialog.getByRole("option", {name: "Product group"}).waitFor({state: "attached"});
await dialog.getByLabel("群聊").selectOption({label: "Product group"});
const trigger = dialog.getByLabel("何时回应");
if (await trigger.inputValue() !== "addressed") throw new Error("Legacy mention default changed");
await trigger.selectOption("human_messages");
await page.screenshot({path: resolve(outputDir, "steward-group-trigger.png"), animations: "disabled"});
await dialog.getByRole("button", {name: "连接", exact: true}).click();
await dialog.waitFor({state: "hidden"});
const row = page.locator(".personal-lark-table-row", {hasText: "Product group"});
await row.getByText("群成员直接发消息,无需 @", {exact: true}).waitFor();
if (api.larkWrites.length !== 1 || api.larkWrites[0].turn_trigger !== "human_messages") throw new Error("Trigger not saved");
await row.getByRole("button", {name: /配置/}).click();
const editor = page.getByRole("dialog");
if (await editor.getByLabel("何时回应").inputValue() !== "human_messages") throw new Error("Trigger not read back");
await page.setViewportSize({width: 390, height: 844});
await editor.getByLabel("何时回应").scrollIntoViewIfNeeded();
await page.screenshot({path: resolve(outputDir, "steward-group-trigger-mobile.png"), animations: "disabled"});
await editor.getByLabel("何时回应").selectOption("addressed");
await editor.getByRole("button", {name: "保存连接", exact: true}).click();
await editor.waitFor({state: "hidden"});
await row.getByText("仅 @ 或回复管家时", {exact: true}).waitFor();
if (api.larkWrites[1]?.turn_trigger !== "addressed") throw new Error("Trigger not restored");
if (context.errors.length) throw new Error(context.errors.join(" | "));
return {coverageEntries: await context.close(), note: "Steward group trigger opt-in, persisted readback and revocation; desktop and narrow screen"};
} catch (error) { await context.close(); throw error; }
},
};
7 changes: 7 additions & 0 deletions loopx/chat_lark_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,7 @@ def _lark_connect(self) -> None:
"capture_scope",
"connection_id",
"conversation_kind",
"turn_trigger",
"executor_endpoint_id",
"chat_id",
"chat_name",
Expand Down Expand Up @@ -623,6 +624,11 @@ def _lark_connect(self) -> None:
or stored_routing.get("conversation_kind")
or "goal"
)
if "turn_trigger" in body:
from .control_plane.collaboration import conversation_trigger
if conversation_kind != "manager" or app_refs_by_agent is not None:
raise ValueError("turn_trigger is only supported for manager connections")
conversation_trigger(body["turn_trigger"])
# The machine owns its manager channel's executor, so the machine
# setting -- not a stored connection field or a request field --
# decides which endpoint this connection runs on and which Session
Expand Down Expand Up @@ -743,6 +749,7 @@ def _lark_connect(self) -> None:
agent_id=agent_id,
connection_id=connection_id,
conversation_kind=conversation_kind,
turn_trigger=body.get("turn_trigger"),
executor_endpoint_id=executor_endpoint_id
if conversation_kind == "manager"
else None,
Expand Down
13 changes: 11 additions & 2 deletions loopx/control_plane/collaboration/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,18 @@

from __future__ import annotations

from typing import Any
from typing import Any, cast

from ..effect_runtime import effect_runtime_result
from ..effect_runtime import EffectRuntimeRejected, effect_runtime_result


def conversation_trigger(mode: str | None = None, **evidence: bool) -> dict[str, Any]:
try:
return cast(dict[str, Any], effect_runtime_result("collaboration.conversation.trigger", {
"mode": mode, **evidence,
}))
except EffectRuntimeRejected as exc:
raise ValueError(str(exc)) from exc


def conversation_scope(session: dict[str, Any], *, origin: str | None = None) -> dict[str, Any]:
Expand Down
Loading
Loading