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
14 changes: 13 additions & 1 deletion apps/presentation/dashboard/src/views/dashboard-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1486,8 +1486,20 @@ function PersonalGoalHome({
const defaultAgentId = discoveredAgents.find((agent) => agent.label === "Codex" && agent.available)?.agentId
?? discoveredAgents.find((agent) => agent.available)?.agentId
?? "status-only";
// The manager channel answers on the executor this machine declares for the
// steward, and the client is expected to send no endpoint until the operator
// picks one. The picker therefore has to show that same resolution: an
// executor merely discovered on this machine is not a reason to present
// itself as the steward's runtime, or the header chip, the composer and the
// answer would tell three different stories.
const declaredStewardEndpoint = managerChannelBinding?.executor_endpoint?.trim() ?? "";
const stewardExecutorAgentId = declaredStewardEndpoint
? discoveredAgents.find((agent) => agent.agentId === declaredStewardEndpoint)?.agentId
: undefined;
const agentDefaultForContext = (targetContextId: string) =>
targetContextId === "manager" ? stewardExecutorAgentId ?? defaultAgentId : defaultAgentId;
const [selectedAgents, setSelectedAgents] = useState<Record<string, string>>(readPersonalAgentSelections);
const selectedAgentId = selectedAgents[contextId] ?? defaultAgentId;
const selectedAgentId = selectedAgents[contextId] ?? agentDefaultForContext(contextId);
const selectedAgent = selectAvailableChatAgent(agentOptions, selectedAgentId, defaultAgentId);
const [agentMenuOpen, setAgentMenuOpen] = useState(false);
const [detailsOpen, setDetailsOpen] = useState(false);
Expand Down
36 changes: 36 additions & 0 deletions docs/architecture/rfcs/harness-selection-dsh-pi-v0.md
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,42 @@ Validation: `tests/capabilities/test_steward_executor_machine_defaults.py`,
`tests/capabilities/test_capability_configuration_ui.py`, and
`examples/loopx-steward-channel-binding-smoke.py`.

### Steward Answer Identity and Runtime Selection (2026-09-16)

Once a machine could declare its steward executor, the Dashboard was still
answering two different questions with one value: *who answered me* and *which
runtime served the turn*. The transcript titled a steward answer with whatever
the chat-runtime picker happened to hold, and the picker itself preferred a
`Codex` adapter whenever one was discovered on the machine -- so a channel
resolving to the managed host could still present itself as an individual CLI
login, and the header chip, the composer and the answer could each name a
different executor.

Two rules now hold on the manager channel:

* **Answer identity names the speaker.** The transcript labels a steward answer
`LoopX Manager` / `LoopX 管家` on every path that can produce one -- return
receipts, resumed history, recovery streaming, the streaming placeholder, the
completion fallback, interruption and failure. The executor and its model
stay in the machine-capability chip, which is the surface that reports them.
Goal channels keep naming the Goal's own Agent.
* **Runtime selection resolves the way the channel resolves.** The chat-runtime
picker follows the channel owner's precedence for the manager context: the
declared steward executor first, the shipped default only when the machine
declares nothing. A discovered adapter is never presented as the steward.

An explicit operator pick still wins for that context, and the client still
sends no endpoint when the operator made no pick, so the create-session contract
in `loopx/chat_server.py` -- "each channel resolves its own default through its
own owner" -- is unchanged. An explicit pick is also the only path that moves
this channel off the declared executor, which keeps a discovered CLI from
silently rewriting a machine decision.

Evidence: `examples/personal-workspace-browser-smoke.mjs` (`execution-chip`
scenario for the picker and composer resolution, `chat-recovery` scenario for
the answer identity), plus a live readback on the installed Dashboard where the
header chip resolved `dsh` while the picker previously reported `Codex`.

## Steward Team Intake (2026-09-16)

The steward answers questions. Since `2026-09-16` its shipped guidance also
Expand Down
26 changes: 26 additions & 0 deletions docs/architecture/rfcs/harness-selection-dsh-pi-v0.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,32 @@ journal 与配额语义;B 作为上游接口出现时的低成本替代;只
`tests/capabilities/test_capability_configuration_ui.py`,以及
`examples/loopx-steward-channel-binding-smoke.py`。

### 管家回答身份与 Runtime 选择(2026-09-16)

机器可以声明管家执行器之后,Dashboard 仍在用一个值回答两个不同的问题:**谁在回答我**,
以及**哪个 runtime 跑了这一回合**。会话记录用聊天 Runtime 选择器当时持有的值给管家回答
署名;而选择器本身只要在本机发现 `Codex` 适配器就优先选它——于是解析到托管宿主的通道
仍可能把自己呈现成个人 CLI 登录,页头 chip、输入框与回答可以各说各的执行器。

管家通道上现在有两条规则:

* **回答身份署名说话者。** 会话记录在**所有**能产生管家回答的路径上署名
`LoopX 管家` / `LoopX Manager`:交接回执、恢复的历史、恢复流式、流式占位、
完成兜底、中断与失败。执行器与模型留在机器能力 chip 上,那才是回报它们的表面。
Goal 通道继续署名该 Goal 自己的 Agent。
* **Runtime 选择按通道的解析方式解析。** 在管家上下文里,聊天 Runtime 选择器遵循通道
属主的优先级:先看本机声明的管家执行器,只有本机什么都没声明时才回落到出货默认值。
一个被发现的适配器永远不会被呈现成管家。

运维方的显式选择仍然在该上下文里优先;运维方没有选择时,客户端依旧不发送任何端点,
因此 `loopx/chat_server.py` 的建会话契约——"每个通道通过自己的属主解析自己的默认值"
——没有变化。显式选择也是这条通道离开已声明执行器的唯一路径,这让一个被发现的 CLI
不会静默改写一项机器决定。

证据:`examples/personal-workspace-browser-smoke.mjs`(`execution-chip` 场景覆盖选择器与
输入框的解析,`chat-recovery` 场景覆盖回答身份),以及在已安装 Dashboard 上的一次现场
回读:页头 chip 解析为 `dsh`,而选择器此前报的是 `Codex`。

## 管家团队入端口径(2026-09-16)

管家负责回答问题。自 2026-09-16 起,它的出厂指引里还带了一段有界流程,用于另一类请求:
Expand Down
58 changes: 57 additions & 1 deletion examples/personal-workspace-browser/execution-chip.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,62 @@ export const executionChipScenario = {
coverageEntries.push(...await managed.close());
}

// The chat-runtime picker has to report the same resolution as the chip: a
// managed host declared for this machine is what will answer, so neither
// the header trigger nor the composer may keep advertising the CLI the
// machine merely happens to have installed.
const managedHostAdapters = [{
adapter_kind: "deepseek_harness_segment",
agent_id: "dsh",
available: true,
display_name: "DeepSeek Harness (managed)",
interrupt: false,
resume: true,
streaming: false,
}];
const stewardPicker = await openWorkspacePage(browser, url, {
apiOptions: { managerChannelBinding: selectedManagedBinding, runtimeAgents: managedHostAdapters },
collectCoverage,
});
try {
const pickerLabel = (await stewardPicker.page
.locator("div.personal-agent-select button.personal-select-trigger")
.innerText()).replace(/\s+/g, " ").trim();
if (!pickerLabel.includes("DeepSeek Harness (managed)")) {
throw new Error(`Chat runtime picker ignored the declared steward executor: ${pickerLabel}`);
}
if (pickerLabel.includes("Codex")) {
throw new Error(`Chat runtime picker advertised a discovered CLI as the steward: ${pickerLabel}`);
}
const composerLabel = (await stewardPicker.page
.locator(".personal-channel-composer > span")
.first()
.innerText()).trim();
if (composerLabel !== "DeepSeek Harness (managed)") {
throw new Error(`Composer named ${composerLabel} instead of the steward's declared executor`);
}
} finally {
coverageEntries.push(...await stewardPicker.close());
}

// A machine that declares no steward executor keeps the shipped default,
// so the parity above is a resolution the control plane asked for and not
// a new hard-coded preference.
const undeclaredSteward = await openWorkspacePage(browser, url, {
apiOptions: { runtimeAgents: managedHostAdapters },
collectCoverage,
});
try {
const pickerLabel = (await undeclaredSteward.page
.locator("div.personal-agent-select button.personal-select-trigger")
.innerText()).replace(/\s+/g, " ").trim();
if (pickerLabel !== "Chat Codex") {
throw new Error(`An undeclared steward executor no longer used the shipped default: ${pickerLabel}`);
}
} finally {
coverageEntries.push(...await undeclaredSteward.close());
}

// A managed host that cannot launch here names the missing fact, and never
// claims the channel lacks a transport it now has.
for (const [binding, expected] of [
Expand Down Expand Up @@ -306,7 +362,7 @@ export const executionChipScenario = {
}
return {
coverageEntries,
note: "execution chip reports the selected executor, the credential it is billed to and the resolved model, names the steward channel's shipped default without claiming a credential branch, keeps a configured credential from moving it, names why a selected managed host cannot launch, and stays absent without a binding",
note: "execution chip reports the selected executor, the credential it is billed to and the resolved model, names the steward channel's shipped default without claiming a credential branch, keeps a configured credential from moving it, keeps the chat-runtime picker on the executor the machine declares for the steward, names why a selected managed host cannot launch, and stays absent without a binding",
};
},
};
4 changes: 2 additions & 2 deletions examples/personal-workspace-browser/fixture.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,7 @@ function filterStatusFixtureToScope(fixture, statusGeneration, scope) {
}
}

export async function installApi(page, { goalSubagentConfigurationEnabled = true, initialActionProposals = [], managerChannelBinding = null } = {}) {
export async function installApi(page, { goalSubagentConfigurationEnabled = true, initialActionProposals = [], managerChannelBinding = null, runtimeAgents = null } = {}) {
let turnCounter = 0;
const runtime = page.__loopxRuntime ??= { actionProposals: new Map(), goalSubagentConfigurations: new Map(), larkConnections: [], messages: new Map(), sessions: new Map(), turnMessages: new Map() };
const actionProposals = runtime.actionProposals;
Expand Down Expand Up @@ -1251,7 +1251,7 @@ export async function installApi(page, { goalSubagentConfigurationEnabled = true
{ agent_id: "codex", display_name: "Codex", adapter_kind: "codex_app_server", available: true, streaming: true, resume: true, interrupt: true },
{ agent_id: "claude-code", display_name: "Claude Code", adapter_kind: "claude_code_cli", available: true, streaming: true, resume: true, interrupt: true },
{ agent_id: "offline-agent", display_name: "Offline Agent", adapter_kind: "acp", available: false, streaming: false, resume: false, interrupt: false },
],
].concat(runtimeAgents ?? []),
}, status: 200 });
return;
}
Expand Down
2 changes: 1 addition & 1 deletion examples/personal-workspace-browser/typed-actions.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1611,7 +1611,7 @@ export const typedActionsScenario = {
await page.keyboard.press("Escape");
if (await agentListbox.isVisible().catch(() => false)) throw new Error("Agent menu did not close on Escape");
if (!(await agentSelect.evaluate((element) => element === document.activeElement))) throw new Error("Agent menu did not restore trigger focus");
pass(14, "Codex remained the healthy default and the unavailable Agent option was disabled with explanation.");
pass(14, "With no declared steward executor the shipped Codex default held, and the unavailable Agent option was disabled with explanation.");
await agentSelect.click();
const reopenedAgentListbox = page.getByRole("listbox", { name: "选择聊天 Runtime" });
await reopenedAgentListbox.getByRole("option", { name: "Claude Code", exact: true }).click();
Expand Down
Loading