diff --git a/electron/main/ai/agent/codex-tool-bridge.ts b/electron/main/ai/agent/codex-tool-bridge.ts new file mode 100644 index 0000000..e730f42 --- /dev/null +++ b/electron/main/ai/agent/codex-tool-bridge.ts @@ -0,0 +1,311 @@ +import type { AiAgentStreamHandlers, AiRunUsage, AppSettings, ToolCallTrace } from '../shared-types' +import { runCodexCli } from '../codex-cli' +import { stripReasoningMarkup } from '../reasoning' +import type { Tool, ToolContext } from './tools/types' + +type CodexToolCall = { + name: string + arguments: Record +} + +type CodexToolEnvelope = { + toolCalls: CodexToolCall[] + finalText: string +} + +export type RunCodexToolAgentParams = { + settings: AppSettings + systemPrompt: string + userPrompt: string + tools: Tool[] + ctx: ToolContext + handlers: AiAgentStreamHandlers + maxSteps: number + disableTools?: boolean +} + +export type CodexToolAgentResult = { + finalText: string + toolCalls: ToolCallTrace[] + iterations: number + usage?: AiRunUsage +} + +const MAX_TOOL_CALLS_PER_STEP = 12 +const MAX_TOOL_RESULT_CHARS = 20_000 + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) +} + +function stableStringify(value: unknown): string { + if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null' + if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]` + const obj = value as Record + const keys = Object.keys(obj).sort() + return `{${keys.map((key) => `${JSON.stringify(key)}:${stableStringify(obj[key])}`).join(',')}}` +} + +function mergeUsage(a: AiRunUsage | undefined, b: AiRunUsage | undefined): AiRunUsage | undefined { + if (!a) return b + if (!b) return a + const sum = (x?: number, y?: number): number | undefined => + x === undefined && y === undefined ? undefined : (x ?? 0) + (y ?? 0) + return { + promptTokens: sum(a.promptTokens, b.promptTokens), + completionTokens: sum(a.completionTokens, b.completionTokens), + totalTokens: sum(a.totalTokens, b.totalTokens), + reasoningTokens: sum(a.reasoningTokens, b.reasoningTokens), + cachedInputTokens: sum(a.cachedInputTokens, b.cachedInputTokens) + } +} + +function jsonCandidates(text: string): string[] { + const trimmed = stripReasoningMarkup(text).trim() + const candidates = [trimmed] + const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i)?.[1]?.trim() + if (fenced) candidates.push(fenced) + const firstBrace = trimmed.indexOf('{') + const lastBrace = trimmed.lastIndexOf('}') + if (firstBrace >= 0 && lastBrace > firstBrace) { + candidates.push(trimmed.slice(firstBrace, lastBrace + 1)) + } + return [...new Set(candidates)] +} + +/** 解析 Codex 返回的宿主工具协议;普通聊天文本返回 null,由调用方直接展示。 */ +export function parseCodexToolEnvelope(text: string): CodexToolEnvelope | null { + for (const candidate of jsonCandidates(text)) { + let value: unknown + try { + value = JSON.parse(candidate) + } catch { + continue + } + if (!isRecord(value) || !Array.isArray(value.toolCalls) || typeof value.finalText !== 'string') { + continue + } + const toolCalls: CodexToolCall[] = [] + let valid = true + for (const rawCall of value.toolCalls.slice(0, MAX_TOOL_CALLS_PER_STEP)) { + if (!isRecord(rawCall) || typeof rawCall.name !== 'string' || !isRecord(rawCall.arguments)) { + valid = false + break + } + const name = rawCall.name.trim() + if (!name) { + valid = false + break + } + toolCalls.push({ name, arguments: rawCall.arguments }) + } + if (valid) return { toolCalls, finalText: value.finalText.trim() } + } + return null +} + +function buildToolProtocolPrompt(tools: Tool[]): string { + const catalog = tools.map((tool) => ({ + name: tool.definition.name, + description: tool.definition.description, + inputSchema: tool.definition.inputSchema + })) + return [ + '【CharacterArc 宿主工具协议】', + '你可以通过下面的 JSON 协议请求 CharacterArc 执行进程内工具。这里的工具由应用宿主执行,不是 Codex CLI 的 shell 工具;即使本地 shell 工具被禁用,也必须按任务需要调用这些宿主工具。', + '每次回复必须只输出一个 JSON 对象,不能使用 Markdown 代码块,也不能在 JSON 前后添加说明:', + '{"toolCalls":[{"name":"工具名","arguments":{"参数":"值"}}],"finalText":""}', + '规则:', + '1. 需要读取资料或生成暂存变更时,把调用写入 toolCalls,并把 finalText 留空。宿主执行后会把结果发给你继续处理。', + '2. 已经得到足够结果时,toolCalls 传空数组,把面向用户的中文回复写入 finalText。', + '3. 修改项目数据必须调用对应的 stage_* 工具进入暂存区,绝不能只在 finalText 里描述拟修改内容,也不要声称工具不可用。', + '4. 只能使用目录里列出的工具和参数;不得虚构工具名。', + '', + '【可用宿主工具目录】', + JSON.stringify(catalog) + ].join('\n') +} + +function buildFollowupPrompt( + originalUserPrompt: string, + observations: Array<{ tool: string; arguments: Record; content: string; isError: boolean }> +): string { + const results = observations.map((item, index) => [ + `### 结果 ${index + 1}:${item.tool}${item.isError ? '(失败)' : ''}`, + `参数:${stableStringify(item.arguments)}`, + item.content.slice(0, MAX_TOOL_RESULT_CHARS) + ].join('\n')).join('\n\n') + return [ + '【原始用户请求】', + originalUserPrompt, + '', + '【本轮已执行的宿主工具结果】', + results, + '', + '请根据结果继续处理。若仍需读取或暂存,继续输出 toolCalls;若任务已完成,输出空 toolCalls 和最终回复。' + ].join('\n') +} + +function formatToolError(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +async function runTextOnly(params: RunCodexToolAgentParams): Promise { + params.handlers.onAgentStatus('正在通过 Codex CLI 思考...', 1, 1) + const result = await runCodexCli( + params.settings, + { system: params.systemPrompt, user: params.userPrompt }, + { signal: params.ctx.signal, handlers: params.handlers } + ) + return { + finalText: stripReasoningMarkup(result.text), + toolCalls: [], + iterations: 1, + usage: result.usage + } +} + +/** + * Codex CLI 本身不能直接持有 Electron 进程内函数,因此用受限 JSON 协议桥接: + * Codex 只选择工具和参数,实际读取与暂存仍由 CharacterArc 宿主执行。 + */ +export async function runCodexToolAgent( + params: RunCodexToolAgentParams +): Promise { + if (params.disableTools || params.tools.length === 0) return await runTextOnly(params) + + const toolsByName = new Map(params.tools.map((tool) => [tool.definition.name, tool])) + const protocolPrompt = buildToolProtocolPrompt(params.tools) + const traces: ToolCallTrace[] = [] + const observations: Array<{ + tool: string + arguments: Record + content: string + isError: boolean + }> = [] + const seenResults = new Map() + let usage: AiRunUsage | undefined + + for (let step = 1; step <= params.maxSteps; step += 1) { + params.ctx.signal.throwIfAborted() + params.handlers.onAgentStatus( + step === 1 ? '正在通过 Codex CLI 思考...' : `Codex CLI 第 ${step} 轮处理中...`, + step, + params.maxSteps + ) + const prompt = step === 1 + ? params.userPrompt + : buildFollowupPrompt(params.userPrompt, observations) + const result = await runCodexCli( + params.settings, + { + system: [params.systemPrompt, protocolPrompt].join('\n\n'), + user: prompt + }, + { + signal: params.ctx.signal, + // 协议 JSON 不能作为聊天正文显示;推理过程仍照常转发。 + handlers: { + onTextDelta: () => {}, + onReasoningDelta: params.handlers.onReasoningDelta + } + } + ) + usage = mergeUsage(usage, result.usage) + const envelope = parseCodexToolEnvelope(result.text) + + // 向下兼容普通文本回复。协议解析失败时仍可聊天,但不会伪造工具执行结果。 + if (!envelope) { + const finalText = stripReasoningMarkup(result.text).trim() + if (finalText) params.handlers.onTextDelta(finalText) + return { finalText, toolCalls: traces, iterations: step, usage } + } + + if (envelope.toolCalls.length === 0) { + if (!envelope.finalText) { + throw new Error('Codex CLI 返回了空的工具协议响应。') + } + params.handlers.onTextDelta(envelope.finalText) + return { + finalText: envelope.finalText, + toolCalls: traces, + iterations: step, + usage + } + } + + let stepHasError = false + for (const [index, call] of envelope.toolCalls.entries()) { + const toolUseId = `codex-${step}-${index + 1}` + const startedAt = Date.now() + params.handlers.onToolUseStart(toolUseId, call.name, call.arguments) + + const fingerprint = `${call.name}:${stableStringify(call.arguments)}` + const cached = seenResults.get(fingerprint) + let content = '' + let isError = false + if (cached) { + content = `(相同工具和参数已执行过,未重复写入。)\n${cached.content}` + isError = cached.isError + } else { + const tool = toolsByName.get(call.name) + if (!tool) { + content = `未知宿主工具:${call.name}。请只使用可用工具目录中的名称。` + isError = true + } else { + try { + const toolResult = await tool.handler(call.arguments, params.ctx) + content = toolResult.content + isError = Boolean(toolResult.isError) + } catch (error) { + content = formatToolError(error) + isError = true + } + } + seenResults.set(fingerprint, { content, isError }) + } + + const durationMs = Date.now() - startedAt + params.handlers.onToolResult( + toolUseId, + call.name, + content.slice(0, 800), + isError, + durationMs + ) + traces.push({ + tool: call.name, + args: call.arguments, + durationMs, + status: isError ? 'error' : 'ok', + ...(isError ? { error: content.slice(0, 240) } : {}) + }) + observations.push({ tool: call.name, arguments: call.arguments, content, isError }) + stepHasError ||= isError + } + + // 兼容少数模型在同一协议响应中同时给出成功工具调用和最终说明。 + if (envelope.finalText && !stepHasError) { + params.handlers.onTextDelta(envelope.finalText) + return { + finalText: envelope.finalText, + toolCalls: traces, + iterations: step, + usage + } + } + } + + const stagedCount = traces.filter((trace) => trace.status === 'ok' && trace.tool.startsWith('stage_')).length + if (stagedCount > 0) { + const finalText = `已生成 ${stagedCount} 条待审阅变更,请在右侧暂存区逐条确认。` + params.handlers.onTextDelta(finalText) + return { + finalText, + toolCalls: traces, + iterations: params.maxSteps, + usage + } + } + throw new Error(`Codex CLI 在 ${params.maxSteps} 轮内未能完成宿主工具任务。`) +} diff --git a/electron/main/ai/agent/run-agent-codex-cli.test.mjs b/electron/main/ai/agent/run-agent-codex-cli.test.mjs new file mode 100644 index 0000000..e51b5d8 --- /dev/null +++ b/electron/main/ai/agent/run-agent-codex-cli.test.mjs @@ -0,0 +1,124 @@ +import assert from 'node:assert/strict' +import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import test from 'node:test' + +import { parseCodexToolEnvelope } from './codex-tool-bridge.ts' +import { runAgent } from './run-agent.ts' + +const codexSettings = { + provider: 'codex-cli', + model: 'default', + apiKey: '', + baseUrl: '', + codexReasoningEffort: 'default', + embeddingModel: '', + imageModel: '', + imageApiKey: '', + imageBaseUrl: '' +} + +test('Codex CLI 可通过宿主工具生成暂存变更,重复调用不会重复写入', async (t) => { + const directory = await mkdtemp(join(tmpdir(), 'characterarc-codex-agent-')) + const executable = join(directory, 'fake-codex') + const toolResponse = JSON.stringify({ + toolCalls: [{ + name: 'stage_worldview', + arguments: { + action: 'create', + type: '地理', + title: '北境冻原', + content: '终年覆盖寒冰。', + reason: '录入用户提供的世界观草稿' + } + }, { + name: 'stage_worldview', + arguments: { + action: 'create', + type: '地理', + title: '北境冻原', + content: '终年覆盖寒冰。', + reason: '录入用户提供的世界观草稿' + } + }], + finalText: '' + }) + const finalResponse = JSON.stringify({ + toolCalls: [], + finalText: '已生成待审阅的世界观变更。' + }) + const script = [ + '#!/usr/bin/env node', + "let prompt = ''", + "process.stdin.on('data', (chunk) => { prompt += chunk })", + `process.stdin.on('end', () => {`, + ` const bridgeEnabled = prompt.includes('"name":"stage_worldview"') && !prompt.includes('进程内工具不可用')`, + ` const text = !bridgeEnabled`, + ` ? JSON.stringify({ toolCalls: [], finalText: '宿主工具桥未启用。' })`, + ` : prompt.includes('change_id=change-1') ? ${JSON.stringify(finalResponse)} : ${JSON.stringify(toolResponse)}`, + ` console.log(JSON.stringify({ type: 'item.completed', item: { type: 'agent_message', text } }))`, + ` console.log(JSON.stringify({ type: 'turn.completed', usage: { input_tokens: 12, output_tokens: 8 } }))`, + '})' + ].join('\n') + await writeFile(executable, script, 'utf8') + await chmod(executable, 0o755) + t.after(async () => { await rm(directory, { recursive: true, force: true }) }) + + const calls = [] + const events = [] + const result = await runAgent({ + settings: { ...codexSettings, codexCliPath: executable }, + systemPrompt: '录入模式:把草稿拆成暂存变更。', + userPrompt: '北境冻原终年覆盖寒冰。', + tools: [{ + definition: { + name: 'stage_worldview', + description: '暂存世界观变更。', + inputSchema: { + type: 'object', + properties: { + action: { type: 'string' }, + type: { type: 'string' }, + title: { type: 'string' }, + content: { type: 'string' }, + reason: { type: 'string' } + }, + required: ['action', 'type', 'title', 'content', 'reason'] + } + }, + handler: async (input) => { + calls.push(input) + return { content: '已暂存世界观新增(change_id=change-1)。尚未写回,需用户确认。' } + } + }], + ctx: { signal: new AbortController().signal, projectId: 'project-1' }, + handlers: { + onTextDelta: (delta) => events.push(['text', delta]), + onReasoningDelta: (delta) => events.push(['reasoning', delta]), + onToolUseStart: (id, name, args) => events.push(['tool-start', id, name, args]), + onToolResult: (id, name, content, isError) => events.push(['tool-result', id, name, content, isError]), + onAgentStatus: (message) => events.push(['status', message]), + onEditApplied() {}, + onEditProposed() {} + }, + maxSteps: 4 + }) + + assert.equal(calls.length, 1) + assert.equal(calls[0].title, '北境冻原') + assert.equal(result.toolCalls.length, 2) + assert.equal(result.toolCalls[0].tool, 'stage_worldview') + assert.equal(result.finalText, '已生成待审阅的世界观变更。') + assert.ok(events.some((event) => event[0] === 'tool-start')) + assert.ok(events.some((event) => event[0] === 'tool-result')) +}) + +test('Codex 宿主工具协议兼容 JSON 代码块,普通聊天文本不会被误解析', () => { + assert.deepEqual( + parseCodexToolEnvelope('```json\n{"toolCalls":[],"finalText":"完成"}\n```'), + { toolCalls: [], finalText: '完成' } + ) + assert.equal(parseCodexToolEnvelope('这是普通聊天回复。'), null) + assert.equal(parseCodexToolEnvelope('{"title":"用户要求的 JSON 内容"}'), null) +}) diff --git a/electron/main/ai/agent/run-agent.ts b/electron/main/ai/agent/run-agent.ts index 309b231..9e00605 100644 --- a/electron/main/ai/agent/run-agent.ts +++ b/electron/main/ai/agent/run-agent.ts @@ -6,7 +6,7 @@ import { stripReasoningMarkup } from '../reasoning' import { isAiStreamIdleTimeoutError } from '../sse' import { resolveSamplingOptions } from '../request-options' import { isCodexCliProvider } from '@shared/ai-provider-catalog' -import { runCodexCli } from '../codex-cli' +import { runCodexToolAgent } from './codex-tool-bridge' export type RunAgentParams = { settings: AppSettings @@ -97,24 +97,7 @@ function mergeUsage(a: AiRunUsage | undefined, b: AiRunUsage | undefined): AiRun export async function runAgent(params: RunAgentParams): Promise { const maxSteps = params.maxSteps ?? 8 if (isCodexCliProvider(params.settings.provider)) { - params.handlers.onAgentStatus('正在通过 Codex CLI 思考...', 1, 1) - const result = await runCodexCli( - params.settings, - { - system: [ - params.systemPrompt, - '当前通过 Codex CLI 文本模式运行,CharacterArc 的进程内工具不可用。请直接根据已提供的上下文回答,不要尝试调用工具。' - ].join('\n\n'), - user: params.userPrompt - }, - { signal: params.ctx.signal, handlers: params.handlers } - ) - return { - finalText: stripReasoningMarkup(result.text), - toolCalls: [], - iterations: 1, - usage: result.usage - } + return await runCodexToolAgent({ ...params, maxSteps }) } const toolCalls: ToolCallTrace[] = [] const toolStartTimes = new Map() diff --git a/package.json b/package.json index 5779da5..b836264 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "license": "MIT", "scripts": { "dev": "electron-vite dev", - "test": "set ELECTRON_RUN_AS_NODE=1&& electron --test electron/shared/continuation-import.test.mjs electron/main/workspace-types.test.mjs renderer/src/features/workspace/outlineReorder.test.mjs renderer/src/features/workspace/volumeCollapseState.test.mjs renderer/src/features/workspace/workbenchMenu.test.mjs renderer/src/features/chapters/chapterFormatting.test.mjs renderer/src/features/ai/taskRegistry.test.mjs renderer/src/features/ai/outlineReferences.test.mjs renderer/src/features/tutorials/tutorials.test.mjs renderer/src/features/knowledge/knowledgeCenter.test.mjs electron/main/story-state-store.test.mjs electron/main/knowledge-document-schema.test.mjs electron/main/ai/settings.test.mjs electron/main/ai/codex-cli.test.mjs electron/main/ai/generate.test.mjs electron/main/ai/provider.test.mjs electron/main/ai/protocol-adapter.test.mjs electron/main/ai/proxy-fetch.test.mjs electron/main/ai/knowledge-retrieval.test.mjs electron/main/ai/state-backfill-store.test.mjs electron/main/ai/state-backfill-task-controller.test.mjs electron/main/ai/runtime/background-task-coordinator.test.mjs electron/main/ai/runtime/chapter-processing-store.test.mjs electron/main/ai/tasks/chapter-session-note.test.mjs electron/main/ai/tasks/outline-context.test.mjs electron/main/ai/tasks/story-state-generation.test.mjs electron/main/ai/tasks/worldview-type.test.mjs electron/main/ai/tasks/ranking-analysis.test.mjs electron/main/ai/tasks/qimao-ranking.test.mjs electron/main/ai/tasks/zongheng-ranking.test.mjs electron/main/ai/runtime-v2/conversation-manager.test.mjs electron/main/ai/runtime-v2/staged-changes-store.test.mjs electron/main/ai/runtime-v2/commit-result.test.mjs electron/main/ai/runtime-v2/context-builder.test.mjs electron/main/ai/runtime-v2/agent-loop.test.mjs electron/main/ai/agent/tools/text-window.test.mjs electron/main/ai/agent/tools/knowledge-tools.test.mjs electron/main/ai/agent/tools/chapter-data-access.test.mjs electron/main/ai/agent/tools/chapter-html-edit.test.mjs electron/main/ai/runtime-v2/tools/stage-chapter-edit-core.test.mjs", + "test": "set ELECTRON_RUN_AS_NODE=1&& electron --test electron/shared/continuation-import.test.mjs electron/main/workspace-types.test.mjs renderer/src/features/workspace/outlineReorder.test.mjs renderer/src/features/workspace/volumeCollapseState.test.mjs renderer/src/features/workspace/workbenchMenu.test.mjs renderer/src/features/chapters/chapterFormatting.test.mjs renderer/src/features/ai/taskRegistry.test.mjs renderer/src/features/ai/outlineReferences.test.mjs renderer/src/features/tutorials/tutorials.test.mjs renderer/src/features/knowledge/knowledgeCenter.test.mjs electron/main/story-state-store.test.mjs electron/main/knowledge-document-schema.test.mjs electron/main/ai/settings.test.mjs electron/main/ai/codex-cli.test.mjs electron/main/ai/generate.test.mjs electron/main/ai/provider.test.mjs electron/main/ai/protocol-adapter.test.mjs electron/main/ai/proxy-fetch.test.mjs electron/main/ai/knowledge-retrieval.test.mjs electron/main/ai/state-backfill-store.test.mjs electron/main/ai/state-backfill-task-controller.test.mjs electron/main/ai/runtime/background-task-coordinator.test.mjs electron/main/ai/runtime/chapter-processing-store.test.mjs electron/main/ai/tasks/chapter-session-note.test.mjs electron/main/ai/tasks/outline-context.test.mjs electron/main/ai/tasks/story-state-generation.test.mjs electron/main/ai/tasks/worldview-type.test.mjs electron/main/ai/tasks/ranking-analysis.test.mjs electron/main/ai/tasks/qimao-ranking.test.mjs electron/main/ai/tasks/zongheng-ranking.test.mjs electron/main/ai/runtime-v2/conversation-manager.test.mjs electron/main/ai/runtime-v2/staged-changes-store.test.mjs electron/main/ai/runtime-v2/commit-result.test.mjs electron/main/ai/runtime-v2/context-builder.test.mjs electron/main/ai/runtime-v2/agent-loop.test.mjs electron/main/ai/agent/run-agent-codex-cli.test.mjs electron/main/ai/agent/tools/text-window.test.mjs electron/main/ai/agent/tools/knowledge-tools.test.mjs electron/main/ai/agent/tools/chapter-data-access.test.mjs electron/main/ai/agent/tools/chapter-html-edit.test.mjs electron/main/ai/runtime-v2/tools/stage-chapter-edit-core.test.mjs", "test:ranking": "set ELECTRON_RUN_AS_NODE=1&& electron --test electron/main/ai/tasks/ranking-analysis.test.mjs", "typecheck": "vue-tsc --noEmit", "build": "pnpm run typecheck && electron-vite build",