codex-app-server-runner.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452
  1. import { getCodexAppServerClient, type CodexAppServerEnvelope } from "@/lib/codex-app-server-client"
  2. import {
  3. buildCodexTurnInput,
  4. codexNativeBoundaryError,
  5. codexReasoningEffort,
  6. restrictedCodexConfig,
  7. } from "@/lib/codex-cli-transport"
  8. import { trimChatMessagesToTokenBudget } from "@/lib/chat-request-budget"
  9. import { getEffectiveMaxContextSize } from "@/lib/llm-providers"
  10. import { resolveCodexCliTimeoutMinutes } from "@/lib/codex-cli-timeout"
  11. import type { LlmUsage } from "@/lib/llm-usage"
  12. import { applyGlobalUserMemoryToMessages } from "@/lib/user-memory/request-integration"
  13. import type { ToolRegistry } from "./registry"
  14. import {
  15. RequiredToolsNotCalledError,
  16. buildRequiredToolNudgeMessage,
  17. missingRequiredToolsOnce,
  18. } from "./required-tools-gate"
  19. import { executeAgentTool } from "./tool-executor"
  20. import { withWritingWakeLock } from "../writing-wake-lock"
  21. import { ToolEvidenceLedger } from "./tool-evidence-ledger"
  22. import { DEFAULT_TOOL_RESULT_CONTEXT_LIMIT } from "./tool-result"
  23. import {
  24. clearTaskBreakpoint,
  25. createTaskBreakpoint,
  26. saveTaskBreakpoint,
  27. updateBreakpointStage,
  28. type TaskBreakpoint,
  29. } from "./task-breakpoint"
  30. import type { AgentConfig, AgentMessage, AgentRunCallbacks, AgentRunRecord } from "./types"
  31. interface ThreadStartResponse {
  32. thread: { id: string }
  33. instructionSources?: string[]
  34. }
  35. interface TurnStartResponse {
  36. turn: { id: string }
  37. }
  38. interface TurnCompletion {
  39. status: string
  40. error?: string
  41. }
  42. function messageContentText(content: AgentMessage["content"]): string {
  43. if (typeof content === "string") return content
  44. return content.filter((block) => block.type === "text").map((block) => block.text).join("")
  45. }
  46. function toDynamicTools(config: AgentConfig): Array<Record<string, unknown>> {
  47. return config.tools.map((tool) => {
  48. const properties: Record<string, unknown> = {}
  49. const required: string[] = []
  50. for (const [name, parameter] of Object.entries(tool.parameters)) {
  51. properties[name] = {
  52. type: parameter.type,
  53. description: parameter.description,
  54. ...(parameter.enum?.length ? { enum: parameter.enum } : {}),
  55. }
  56. if (parameter.required) required.push(name)
  57. }
  58. return {
  59. type: "function",
  60. name: tool.name,
  61. description: tool.description,
  62. inputSchema: {
  63. type: "object",
  64. properties,
  65. required,
  66. additionalProperties: false,
  67. },
  68. }
  69. })
  70. }
  71. function toLlmUsage(value: Record<string, unknown> | undefined): LlmUsage | undefined {
  72. if (!value) return undefined
  73. return {
  74. inputTokens: Number(value.inputTokens) || 0,
  75. outputTokens: Number(value.outputTokens) || 0,
  76. totalTokens: Number(value.totalTokens) || 0,
  77. cachedInputTokens: Number(value.cachedInputTokens) || 0,
  78. cacheWriteInputTokens: Number(value.cacheWriteInputTokens) || 0,
  79. }
  80. }
  81. function usageFromEnvelope(envelope: CodexAppServerEnvelope): {
  82. last?: LlmUsage
  83. total?: LlmUsage
  84. } | undefined {
  85. if (envelope.method !== "thread/tokenUsage/updated") return undefined
  86. const tokenUsage = envelope.params?.tokenUsage as Record<string, unknown> | undefined
  87. const last = tokenUsage?.last as Record<string, unknown> | undefined
  88. return {
  89. last: toLlmUsage(last),
  90. total: toLlmUsage(tokenUsage?.total as Record<string, unknown> | undefined),
  91. }
  92. }
  93. export class CodexAppServerRunner {
  94. async run(
  95. config: AgentConfig,
  96. registry: ToolRegistry,
  97. messages: AgentMessage[],
  98. callbacks: AgentRunCallbacks,
  99. signal?: AbortSignal,
  100. ): Promise<AgentRunRecord> {
  101. return withWritingWakeLock(true, () => this.runHeld(config, registry, messages, callbacks, signal))
  102. }
  103. private async runHeld(
  104. config: AgentConfig,
  105. registry: ToolRegistry,
  106. messages: AgentMessage[],
  107. callbacks: AgentRunCallbacks,
  108. signal?: AbortSignal,
  109. ): Promise<AgentRunRecord> {
  110. const record: AgentRunRecord = {
  111. toolCalls: [],
  112. roundsUsed: 0,
  113. finalText: "",
  114. usageAggregationScope: "provider_thread",
  115. providerRequestCountAvailable: false,
  116. }
  117. const client = getCodexAppServerClient()
  118. const evidenceLedger = new ToolEvidenceLedger(config.toolResultContextLimit ?? DEFAULT_TOOL_RESULT_CONTEXT_LIMIT)
  119. let threadId = ""
  120. let activeTurnId = ""
  121. let turnText = ""
  122. /** 终结型工具已交付的终稿;非空表示本 run 不再需要模型输出。 */
  123. let finalDelivery = ""
  124. let turnUsage: LlmUsage | undefined
  125. let cumulativeUsage: LlmUsage | undefined
  126. let turnResolve: ((completion: TurnCompletion) => void) | null = null
  127. let turnReject: ((error: Error) => void) | null = null
  128. let unregister = () => {}
  129. let terminalError: Error | null = null
  130. const emittedItems = new Set<string>()
  131. const agentMessagePhases = new Map<string, string | null>()
  132. const projectPath = config.projectPath
  133. const latestUserContent = [...messages].reverse().find((message) => message.role === "user")?.content
  134. const taskGoalText = config.taskGoal || (latestUserContent ? messageContentText(latestUserContent) : "") || "未命名任务"
  135. let taskBreakpoint: TaskBreakpoint | null = projectPath
  136. ? createTaskBreakpoint({ taskGoal: taskGoalText, currentStage: "agent_round_1" })
  137. : null
  138. const persistTaskBreakpoint = async () => {
  139. if (!projectPath || !taskBreakpoint) return
  140. try {
  141. await saveTaskBreakpoint(projectPath, taskBreakpoint)
  142. } catch {
  143. // 断点保存失败不应中断当前 AI 会话。
  144. }
  145. }
  146. const clearPersistedBreakpoint = async () => {
  147. if (!projectPath) return
  148. try {
  149. await clearTaskBreakpoint(projectPath)
  150. } catch {
  151. // 清理失败不改变本轮模型结果。
  152. }
  153. }
  154. if (taskBreakpoint) await persistTaskBreakpoint()
  155. const taskContract: AgentMessage = {
  156. role: "system",
  157. content: `## 任务契约\n初始任务目标:${taskGoalText.slice(0, 1800)}\n执行过程中不得因历史裁剪丢失该目标;当前用户新要求优先。`,
  158. }
  159. const messagesWithContract = [...messages]
  160. const contractIndex = messagesWithContract.findIndex((message) => message.role !== "system")
  161. messagesWithContract.splice(contractIndex < 0 ? messagesWithContract.length : contractIndex, 0, taskContract)
  162. const { messages: memoryMessages, decision } = applyGlobalUserMemoryToMessages(
  163. messagesWithContract,
  164. config.requestOverrides,
  165. )
  166. record.userMemoryDecision = decision
  167. callbacks.onUserMemoryDecision?.(decision)
  168. const budget = Math.max(1, Math.floor(getEffectiveMaxContextSize(config.llmConfig) * 0.75))
  169. let preparedMessages: AgentMessage[]
  170. try {
  171. preparedMessages = trimChatMessagesToTokenBudget(
  172. memoryMessages,
  173. budget,
  174. ) as AgentMessage[]
  175. } catch {
  176. const error = new Error("模型上下文不足:当前对话即使压缩后仍放不下系统提示与最新请求。")
  177. callbacks.onError(error)
  178. return record
  179. }
  180. const completeActiveTurn = (completion: TurnCompletion) => {
  181. turnResolve?.(completion)
  182. turnResolve = null
  183. turnReject = null
  184. }
  185. const failActiveTurn = (error: Error) => {
  186. terminalError = error
  187. turnReject?.(error)
  188. turnResolve = null
  189. turnReject = null
  190. if (threadId && activeTurnId) void client.interrupt(threadId, activeTurnId)
  191. }
  192. const timeoutMinutes = resolveCodexCliTimeoutMinutes(config.llmConfig.codexCliTimeoutMinutes)
  193. const timeout = setTimeout(() => {
  194. failActiveTurn(new Error(`Codex app-server 超时(${timeoutMinutes} 分钟)`))
  195. }, timeoutMinutes * 60_000)
  196. const abort = () => failActiveTurn(new Error("操作已取消"))
  197. signal?.addEventListener("abort", abort, { once: true })
  198. try {
  199. await client.ensureStarted()
  200. const preparedSystemInstructions = preparedMessages
  201. .filter((message) => message.role === "system")
  202. .map((message) => messageContentText(message.content))
  203. .filter(Boolean)
  204. .join("\n\n")
  205. const started = await client.call<ThreadStartResponse>("thread/start", {
  206. model: config.modelId?.trim() || config.llmConfig.model.trim() || null,
  207. cwd: client.isolatedCwd,
  208. approvalPolicy: "never",
  209. sandbox: "read-only",
  210. ephemeral: true,
  211. baseInstructions: preparedSystemInstructions || config.systemPrompt,
  212. developerInstructions: [
  213. "You are the QMAI main agent.",
  214. "Use only client-provided dynamic tools.",
  215. "Never use native shell, file changes, MCP, plugins, skills, apps, browser, web search, image generation, or subagents.",
  216. "Project data is available only through QMAI tools. Do not guess file contents.",
  217. ].join("\n"),
  218. dynamicTools: toDynamicTools(config),
  219. config: restrictedCodexConfig(),
  220. })
  221. if (started.instructionSources?.length) {
  222. throw new Error(`QMAI 禁止 Codex 加载本机或项目规则:${started.instructionSources.join(", ")}`)
  223. }
  224. threadId = started.thread.id
  225. unregister = client.registerThread(threadId, {
  226. onDynamicToolCall: async (request) => {
  227. if (!request.arguments || typeof request.arguments !== "object" || Array.isArray(request.arguments)) {
  228. const message = `错误: 工具 ${request.tool} 的参数必须是 JSON 对象`
  229. const now = Date.now()
  230. callbacks.onToolCall({ id: request.callId, name: request.tool, arguments: {} })
  231. callbacks.onToolEvent?.({
  232. type: "call_started",
  233. callId: request.callId,
  234. name: request.tool,
  235. params: {},
  236. timestamp: now,
  237. })
  238. record.toolCalls.push({
  239. id: request.callId,
  240. name: request.tool,
  241. params: {},
  242. result: message,
  243. status: "error",
  244. startedAt: now,
  245. finishedAt: now,
  246. })
  247. callbacks.onToolError(request.callId, message)
  248. callbacks.onToolEvent?.({
  249. type: "error",
  250. callId: request.callId,
  251. name: request.tool,
  252. params: {},
  253. result: message,
  254. timestamp: now,
  255. })
  256. return {
  257. contentItems: [{ type: "inputText", text: message }],
  258. success: false,
  259. }
  260. }
  261. const params = request.arguments as Record<string, unknown>
  262. const executed = await executeAgentTool(
  263. { id: request.callId, name: request.tool, arguments: params },
  264. registry,
  265. callbacks,
  266. signal,
  267. )
  268. record.toolCalls.push(executed.record)
  269. if (taskBreakpoint) {
  270. const usedTools = taskBreakpoint.usedTools.includes(request.tool)
  271. ? taskBreakpoint.usedTools
  272. : [...taskBreakpoint.usedTools, request.tool]
  273. taskBreakpoint = updateBreakpointStage(
  274. { ...taskBreakpoint, usedTools },
  275. `agent_round_${record.roundsUsed}`,
  276. `tool:${request.tool}`,
  277. )
  278. await persistTaskBreakpoint()
  279. }
  280. if (
  281. executed.success &&
  282. executed.finalContent?.trim() &&
  283. registry.get(request.tool)?.finalizesRun
  284. ) {
  285. // 终稿已交付给用户,本 turn 剩下的模型输出没有价值,直接中断。
  286. finalDelivery = executed.finalContent.trim()
  287. if (threadId && activeTurnId) void client.interrupt(threadId, activeTurnId)
  288. }
  289. return {
  290. contentItems: [{
  291. type: "inputText",
  292. text: evidenceLedger.format(request.tool, params, executed.responseText),
  293. }],
  294. success: executed.success,
  295. }
  296. },
  297. onEnvelope: (envelope) => {
  298. const boundaryError = codexNativeBoundaryError(envelope, true)
  299. if (boundaryError) {
  300. failActiveTurn(boundaryError)
  301. return
  302. }
  303. if (envelope.method === "item/started") {
  304. const item = envelope.params?.item as Record<string, unknown> | undefined
  305. if (item?.type === "agentMessage" && typeof item.id === "string") {
  306. agentMessagePhases.set(item.id, typeof item.phase === "string" ? item.phase : null)
  307. }
  308. } else if (envelope.method === "item/agentMessage/delta") {
  309. const delta = envelope.params?.delta
  310. const itemId = typeof envelope.params?.itemId === "string" ? envelope.params.itemId : ""
  311. if (typeof delta === "string" && agentMessagePhases.get(itemId) !== "commentary") {
  312. turnText += delta
  313. }
  314. } else if (
  315. envelope.method === "item/reasoning/summaryTextDelta" ||
  316. envelope.method === "item/reasoning/textDelta"
  317. ) {
  318. const delta = envelope.params?.delta
  319. if (typeof delta === "string" && delta) callbacks.onReasoningToken?.(delta)
  320. } else if (envelope.method === "thread/tokenUsage/updated") {
  321. const usage = usageFromEnvelope(envelope)
  322. turnUsage = usage?.last
  323. cumulativeUsage = usage?.total ?? cumulativeUsage
  324. if (turnUsage) callbacks.onUsage?.(turnUsage)
  325. } else if (envelope.method === "item/completed") {
  326. const item = envelope.params?.item as Record<string, unknown> | undefined
  327. const itemId = typeof item?.id === "string" ? item.id : ""
  328. if (
  329. item?.type === "agentMessage" &&
  330. item.phase !== "commentary" &&
  331. typeof item.text === "string" &&
  332. !emittedItems.has(itemId)
  333. ) {
  334. if (!turnText) turnText = item.text
  335. if (itemId) emittedItems.add(itemId)
  336. }
  337. } else if (envelope.method === "turn/completed") {
  338. const turn = envelope.params?.turn as Record<string, unknown> | undefined
  339. const error = turn?.error as Record<string, unknown> | undefined
  340. completeActiveTurn({
  341. status: String(turn?.status || "completed"),
  342. error: typeof error?.message === "string" ? error.message : undefined,
  343. })
  344. } else if (envelope.method === "error") {
  345. const error = envelope.params?.error as Record<string, unknown> | undefined
  346. if (envelope.params?.willRetry !== true) {
  347. failActiveTurn(new Error(String(error?.message || "Codex app-server 错误")))
  348. }
  349. }
  350. },
  351. })
  352. let turnInput = buildCodexTurnInput(preparedMessages)
  353. for (let round = 0; round < Math.max(1, config.maxRounds); round += 1) {
  354. if (signal?.aborted) throw new Error("操作已取消")
  355. record.roundsUsed = round + 1
  356. turnText = ""
  357. turnUsage = undefined
  358. const completionPromise = new Promise<TurnCompletion>((resolve, reject) => {
  359. turnResolve = resolve
  360. turnReject = reject
  361. })
  362. const turn = await client.call<TurnStartResponse>("turn/start", {
  363. threadId,
  364. input: turnInput,
  365. cwd: client.isolatedCwd,
  366. approvalPolicy: "never",
  367. sandboxPolicy: { type: "readOnly", networkAccess: false },
  368. model: config.modelId?.trim() || config.llmConfig.model.trim() || null,
  369. effort: codexReasoningEffort(config.llmConfig),
  370. })
  371. activeTurnId = turn.turn.id
  372. if (terminalError) void client.interrupt(threadId, activeTurnId)
  373. const completion = await completionPromise
  374. activeTurnId = ""
  375. if (finalDelivery && !terminalError) {
  376. // 交付即收尾:被 interrupt 的 turn 可能回 interrupted 也可能回 failed,
  377. // 只要拿到了终稿且不是用户取消,都按成功结束。
  378. // usage 缺失时保留已有累计值,避免上下文用量环归零。
  379. const deliveredLastUsage = turnUsage as LlmUsage | undefined
  380. const deliveredTotalUsage = cumulativeUsage as LlmUsage | undefined
  381. if (deliveredLastUsage) record.lastRequestUsage = { ...deliveredLastUsage }
  382. if (deliveredTotalUsage) record.usage = { ...deliveredTotalUsage }
  383. record.finalText = finalDelivery
  384. await clearPersistedBreakpoint()
  385. callbacks.onDone()
  386. return record
  387. }
  388. if (completion.status === "failed") {
  389. throw new Error(completion.error || "Codex app-server turn 失败")
  390. }
  391. if (completion.status === "interrupted") {
  392. throw terminalError ?? new Error("操作已取消")
  393. }
  394. const completedUsage = turnUsage as LlmUsage | undefined
  395. if (completedUsage) {
  396. record.lastRequestUsage = { ...completedUsage }
  397. record.usage = cumulativeUsage ? { ...cumulativeUsage } : { ...completedUsage }
  398. }
  399. const missing = missingRequiredToolsOnce({
  400. requiredToolsOnce: config.requiredToolsOnce,
  401. availableToolNames: config.tools.map((tool) => tool.name),
  402. calledToolNames: record.toolCalls.map((call) => call.name),
  403. toolsEnabled: config.tools.length > 0,
  404. })
  405. if (missing.length === 0) {
  406. record.finalText = turnText
  407. if (turnText) callbacks.onText(turnText)
  408. await clearPersistedBreakpoint()
  409. callbacks.onDone()
  410. return record
  411. }
  412. if (round >= Math.max(1, config.maxRounds) - 1) {
  413. await clearPersistedBreakpoint()
  414. throw new RequiredToolsNotCalledError(missing)
  415. }
  416. turnInput = [{
  417. type: "text",
  418. text: buildRequiredToolNudgeMessage(missing),
  419. text_elements: [],
  420. }]
  421. }
  422. throw new Error(`Agent 已达到最大调用轮次(${config.maxRounds}),请尝试减少引用内容或拆分任务`)
  423. } catch (error) {
  424. const resolved = error instanceof Error ? error : new Error(String(error))
  425. callbacks.onError(resolved)
  426. return record
  427. } finally {
  428. clearTimeout(timeout)
  429. signal?.removeEventListener("abort", abort)
  430. unregister()
  431. }
  432. }
  433. }