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
15 changes: 11 additions & 4 deletions docs/dialog-improvement-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -357,7 +357,7 @@ Query 参数:

| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `projectKey` | string | | 项目注册表中的标识 |
| `projectKey` | string | | 仅兼容旧客户端;模型目录为全局配置,不按项目过滤或扫描项目 |
| `query` | string | 否 | 按 provider、model、displayName 检索 |
| `provider` | string | 否 | 过滤 provider |
| `includeAuto` | boolean | 否 | 是否在 Router 可用时返回 auto |
Expand Down Expand Up @@ -388,6 +388,7 @@ type ModelCatalogItem = {
};

type ModelsResponse = {
defaultSelection: { mode: "model"; provider: string; model: string };
items: ModelCatalogItem[];
router: {
enabled: boolean;
Expand All @@ -408,6 +409,10 @@ type ModelsResponse = {
- 未返回的能力表示该模型不支持对应参数。
- Router 开启且支持 auto 时,接口可返回 `{ provider: "router", model: "auto" }` 虚拟条目。

Web Composer 使用同一浏览器、同一站点的全局模型偏好:首次使用 `defaultSelection`,用户手动选择后保存完整模型及参数,跨项目、会话、刷新和标签页复用。系统默认值变化只影响未手动选择的用户;不可用的已选模型保留并提示重新选择。旧项目和会话草稿不参与全局偏好恢复,避免无法确定时间顺序时任意继承旧选择。

Composer 不再请求会话模型 GET/PUT 来恢复或保存选择。每条消息仍携带提交时的 `modelSelection` 快照,运行中和排队消息不受后续选择影响。下列会话 API 为兼容已有客户端保留,不决定 Web Composer 的全局选择。模型目录由页面内共享缓存复用,配置重载或 WebSocket 重连后失效。

### 6.2 查询会话模型设置

`GET /api/sessions/model?sessionKey=<sessionKey>&projectKey=<projectKey>`
Expand Down Expand Up @@ -500,6 +505,7 @@ Gateway WebSocket 方法:`submit_turn`

```ts
type SessionModelOverride = {
mode: "model";
provider: string;
model: string;
reasoning?: number;
Expand All @@ -522,21 +528,22 @@ type SubmitTurnRequest = {
mode?: "default" | "plan" | "bypassPermissions";
basePermissionMode?: "default" | "plan" | "bypassPermissions";
modelOverride?: SessionModelOverride;
modelSelection?: { mode: "auto" } | SessionModelOverride;
runId?: string;
};
```

`modelOverride` 仅覆盖当前 turn,不修改第 6.3 节保存的会话模型设置。未传 `modelOverride` 时使用保存的会话设置;会话未设置时,Router 开启则使用 auto,否则使用系统默认模型。
Web Composer 始终提交 `modelSelection` 快照,可明确选择具体模型或 Auto,并随已接收输入记录。`modelOverride` 为已有客户端保留,只覆盖当前 turn;两者不能同时传入。仅当两者都未传时,才使用兼容的会话设置;会话未设置时,Router 开启则使用 auto,否则使用系统默认模型。

服务端校验:

- `modelOverride.provider/model` 必须存在且可用。
- 显式选择的 `provider/model` 必须存在且可用;Auto 必须有可用 Router
- reasoning、temperature、speed 必须满足模型 capabilities;speed 使用 `0..1` 的统一数值语义。
- `uploadedAttachments` 必须属于同一 `projectKey`、状态为 completed 且未过期。
- `mode` 和 `basePermissionMode` 必须属于声明枚举。
- 校验失败时不得启动模型调用。

模型选择优先级:本轮 `modelOverride` > 已保存的会话模型 > Router auto > 系统默认模型。
模型选择优先级:本轮 `modelSelection` 或 `modelOverride`(互斥)> 兼容会话设置 > Router auto > 系统默认模型。Web Composer 始终发送快照,所以不依赖会话回退

响应为 Gateway 事件流,新增事件:

Expand Down
14 changes: 11 additions & 3 deletions docs/trd-dialog-improvement.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,9 @@ type UploadedAttachmentRef = {

### 9.1 模型目录

`GET /api/models?projectKey=&query=&provider=&includeAuto=`
`GET /api/models?query=&provider=&includeAuto=`

模型目录直接读取全局配置,不枚举项目或会话;旧客户端传入的 `projectKey` 仅为兼容保留。返回 `defaultSelection` 明确指定系统默认模型,目录顺序不影响选择。

返回 provider、model、displayName、available 以及 reasoning(推理强度)、temperature 和可选 speed 的能力声明。对话框统一使用 0..1 的数值语义;每个模型可通过能力声明限制可用范围、步长或枚举值,后端负责把 0..1 值映射为 Provider 所需参数。temperature 和 speed 统一范围为 0..1。官方 OpenAI / Anthropic 模型默认声明 speed;自定义模型需显式 `supportsSpeed: true`,且 Google Provider 当前不支持该字段。目录将 speed 暴露为枚举 `0`(标准)与 `1`(快速)。

Expand All @@ -227,6 +229,7 @@ type UploadedAttachmentRef = {

```ts
type SessionModelOverride = {
mode: "model";
provider: string;
model: string;
reasoning?: number;
Expand All @@ -235,11 +238,16 @@ type SessionModelOverride = {
};
type GatewaySubmitTurnInput = ExistingGatewaySubmitTurnInput & {
modelOverride?: SessionModelOverride;
modelSelection?: { mode: "auto" } | SessionModelOverride;
uploadedAttachments?: UploadedAttachmentRef[];
};
```

### 9.3 会话模型状态
### 9.3 Web 全局偏好与兼容会话模型状态

Web Composer 只保存同一浏览器、同一站点内最近一次手动选择的模型及参数,不区分项目或会话,并同步其他标签页。未手动选择时使用系统默认模型;手动选择后,配置默认值变化、会话切换、提交确认和运行结果都不改写偏好。已选模型不可用时提示重新选择,不自动切换。

Composer 不再调用下列会话模型读写接口。它在准备附件之前固定每条提交的 `modelSelection`,队列、编辑重发和历史执行记录继续保留各自的快照。会话 API 保留给已有客户端,其保存值不参与 Web 全局选择恢复。

新增会话模型读写接口:

Expand All @@ -249,7 +257,7 @@ type GatewaySubmitTurnInput = ExistingGatewaySubmitTurnInput & {

保存值写入 session metadata,会话恢复后继续生效。`mode=auto` 仅在 Router 开启时允许;清除设置后,Router 开启则回到 auto,Router 关闭则回到 `agent.model`。

`submit_turn.modelOverride` 只覆盖本轮,不修改会话保存值。模型解析顺序:本轮 `modelOverride` > 会话保存模型 > Router auto/路由决策 > `agent.model` 默认模型。
`submit_turn.modelSelection` 为 Web 提交时固定的模型快照,随已接收输入记录;`modelOverride` 只覆盖本轮,不修改会话保存值。两者互斥,均优先于兼容会话设置。只有两者都未传时才使用会话保存模型 > Router auto/路由决策 > `agent.model` 默认模型;Web Composer 不依赖这条回退路径

`provider/model` 不存在或不可用返回 `INVALID_MODEL_OVERRIDE`;reasoning、temperature 或 speed 不满足模型能力返回 `UNSUPPORTED_MODEL_PARAMETER`。未声明支持的参数不发送给 Provider。speed 必须在 canonical request 入口通过 `0..1` 校验,再由支持 speed 的 Provider adapter 映射为原生字段;Google Provider 不声明或接收 speed。

Expand Down
2 changes: 2 additions & 0 deletions src/agent/protocol/input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,6 @@ export type AgentSubmitOptions = {
*/
syntheticMessages?: import("../../model/index.js").CanonicalMessage[];
modelOverride?: AgentModelOverride;
/** Submitted model snapshot, recorded for replay and legacy session clients. */
modelSelection?: NonNullable<import("../../session/transcript/TranscriptEntry.js").SessionMetadataValue["modelSelection"]>;
};
1 change: 1 addition & 0 deletions src/agent/session/AgentSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ export class AgentSession {
permissionRules: submitOptions.permissionRules,
syntheticMessages: submitOptions.syntheticMessages,
modelOverride: submitOptions.modelOverride,
modelSelection: submitOptions.modelSelection,
abortSignal: this.state.abortController.signal,
openSteerMailbox: () => this.steerMailbox.start(turnId),
drainSteerMessages: () => this.steerMailbox.drain(turnId),
Expand Down
14 changes: 10 additions & 4 deletions src/agent/turn/TurnRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export type TurnRunnerOptions = {
/** Synthetic messages appended after user input; stored with metadata.synthetic flag. */
syntheticMessages?: CanonicalMessage[];
modelOverride?: AgentModelOverride;
modelSelection?: NonNullable<SessionMetadataValue["modelSelection"]>;
openSteerMailbox?: () => void;
drainSteerMessages?: () => AgentSteerMessage[];
drainOrCloseSteerMailbox?: () => { messages: AgentSteerMessage[]; closed: boolean };
Expand Down Expand Up @@ -488,12 +489,15 @@ export class TurnRunner {

const snapshot = metadataStore.getSnapshot();
const prompt = allHumanText(acceptedMessages);
if (!prompt) return;
if (!prompt && !options.modelSelection) return;

const boundedPrompt = prompt.slice(0, SESSION_LISTING_PROMPT_MAX_CHARS);
const boundedPrompt = prompt?.slice(0, SESSION_LISTING_PROMPT_MAX_CHARS);
await metadataStore.record(options.turnId, {
...(snapshot.firstPrompt ? {} : { firstPrompt: boundedPrompt }),
lastPrompt: boundedPrompt,
...(boundedPrompt ? {
...(snapshot.firstPrompt ? {} : { firstPrompt: boundedPrompt }),
lastPrompt: boundedPrompt,
} : {}),
...(options.modelSelection ? { modelSelection: { ...options.modelSelection } } : {}),
updatedAt: this.now().toISOString(),
}).catch(() => {});
}
Expand All @@ -505,6 +509,8 @@ function isVisibleFailureStatus(status: AgentStatusMessageInput): boolean {

function acceptedInputMetadata(options: TurnRunnerOptions): Record<string, unknown> | undefined {
const metadata: Record<string, unknown> = {};
// Save alongside input so a crash before the metadata snapshot cannot lose the choice.
if (options.modelSelection) metadata.modelSelection = { ...options.modelSelection };
if (options.permissionMode) {
metadata.permissionMode = options.permissionMode;
}
Expand Down
14 changes: 11 additions & 3 deletions src/cli/createLocalGateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -382,8 +382,7 @@ export function createLocalGateway(options: CreateLocalGatewayOptions = {}): Cre
return listCommands({ ...input, projectKey }, pilotHome);
},
async modelCatalogList(input) {
const projectKey = await dialogProjects.resolveProjectKey(input.projectKey);
return listModelCatalog({ ...input, projectKey }, env);
return listModelCatalog(input, env);
},
async sessionModelGet(input) {
const projectKey = await dialogProjects.resolveProjectKey(input.projectKey);
Expand Down Expand Up @@ -415,13 +414,22 @@ export function createLocalGateway(options: CreateLocalGatewayOptions = {}): Cre
},
async resolveTurnModelSelection(input) {
const projectKey = await dialogProjects.resolveProjectKey(input.projectKey ?? fallbackProjectRoot);
if (input.modelSelection !== undefined && input.modelOverride !== undefined) {
throw new DialogGatewayError("INVALID_MODEL_OVERRIDE", "Specify modelSelection or modelOverride, not both.");
}
if (input.modelSelection !== undefined) {
validateModelSelection(projectKey, input.modelSelection, env);
return input.modelSelection.mode === "model"
? { selection: input.modelSelection, source: "turn" as const }
: { source: "router" as const };
}
if (input.modelOverride) {
validateExplicitModelSelection(projectKey, input.modelOverride, env);
return { selection: input.modelOverride, source: "turn" as const };
}
const saved = await readSavedModel(projectKey, input.sessionKey);
if (saved) validateModelSelection(projectKey, saved, env);
if (saved?.mode === "model") {
validateExplicitModelSelection(projectKey, saved, env);
return { selection: saved, source: "session" as const };
}
const config = loadPilotConfig({ projectRoot: projectKey, env }).config;
Expand Down
15 changes: 12 additions & 3 deletions src/gateway/client/InProcessGateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -554,10 +554,13 @@ export class InProcessGateway implements Gateway {
}));
const modelSelection = this.options.resolveTurnModelSelection
? await this.options.resolveTurnModelSelection(input)
: input.modelOverride
? { selection: input.modelOverride, source: "turn" as const }
: { source: "default" as const };
: input.modelSelection?.mode === "auto"
? { source: "router" as const }
: input.modelSelection?.mode === "model" || input.modelOverride
? { selection: input.modelSelection?.mode === "model" ? input.modelSelection : input.modelOverride, source: "turn" as const }
: { source: "default" as const };
let lastEmittedModel: string | undefined;
let actualRequestModel: string | undefined;
if (modelSelection.selection) {
const event: GatewayEvent = {
type: "model_selection_changed",
Expand All @@ -577,6 +580,7 @@ export class InProcessGateway implements Gateway {
agentInput,
{
turnId: runId,
modelSelection: input.modelSelection,
maxTurns: input.maxTurns,
runMode,
permissionMode,
Expand Down Expand Up @@ -620,6 +624,7 @@ export class InProcessGateway implements Gateway {
if (event.type === "input_accepted") {
await this.commitAcceptedTurnReplacement(input.sessionKey, runId);
}
if (event.type === "model_event" && event.event.type === "request_started") actualRequestModel = event.event.model;
if (event.type === "model_event" && event.event.type === "request_started"
&& lastEmittedModel !== `${event.event.provider}\0${event.event.model}`) {
const selectionEvent: GatewayEvent = {
Expand All @@ -634,6 +639,10 @@ export class InProcessGateway implements Gateway {
lastEmittedModel = `${event.event.provider}\0${event.event.model}`;
}
for (const gatewayEvent of mapAgentEvent(event, runId)) {
if (gatewayEvent.type === "assistant_text_delta" && actualRequestModel) gatewayEvent.model = actualRequestModel;
if (gatewayEvent.type === "input_accepted" && input.modelSelection) {
gatewayEvent.modelSelection = { ...input.modelSelection };
}
if (gatewayEvent.type === "context_budget") {
this.recordGatewayStatusMessage({
sessionKey: input.sessionKey,
Expand Down
11 changes: 7 additions & 4 deletions src/gateway/dialog/modelCatalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,7 @@ const REASONING_VALUES = new Map<number, ThinkingMode>([
]);

export function listModelCatalog(input: ModelCatalogListInput, env: NodeJS.ProcessEnv = process.env): ModelCatalogListResult {
if (!input.projectKey?.trim()) throw new DialogGatewayError("PROJECT_NOT_FOUND", "projectKey is required.");
const config = loadPilotConfig({ projectRoot: input.projectKey, env }).config;
const config = loadPilotConfig({ env }).config;
const query = input.query?.trim().toLocaleLowerCase() ?? "";
const items: ModelCatalogItem[] = [];
for (const [providerId, provider] of Object.entries(config.model.providers)) {
Expand Down Expand Up @@ -51,11 +50,15 @@ export function listModelCatalog(input: ModelCatalogListInput, env: NodeJS.Proce
&& (!query || "router auto".includes(query))) {
items.unshift({ id: "router/auto", provider: "router", model: "auto", displayName: "Auto", available: true, capabilities: {} });
}
return { items, router: { enabled: routerEnabled, autoAvailable: routerEnabled } };
return {
items,
defaultSelection: { mode: "model", provider: config.agent.model.provider, model: config.agent.model.model },
router: { enabled: routerEnabled, autoAvailable: routerEnabled },
};
}

export function validateModelSelection(projectKey: string, selection: SessionModelSelection, env: NodeJS.ProcessEnv = process.env): void {
if (selection.mode === "auto") {
if (selection?.mode === "auto") {
if (!listModelCatalog({ projectKey }, env).router.autoAvailable) {
throw new DialogGatewayError("ROUTER_AUTO_UNAVAILABLE", "Router auto is not available for this project.");
}
Expand Down
10 changes: 7 additions & 3 deletions src/gateway/protocol/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ export type GatewaySubmitTurnInput = {
uploadedAttachments?: UploadedAttachmentRef[];
/** A one-turn model override. Persisted session preferences are managed separately. */
modelOverride?: ExplicitModelSelection;
/** Submitted choice: used for this turn and recorded with accepted input; never updates the Web global preference. */
modelSelection?: SessionModelSelection;
runMode?: AgentRunMode;
mode?: GatewayMode;
/** The user's actual permission preference before plan-mode override. */
Expand Down Expand Up @@ -172,7 +174,7 @@ type GatewayTurnScopedEventMetadata = {

export type GatewayEvent = GatewayTurnScopedEventMetadata & (
| { type: "turn_started"; runId: string }
| { type: "input_accepted"; runId: string }
| { type: "input_accepted"; runId: string; modelSelection?: SessionModelSelection }
| { type: "steer_applied"; itemId: string; message: CanonicalMessage }
| { type: "steer_unapplied"; itemId: string; reason: "turn_ended" }
| { type: "model_request_started"; model?: string; provider?: string }
Expand All @@ -185,7 +187,7 @@ export type GatewayEvent = GatewayTurnScopedEventMetadata & (
temperature?: number;
speed?: number;
}
| { type: "assistant_text_delta"; text: string }
| { type: "assistant_text_delta"; text: string; model?: string }
| { type: "assistant_attachment"; attachment: GatewayOutboundAttachment }
| { type: "file_artifacts"; artifacts: import("../../session/artifacts/FileArtifact.js").FileArtifact[] }
| { type: "assistant_thinking_delta"; text: string }
Expand Down Expand Up @@ -467,13 +469,15 @@ export type ModelCatalogItem = {
};

export type ModelCatalogListInput = {
projectKey: string;
/** Accepted for compatibility; the model catalog is global. */
projectKey?: string;
query?: string;
provider?: string;
includeAuto?: boolean;
};

export type ModelCatalogListResult = {
defaultSelection: ExplicitModelSelection;
items: ModelCatalogItem[];
router: { enabled: boolean; autoAvailable: boolean };
};
Expand Down
2 changes: 2 additions & 0 deletions src/model/protocol/canonical.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,8 @@ export type CanonicalContentBlock =
| CanonicalMediaReferenceBlock;

export type CanonicalMessageMetadata = {
/** Actual model that generated this assistant message. */
model?: string;
/** True for messages injected by the system (e.g. JSON self-correct prompts). */
synthetic?: boolean;
/** Synthetic prompt that should be consumed by the next assistant response only. */
Expand Down
4 changes: 4 additions & 0 deletions src/model/streaming/assembleModelMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
export type ModelMessageAssemblerState = {
content: CanonicalContentBlock[];
textBuffer: string;
model?: string;
thinkingBuffer: string;
thinkingReasoningContentBuffer: string;
thinkingSignature?: string;
Expand Down Expand Up @@ -67,6 +68,8 @@ export function applyModelEventToAssembler(
): void {
switch (event.type) {
case "request_started":
state.model = event.model;
return;
case "message_start":
case "tool_call_start":
case "tool_call_delta":
Expand Down Expand Up @@ -151,6 +154,7 @@ export function assembleAssistantMessage(state: ModelMessageAssemblerState): Ass
message: {
role: "assistant",
content: [...state.content],
...(state.model ? { metadata: { model: state.model } } : {}),
},
finishReason: state.finishReason ?? (state.error ? "error" : "unknown"),
hasMessageEnd: state.hasMessageEnd,
Expand Down
6 changes: 6 additions & 0 deletions src/session/transcript/TranscriptReplay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ export function replayTranscriptEntries(entries: AgentTranscriptEntry[]): AgentT

switch (entry.type) {
case "accepted_input":
if (entry.metadata?.modelSelection) {
const choice = entry.metadata.modelSelection as SessionMetadataValue["modelSelection"];
if (choice?.mode === "auto" || (choice?.mode === "model" && typeof choice.provider === "string" && typeof choice.model === "string")) {
metadata = mergeMetadata(metadata, { modelSelection: { ...choice } });
}
}
if (!beforeBoundary) {
messages.push(...cloneMessages(entry.messages));
events.push({
Expand Down
Loading
Loading