feat: 完善codex支持

This commit is contained in:
hangerye
2025-12-17 10:09:26 +08:00
parent 87f6402259
commit 9566a02341
6 changed files with 352 additions and 25 deletions
+81 -17
View File
@@ -46,34 +46,98 @@ export const CLAUDE_MODELS: ModelDefinition[] = [
export const CLAUDE_DEFAULT_MODEL = 'claude-sonnet-4-5-20250929';
// ============================================================
// Codex Models
// Codex Models (aligned with other/cweb)
// ============================================================
export const CODEX_MODELS: ModelDefinition[] = [
{
id: 'o3',
name: 'o3',
id: 'gpt-5',
name: 'GPT-5',
description: 'OpenAI flagship reasoning model',
},
{
id: 'gpt-4.1',
name: 'GPT-4.1',
description: 'OpenAI GPT-4.1 model',
},
{
id: 'o4-mini',
name: 'o4 Mini',
description: 'Fast and efficient model',
},
{
id: 'claude-sonnet-4-5-20250929',
name: 'Claude Sonnet 4.5 (via Codex)',
description: 'Anthropic model via Codex',
id: 'gpt-4o',
name: 'GPT-4o',
description: 'General-purpose model with multimodal support',
supportsImages: true,
},
{
id: 'gpt-4o-mini',
name: 'GPT-4o Mini',
description: 'Cost-efficient GPT-4o variant',
supportsImages: true,
},
{
id: 'o1-preview',
name: 'o1 Preview',
description: 'OpenAI o1 preview model focused on agent use-cases',
},
{
id: 'o1-mini',
name: 'o1 Mini',
description: 'Lightweight o1 model for faster iterations',
},
{
id: 'o3',
name: 'o3',
description: 'OpenAI o3 reasoning model',
},
{
id: 'claude-3.5-sonnet',
name: 'Claude 3.5 Sonnet (via Codex)',
description: 'Anthropic Claude via Codex router',
},
{
id: 'claude-3-haiku',
name: 'Claude 3 Haiku (via Codex)',
description: 'Anthropic Haiku model routed through Codex',
},
];
export const CODEX_DEFAULT_MODEL = 'o3';
export const CODEX_DEFAULT_MODEL = 'gpt-5';
// Codex model alias normalization (aligned with other/cweb)
const CODEX_ALIAS_MAP: Record<string, string> = {
gpt5: 'gpt-5',
gpt_5: 'gpt-5',
'gpt-5.0': 'gpt-5',
'gpt-4o-mini-high': 'gpt-4o-mini',
'gpt-4o-mini-low': 'gpt-4o-mini',
'claude-sonnet-3.5': 'claude-3.5-sonnet',
'claude35-sonnet': 'claude-3.5-sonnet',
};
const CODEX_KNOWN_IDS = new Set(CODEX_MODELS.map((model) => model.id));
/**
* Normalize a Codex model ID, handling aliases and falling back to default.
*/
export function normalizeCodexModelId(model?: string | null): string {
if (!model || typeof model !== 'string') {
return CODEX_DEFAULT_MODEL;
}
const trimmed = model.trim();
if (!trimmed) {
return CODEX_DEFAULT_MODEL;
}
const lower = trimmed.toLowerCase();
if (CODEX_ALIAS_MAP[lower]) {
return CODEX_ALIAS_MAP[lower];
}
if (CODEX_KNOWN_IDS.has(lower)) {
return lower;
}
// If the exact casing exists, allow it
if (CODEX_KNOWN_IDS.has(trimmed)) {
return trimmed;
}
return CODEX_DEFAULT_MODEL;
}
// ============================================================
// Cursor Models
@@ -271,6 +271,8 @@ export class AgentChatService {
resumeClaudeSessionId: engineName === 'claude' ? resumeClaudeSessionId : undefined,
// Pass useCcr flag for Claude Code Router support (ClaudeEngine only)
useCcr: engineName === 'claude' ? projectUseCcr : undefined,
// Pass Codex-specific configuration (CodexEngine only)
codexConfig: engineName === 'codex' ? dbSession?.optionsConfig?.codexConfig : undefined,
};
// Create abort controller for cancellation support
+192 -8
View File
@@ -2,6 +2,11 @@ import { spawn } from 'node:child_process';
import readline from 'node:readline';
import path from 'node:path';
import { randomUUID } from 'node:crypto';
import {
CODEX_AUTO_INSTRUCTIONS,
DEFAULT_CODEX_CONFIG,
type CodexEngineConfig,
} from 'chrome-mcp-shared';
import type { AgentEngine, EngineExecutionContext, EngineInitOptions } from './types';
import type { AgentMessage, RealtimeEvent } from '../types';
import { AgentToolBridge } from '../tool-bridge';
@@ -41,7 +46,16 @@ export class CodexEngine implements AgentEngine {
private static readonly MAX_STDERR_LINES = 200;
async initializeAndRun(options: EngineInitOptions, ctx: EngineExecutionContext): Promise<void> {
const { sessionId, instruction, model, projectRoot, requestId, signal, attachments } = options;
const {
sessionId,
instruction,
model,
projectRoot,
requestId,
signal,
attachments,
codexConfig,
} = options;
const repoPath = this.resolveRepoPath(projectRoot);
// Check if already aborted
@@ -54,6 +68,22 @@ export class CodexEngine implements AgentEngine {
throw new Error('CodexEngine: instruction must not be empty');
}
// Merge user config with defaults
const resolvedConfig: CodexEngineConfig = {
...DEFAULT_CODEX_CONFIG,
...(codexConfig ?? {}),
};
// Ensure autoInstructions has a value
if (!resolvedConfig.autoInstructions?.trim()) {
resolvedConfig.autoInstructions = CODEX_AUTO_INSTRUCTIONS;
}
// Optionally append project context to the prompt
const prompt = resolvedConfig.appendProjectContext
? await this.appendProjectContext(normalizedInstruction, repoPath)
: normalizedInstruction;
const executable = process.platform === 'win32' ? 'codex.cmd' : 'codex';
const args: string[] = [
'exec',
@@ -66,6 +96,9 @@ export class CodexEngine implements AgentEngine {
repoPath,
];
// Add Codex configuration arguments
args.push(...this.buildCodexConfigArgs(resolvedConfig));
if (model && model.trim()) {
args.push('--model', model.trim());
}
@@ -86,7 +119,7 @@ export class CodexEngine implements AgentEngine {
}
}
args.push(normalizedInstruction);
args.push(prompt);
// Use explicit Promise wrapping to ensure child process errors are properly rejected.
return new Promise<void>((resolve, reject) => {
@@ -111,6 +144,8 @@ export class CodexEngine implements AgentEngine {
let assistantMessageId: string | null = null;
let assistantCreatedAt: string | null = null;
const streamedToolHashes = new Set<string>();
const activeCommands = new Map<string, { command?: string }>();
const thinkingSegments: string[] = [];
/**
* Cleanup and settle the promise (resolve or reject).
@@ -188,9 +223,40 @@ export class CodexEngine implements AgentEngine {
rl = readline.createInterface({ input: child.stdout });
/**
* Build the assistant message payload, combining thinking and agent content.
*/
const buildAssistantPayload = (): string => {
const trimmedAssistant = assistantBuffer.trim();
const thinkingContent = thinkingSegments
.map((segment) => segment.trim())
.filter((segment) => segment.length > 0)
.map((segment) => `<thinking>${segment}</thinking>`)
.join('\n\n');
const parts: string[] = [];
if (thinkingContent) {
parts.push(thinkingContent);
}
if (trimmedAssistant) {
parts.push(trimmedAssistant);
}
return parts.join('\n\n').trim();
};
/**
* Reset assistant buffers after emitting a final message.
*/
const resetAssistantBuffers = (): void => {
assistantBuffer = '';
thinkingSegments.length = 0;
assistantMessageId = null;
assistantCreatedAt = null;
};
// Helper: emit assistant message
const emitAssistant = (isFinal: boolean): void => {
const content = assistantBuffer.trim();
const content = buildAssistantPayload();
if (!content) return;
if (!assistantMessageId) {
@@ -251,7 +317,9 @@ export class CodexEngine implements AgentEngine {
// Event handlers for specific item types
const emitCommandStart = (item: Record<string, unknown>): void => {
const id = this.pickFirstString(item.id) ?? randomUUID();
const command = this.pickFirstString(item.command);
activeCommands.set(id, { command });
dispatchToolMessage(
command ? `Running: ${command}` : 'Running command',
{
@@ -266,7 +334,12 @@ export class CodexEngine implements AgentEngine {
};
const emitCommandResult = (item: Record<string, unknown>): void => {
const command = this.pickFirstString(item.command);
const id = this.pickFirstString(item.id);
const tracked = id ? activeCommands.get(id) : undefined;
if (id) {
activeCommands.delete(id);
}
const command = this.pickFirstString(item.command) ?? tracked?.command;
const output = this.pickFirstString(item.aggregated_output) ?? '';
const exitCode = typeof item.exit_code === 'number' ? item.exit_code : undefined;
const status = this.pickFirstString(item.status);
@@ -347,12 +420,18 @@ export class CodexEngine implements AgentEngine {
const record = delta as Record<string, unknown>;
const type = this.pickFirstString(record.type);
if (type === 'agent_message' || type === 'reasoning') {
if (type === 'agent_message') {
const text = this.pickFirstString(record.text);
if (text) {
assistantBuffer += text;
emitAssistant(false);
}
} else if (type === 'reasoning') {
const text = this.pickFirstString(record.text);
if (text) {
thinkingSegments.push(text);
emitAssistant(false);
}
} else if (type === 'todo_list') {
emitTodoListUpdate(record, 'update');
}
@@ -377,12 +456,21 @@ export class CodexEngine implements AgentEngine {
const text = this.pickFirstString(record.text);
if (text) assistantBuffer = text;
emitAssistant(true);
resetAssistantBuffers();
break;
}
case 'reasoning': {
const text = this.pickFirstString(record.text);
if (text) {
thinkingSegments.push(text);
emitAssistant(false);
}
break;
}
default: {
const text = this.pickFirstString(record.text);
if (text) {
assistantBuffer += text;
thinkingSegments.push(text);
emitAssistant(false);
}
break;
@@ -411,12 +499,33 @@ export class CodexEngine implements AgentEngine {
}, timeoutMs);
timeoutHandle.unref?.();
// Cleanup timeout when child closes
child.on('close', () => {
// Cleanup timeout and handle abnormal exit
child.on('close', (code: number | null, closeSignal: NodeJS.Signals | null) => {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
timeoutHandle = null;
}
// If already timed out, settled, or completed normally, do nothing
if (timedOut || settled || hasCompleted) {
return;
}
// Build error detail from exit code and signal
const detailParts: string[] = [];
if (typeof code === 'number') {
detailParts.push(`exit code ${code}`);
}
if (closeSignal) {
detailParts.push(`signal ${closeSignal}`);
}
const detail = detailParts.length > 0 ? detailParts.join(', ') : 'unexpected shutdown';
// Emit final assistant message and mark as failed
emitAssistant(true);
resetAssistantBuffers();
hasCompleted = true;
finish(new Error(`CodexEngine: process terminated (${detail})`));
});
// Main event processing loop (wrapped in IIFE to handle async properly)
@@ -448,6 +557,9 @@ export class CodexEngine implements AgentEngine {
case 'item.failed': {
const item = (event as { item?: unknown }).item ?? null;
handleItemCompleted(item);
// Flush assistant message before throwing (aligned with other/cweb)
emitAssistant(true);
resetAssistantBuffers();
const msg =
(item &&
typeof item === 'object' &&
@@ -457,6 +569,9 @@ export class CodexEngine implements AgentEngine {
throw new Error(msg);
}
case 'error': {
// Flush assistant message before throwing (aligned with other/cweb)
emitAssistant(true);
resetAssistantBuffers();
const msg =
this.pickFirstString((event as { error?: unknown }).error) ||
this.pickFirstString((event as { message?: unknown }).message) ||
@@ -466,6 +581,8 @@ export class CodexEngine implements AgentEngine {
throw new Error(msg);
}
case 'turn.completed':
emitAssistant(true);
resetAssistantBuffers();
hasCompleted = true;
break;
default:
@@ -482,6 +599,7 @@ export class CodexEngine implements AgentEngine {
// Emit final assistant message if not already completed
if (!hasCompleted) {
emitAssistant(true);
resetAssistantBuffers();
hasCompleted = true;
}
@@ -499,6 +617,61 @@ export class CodexEngine implements AgentEngine {
return path.resolve(base);
}
/**
* Append project context (file listing) to the prompt.
* Aligned with other/cweb implementation.
*/
private async appendProjectContext(baseInstruction: string, repoPath: string): Promise<string> {
try {
const fs = await import('node:fs/promises');
const entries = await fs.readdir(repoPath, { withFileTypes: true });
const visible = entries
.filter((entry) => !entry.name.startsWith('.git') && entry.name !== 'AGENTS.md')
.map((entry) => entry.name);
if (visible.length === 0) {
return `${baseInstruction}
<current_project_context>
This is an empty project directory. Work directly in the current folder without creating extra subdirectories.
</current_project_context>`;
}
return `${baseInstruction}
<current_project_context>
Current files in project directory: ${visible.sort().join(', ')}
Work directly in the current directory. Do not create subdirectories unless specifically requested.
</current_project_context>`;
} catch (error) {
console.warn('[CodexEngine] Failed to append project context:', error);
return baseInstruction;
}
}
/**
* Build Codex CLI configuration arguments from the resolved config.
* Aligned with other/cweb implementation for feature parity.
*/
private buildCodexConfigArgs(config: CodexEngineConfig): string[] {
const args: string[] = [];
const pushConfig = (key: string, value: string | number | boolean): void => {
args.push('-c', `${key}=${String(value)}`);
};
pushConfig('include_apply_patch_tool', config.includeApplyPatchTool);
pushConfig('include_plan_tool', config.includePlanTool);
pushConfig('tools.web_search_request', config.enableWebSearch);
pushConfig('use_experimental_streamable_shell_tool', config.useStreamableShell);
pushConfig('sandbox_mode', config.sandboxMode);
pushConfig('max_turns', config.maxTurns);
pushConfig('max_thinking_tokens', config.maxThinkingTokens);
args.push('-c', `instructions=${JSON.stringify(config.autoInstructions)}`);
return args;
}
/**
* Write an attachment to a temporary file and return its path.
*/
@@ -530,6 +703,17 @@ export class CodexEngine implements AgentEngine {
if (globalPath) {
extraPaths.push(globalPath);
}
// Enhanced Windows PATH handling (aligned with other/cweb)
if (process.platform === 'win32') {
const appData = process.env.APPDATA;
const localApp = process.env.LOCALAPPDATA;
if (appData) {
extraPaths.push(path.join(appData, 'npm'));
}
if (localApp) {
extraPaths.push(path.join(localApp, 'Programs', 'nodejs'));
}
}
if (extraPaths.length > 0) {
const currentPath = env.PATH || env.Path || '';
env.PATH = [...extraPaths, currentPath].filter(Boolean).join(path.delimiter);
@@ -1,4 +1,5 @@
import type { AgentAttachment, RealtimeEvent } from '../types';
import type { CodexEngineConfig } from 'chrome-mcp-shared';
export interface EngineInitOptions {
sessionId: string;
@@ -50,6 +51,11 @@ export interface EngineInitOptions {
* Only applicable to ClaudeEngine; when true, CCR will be auto-detected.
*/
useCcr?: boolean;
/**
* Optional Codex-specific configuration overrides.
* Only applicable to CodexEngine; merged with DEFAULT_CODEX_CONFIG.
*/
codexConfig?: Partial<CodexEngineConfig>;
}
/**
@@ -44,6 +44,11 @@ export interface SessionOptionsConfig {
enableFileCheckpointing?: boolean;
sandbox?: Record<string, unknown>;
env?: Record<string, string>;
/**
* Optional Codex-specific configuration overrides.
* Only applicable when using CodexEngine.
*/
codexConfig?: Partial<import('chrome-mcp-shared').CodexEngineConfig>;
}
/**
+66
View File
@@ -183,6 +183,11 @@ export interface AgentSessionOptionsConfig {
enableFileCheckpointing?: boolean;
sandbox?: Record<string, unknown>;
env?: Record<string, string>;
/**
* Optional Codex-specific configuration overrides.
* Only applicable when using CodexEngine.
*/
codexConfig?: Partial<CodexEngineConfig>;
}
/**
@@ -268,3 +273,64 @@ export interface AgentStoredMessage {
createdAt?: string;
requestId?: string;
}
// ============================================================
// Codex Engine Configuration
// ============================================================
/**
* Sandbox mode for Codex CLI execution.
*/
export type CodexSandboxMode = 'read-only' | 'workspace-write' | 'danger-full-access';
/**
* Configuration options for Codex Engine.
* These can be overridden per-session via session settings.
*/
export interface CodexEngineConfig {
/** Enable apply_patch tool for file modifications. Default: true */
includeApplyPatchTool: boolean;
/** Enable plan tool for task planning. Default: true */
includePlanTool: boolean;
/** Enable web search capability. Default: true */
enableWebSearch: boolean;
/** Use experimental streamable shell tool. Default: true */
useStreamableShell: boolean;
/** Sandbox mode for command execution. Default: 'danger-full-access' */
sandboxMode: CodexSandboxMode;
/** Maximum number of turns. Default: 20 */
maxTurns: number;
/** Maximum thinking tokens. Default: 4096 */
maxThinkingTokens: number;
/** Auto instructions for autonomous behavior. Default: AUTO_INSTRUCTIONS */
autoInstructions: string;
/** Append project context (file listing) to prompt. Default: true */
appendProjectContext: boolean;
}
/**
* Default auto instructions for Codex to act autonomously.
* Aligned with other/cweb implementation.
*/
export const CODEX_AUTO_INSTRUCTIONS = `Act autonomously without asking for confirmations.
Use apply_patch to create and modify files directly in the current working directory (do not create subdirectories unless the user explicitly requests it).
Use exec_command to run, build, and test as needed.
You have full permissions. Keep taking concrete actions until the task is complete.
Respect the existing project structure when creating or modifying files.
Prefer concise status updates over questions.`;
/**
* Default configuration for Codex Engine.
* Aligned with other/cweb implementation for feature parity.
*/
export const DEFAULT_CODEX_CONFIG: CodexEngineConfig = {
includeApplyPatchTool: true,
includePlanTool: true,
enableWebSearch: true,
useStreamableShell: true,
sandboxMode: 'danger-full-access',
maxTurns: 20,
maxThinkingTokens: 4096,
autoInstructions: CODEX_AUTO_INSTRUCTIONS,
appendProjectContext: true,
};