feat(agent): add support for selecting Codex model and reasoning effort in the canvas agent, retaining choices for subsequent tasks

This commit is contained in:
HouYunFei
2026-07-31 11:03:19 +08:00
parent 3c6af91e8d
commit 9330bdb318
9 changed files with 164 additions and 12 deletions
+8 -3
View File
@@ -7,7 +7,7 @@ import stripAnsi from "strip-ansi";
import { VERSION } from "../config.js";
import { logger } from "../utils/logger.js";
import { field, type JsonRecord } from "../utils/value.js";
import type { CodexNotificationParams, CodexPlanUpdate, CodexRequestMethod, CodexRequestParams, CodexRequestResult, CodexTurnInput } from "./codex-protocol.js";
import type { CodexNotificationParams, CodexPlanUpdate, CodexReasoningEffort, CodexRequestMethod, CodexRequestParams, CodexRequestResult, CodexTurnInput } from "./codex-protocol.js";
import type { AgentEmit, AgentPermissionMode } from "./types.js";
type AgentEvent = JsonRecord & { type: string; usage?: unknown };
@@ -92,6 +92,11 @@ export class CodexAppClient {
return this.request("thread/archive", { threadId });
}
/** 查询当前账号可用的 Codex 模型。 */
listModels() {
return this.request("model/list", { limit: 100, includeHidden: false });
}
/** 返回指定线程在当前进程中收到的最新任务计划。 */
planUpdates(threadId: string) {
return [...this.plansByTurn.values()].filter((item) => item.threadId === threadId);
@@ -105,9 +110,9 @@ export class CodexAppClient {
}
/** 启动一个 Codex turn 并等待完成通知。 */
async startTurn(threadId: string, prompt: string, images: string[], permissionMode: AgentPermissionMode, onTurn?: (turnId: string) => void) {
async startTurn(threadId: string, prompt: string, images: string[], permissionMode: AgentPermissionMode, model?: string, effort?: CodexReasoningEffort, onTurn?: (turnId: string) => void) {
this.currentThreadId = threadId;
const { turn } = await this.request("turn/start", { threadId, input: codexInput(prompt, images), ...turnSettings(permissionMode) });
const { turn } = await this.request("turn/start", { threadId, input: codexInput(prompt, images), ...turnSettings(permissionMode), ...(model ? { model } : {}), ...(effort ? { effort } : {}) });
const turnId = turn.id;
if (!turnId) throw new Error("Codex app-server 没有返回 turn id");
this.currentTurnId = turnId;
+14 -1
View File
@@ -6,6 +6,15 @@ export type CodexTurnError = JsonRecord & { message: string };
export type CodexItem = JsonRecord & { id: string; type: string; text?: string };
export type CodexPlanStep = { step: string; status: "pending" | "inProgress" | "completed" };
export type CodexPlanUpdate = { threadId: string; turnId: string; explanation?: string | null; plan: CodexPlanStep[]; turnStatus?: string };
export type CodexReasoningEffort = "minimal" | "low" | "medium" | "high" | "xhigh";
export type CodexModel = JsonRecord & {
id: string;
model: string;
displayName: string;
defaultReasoningEffort: CodexReasoningEffort;
supportedReasoningEfforts: Array<{ reasoningEffort: CodexReasoningEffort; description?: string }>;
isDefault?: boolean;
};
export type CodexTurnInput =
| { type: "text"; text: string; text_elements: [] }
@@ -53,8 +62,12 @@ type CodexRequestSpec = {
params: { threadId: string };
result: Record<string, never>;
};
"model/list": {
params: { limit: number; includeHidden: boolean };
result: { data: CodexModel[]; nextCursor: string | null };
};
"turn/start": {
params: { threadId: string; input: CodexTurnInput[]; approvalPolicy: "never" | "on-request"; sandboxPolicy: { type: "workspaceWrite"; networkAccess: boolean } | { type: "dangerFullAccess" } };
params: { threadId: string; input: CodexTurnInput[]; approvalPolicy: "never" | "on-request"; sandboxPolicy: { type: "workspaceWrite"; networkAccess: boolean } | { type: "dangerFullAccess" }; model?: string; effort?: CodexReasoningEffort };
result: { turn: CodexTurn };
};
"turn/interrupt": {
+9 -3
View File
@@ -6,9 +6,10 @@ import { logger } from "../utils/logger.js";
import { errorMessage, field } from "../utils/value.js";
import { CodexAppClient } from "./codex-client.js";
import { summarizeCodexThread, threadMessages } from "./codex-history.js";
import type { CodexReasoningEffort } from "./codex-protocol.js";
import type { AgentAttachment, AgentEmit, AgentPermissionMode } from "./types.js";
type CodexRunOptions = { threadId?: string; cwd?: string; permissionMode?: AgentPermissionMode; appEmit?: AgentEmit; onStart?: () => void; onThread?: (threadId: string) => void; onTurn?: (turnId: string) => void; onFinish?: () => void };
type CodexRunOptions = { threadId?: string; cwd?: string; permissionMode?: AgentPermissionMode; model?: string; effort?: CodexReasoningEffort; appEmit?: AgentEmit; onStart?: () => void; onThread?: (threadId: string) => void; onTurn?: (turnId: string) => void; onFinish?: () => void };
let codexQueue: Promise<unknown> = Promise.resolve();
let codexApp: CodexAppClient | null = null;
@@ -71,6 +72,11 @@ export async function listCodexThreads(emit: AgentEmit, options: { cwd: string;
return { data, nextCursor: field(result, "nextCursor") || null, backwardsCursor: field(result, "backwardsCursor") || null };
}
/** 查询当前账号可用于新任务的 Codex 模型。 */
export async function listCodexModels(emit: AgentEmit) {
return await (await getCodexApp(emit)).listModels();
}
/** 读取指定 Codex 线程及其聊天历史。 */
export async function readCodexThread(emit: AgentEmit, threadId: string, cwd?: string) {
const app = await getCodexApp(emit);
@@ -115,7 +121,7 @@ async function runCodexTurnNow(prompt: string, emit: AgentEmit, attachments: Age
options.onThread?.(threadId);
unmaterializedThreadIds.delete(threadId);
try {
await app.startTurn(threadId, prompt, files, options.permissionMode || "request", options.onTurn);
await app.startTurn(threadId, prompt, files, options.permissionMode || "request", options.model, options.effort, options.onTurn);
} catch (error) {
if (!isRecoverableThreadError(error)) throw error;
emit("agent_log", { text: `Codex thread unavailable, starting a new thread: ${errorMessage(error)}` });
@@ -123,7 +129,7 @@ async function runCodexTurnNow(prompt: string, emit: AgentEmit, attachments: Age
threadId = await ensureCodexThread(app, { cwd: options.cwd }, emit);
options.onThread?.(threadId);
unmaterializedThreadIds.delete(threadId);
await app.startTurn(threadId, prompt, files, options.permissionMode || "request", options.onTurn);
await app.startTurn(threadId, prompt, files, options.permissionMode || "request", options.model, options.effort, options.onTurn);
}
} catch (error) {
logger.error("Codex turn failed", error);
+9 -1
View File
@@ -4,7 +4,8 @@ import path from "node:path";
import express, { type NextFunction, type Request, type Response } from "express";
import { runClaudeTurn } from "../agent/claude.js";
import { archiveCodexThread, interruptCodexTurn, isRecoverableThreadError, listCodexThreads, readCodexThread, resolveCodexApproval, resumeCodexThread, runCodexTurn, startCodexThread, summarizeCodexThread, verifyCodexThreadWorkspace } from "../agent/codex.js";
import { archiveCodexThread, interruptCodexTurn, isRecoverableThreadError, listCodexModels, listCodexThreads, readCodexThread, resolveCodexApproval, resumeCodexThread, runCodexTurn, startCodexThread, summarizeCodexThread, verifyCodexThreadWorkspace } from "../agent/codex.js";
import type { CodexReasoningEffort } from "../agent/codex-protocol.js";
import type { AgentAttachment, AgentPermissionMode } from "../agent/types.js";
import { CanvasSession } from "../canvas/session.js";
import { DEFAULT_PORT, ensureSiteWorkspace, loadConfig, saveConfig, updateSiteWorkspace, type CanvasAgentConfig } from "../config.js";
@@ -96,6 +97,7 @@ export function startHttpServer() {
const workspace = ensureSiteWorkspace(config);
res.json({ ok: true, workspace });
});
app.get("/agent/codex/models", route(async (_req, res) => res.json({ ok: true, ...(await listCodexModels(emit)) })));
app.get("/agent/codex/threads", route(async (req, res) => {
const workspace = ensureSiteWorkspace(config);
const result = await listCodexThreads(emit, { cwd: workspace.workspacePath, searchTerm: String(req.query.searchTerm || "") });
@@ -174,6 +176,8 @@ export function startHttpServer() {
threadId,
cwd: workspace.workspacePath,
permissionMode: permissionMode(req.body?.permissionMode),
model: String(req.body?.model || "") || undefined,
effort: reasoningEffort(req.body?.effort),
appEmit: emit,
onStart: clientId ? () => session.bindClient(clientId) : undefined,
onThread: (actualThreadId) => {
@@ -249,6 +253,10 @@ function permissionMode(value: unknown): AgentPermissionMode {
return value === "automatic" || value === "full" ? value : "request";
}
function reasoningEffort(value: unknown): CodexReasoningEffort | undefined {
return value === "minimal" || value === "low" || value === "medium" || value === "high" || value === "xhigh" ? value : undefined;
}
/** 使用当前操作系统的文件管理器定位本地文件。 */
function revealLocalFile(filePath: string, isDirectory: boolean) {
const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer.exe" : "xdg-open";