agentworkshop 0.7.33 → 0.7.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,729 +1,746 @@
1
- /**
2
- * CodexAgentImpl — OpenAI Codex CLI(app-server)harness 的 AgentInterface 实现。
3
- *
4
- * 进程模型:每 Agent 一个 `codex app-server` 子进程(stdio NDJSON JSON-RPC v2,
5
- * lazy spawn 跨消息复用);thread/start 建会话,turn/start 驱动回合。
6
- *
7
- * - prompt/工具面与 omp 全引擎同源(prompt-builder / host-tool-bridge);
8
- * 工具经 stdio MCP 桥注入(config.toml 由 impl 装配 [mcp_servers.aw])
9
- * - 事件映射:item/agentMessage/delta → delta;item/started → 🔧 status;
10
- * turn/completed → artifact + done / error(codexErrorInfo 结构化)
11
- * - HITL:item/commandExecution|fileChange/requestApproval(server→client 请求)
12
- * → hitl-registry 登记 → respondHitl 应答(decision: accept/decline/cancel)
13
- * - steer:turn 运行中 → turn/steer;无 active turn → 'deferred'
14
- * - 上下文治理:thread/tokenUsage/updated 被动跟踪;越阈值 → thread/compact/start
15
- *
16
- * 协议权威:github.com/openai/codex codex-rs/app-server/README.md(v0.149 实测)。
17
- */
18
- import { createLogger } from '../logger'
19
- import { randomUUID } from 'node:crypto'
20
- import { join } from 'node:path'
21
- import { mkdirSync, writeFileSync, existsSync, readFileSync, copyFileSync } from 'node:fs'
22
- import { tmpdir } from 'node:os'
23
- import type { AgentEvent, AgentInterface, AgentInfo, AgentRunContext, AgentRunRequest } from './agent-interface'
24
- import type { AgentContextStats } from '../types/task'
25
- import { registerHarnessProcess, bindHarnessProcess, markHarnessProcessExit, killHarnessProcess } from './harness-process'
26
- import { peerPrompt, systemManual, toolArgsPreview, workerPrompt } from './prompt-builder'
27
- import { getHitlRegistry } from './hitl-registry'
28
- import { harnessSettings } from '../settings'
29
- import { StdioJsonRpcClient } from './adapters/stdio-jsonrpc'
30
- import { BaseAgentImpl } from './base-agent'
31
- import { generateMcpBridgeEnv } from './harness-env'
32
-
33
- const log = createLogger('workshop.codex')
34
-
35
- export interface CodexAgentConfig {
36
- /** codex 可执行文件(默认取 harness.codex_command 设置) */
37
- command?: string
38
- /** 额外 CLI 参数(app-server 之前) */
39
- args?: string[]
40
- cwd?: string
41
- /** 模型(如 gpt-5.2) */
42
- model?: string
43
- /** 审批策略(默认 on-request:沙箱内自由,越界请求审批 → HITL) */
44
- approvalPolicy?: 'untrusted' | 'on-request' | 'never'
45
- /** 沙箱(默认 workspace-write) */
46
- sandbox?: 'read-only' | 'workspace-write' | 'danger-full-access'
47
- /** CODEX_HOME(缺省进程环境;配置隔离/多账户时按 agent 指定;effort 未指定 home 时自动种子化) */
48
- codexHome?: string
49
- /** 思考 effort(model_reasoning_effort;需 CODEX_HOME 配置面,未指定 home 时自动种子化) */
50
- effort?: string
51
- /** 上下文窗口(usage 百分比计算) */
52
- contextWindow?: number
53
- /** 压缩阈值(0-1,默认 0.7) */
54
- compactThreshold?: number
55
- promptTimeoutMs?: number
56
- superviseTimeoutMs?: number
57
- systemPromptPrefix?: string
58
- scenarioPrompt?: string
59
- agentId?: string
60
- name?: string
61
- role?: 'lead' | 'worker'
62
- channelId?: string
63
- token?: string
64
- baseUrl?: string
65
- mcpBridgePath?: string
66
- }
67
-
68
- export class CodexAgentImpl extends BaseAgentImpl implements AgentInterface {
69
- private readonly config: CodexAgentConfig
70
- private agentInfo: AgentInfo | null = null
71
- private readonly bridgeCtx = {
72
- identity: { agentId: '', channelId: '', role: 'worker' as 'lead' | 'worker', name: 'agent' },
73
- state: this.toolState,
74
- getWorkspace: () => this.workspace,
75
- }
76
-
77
- private agentRole: 'lead' | 'worker' = 'worker'
78
-
79
- private client: StdioJsonRpcClient | null = null
80
- private clientStarting: Promise<void> | null = null
81
- private threadId: string | null = null
82
- private turnId: string | null = null
83
-
84
- // 回合状态
85
- private turnActive = false
86
- private deltaBuf = ''
87
- private deltaTimer: ReturnType<typeof setTimeout> | null = null
88
- private lastUsage: { input: number, at: number } | null = null
89
- private compacting = false
90
- private lastCompactAt = 0
91
- /** 待应答审批(approval request id → JSON-RPC id) */
92
- private pendingApprovals = new Map<string, { rpcId: string | number, timer: ReturnType<typeof setTimeout> | null }>()
93
-
94
- constructor(config: Record<string, unknown> = {}) {
95
- super({
96
- agentId: typeof config.agentId === 'string' ? config.agentId : '',
97
- name: typeof config.name === 'string' ? config.name : undefined,
98
- role: config.role === 'lead' ? 'lead' : 'worker',
99
- channelId: typeof config.channelId === 'string' ? config.channelId : '',
100
- })
101
- this.config = config as CodexAgentConfig
102
- }
103
-
104
- protected get harnessId(): string {
105
- return 'codex'
106
- }
107
-
108
- protected configRecord(): Record<string, unknown> {
109
- return this.config
110
- }
111
-
112
- async dispose(): Promise<void> {
113
- const client = this.client
114
- this.client = null
115
- this.threadId = null
116
- if (client) {
117
- const pid = client.pid
118
- await client.dispose().catch(() => {})
119
- if (pid) markHarnessProcessExit(pid, null)
120
- }
121
- for (const [id, p] of this.pendingApprovals) {
122
- if (p.timer) clearTimeout(p.timer)
123
- this.pendingApprovals.delete(id)
124
- }
125
- }
126
-
127
- getProcessInfo(): { pid: number, alive: boolean, command: string } | null {
128
- const client = this.client
129
- const pid = client?.pid
130
- if (!pid || !client) return null
131
- return { pid, alive: client.alive, command: 'codex app-server' }
132
- }
133
-
134
- killProcess(): void {
135
- const pid = this.client?.pid
136
- if (pid) killHarnessProcess(pid)
137
- else this.client?.kill()
138
- }
139
-
140
- reconcileProcess(): void {
141
- this.client?.reconcile()
142
- }
143
-
144
- getContextStats(): AgentContextStats | null {
145
- if (!this.lastUsage) return null
146
- const window = this.config.contextWindow ?? null
147
- return {
148
- usedTokens: this.lastUsage.input,
149
- contextWindow: window,
150
- percent: window && window > 0 ? Math.min(1, this.lastUsage.input / window) : null,
151
- compacting: this.compacting,
152
- }
153
- }
154
-
155
- /** 实时注入:turn 运行中 → turn/steer;否则 'deferred'(消息保持 pending) */
156
- async steer(text: string): Promise<'steer' | 'deferred'> {
157
- const client = this.client
158
- if (!client?.alive || !this.threadId || !this.turnActive) return 'deferred'
159
- try {
160
- await client.request('turn/steer', {
161
- threadId: this.threadId,
162
- input: [{ type: 'text', text }],
163
- }, 15_000)
164
- return 'steer'
165
- }
166
- catch {
167
- // 无 active turn / 引擎拒绝 → 消息保持 pending 由消费循环处理
168
- return 'deferred'
169
- }
170
- }
171
-
172
- /** HITL 应答:审批请求 → JSON-RPC 应答(decision) */
173
- async respondHitl(kind: string, id: string, outcome: {
174
- confirmed?: boolean
175
- cancelled?: boolean
176
- value?: string
177
- response?: string
178
- comment?: string
179
- }): Promise<void> {
180
- if (kind !== 'codex-approval') return
181
- const pending = this.pendingApprovals.get(id)
182
- if (!pending) throw new Error(`待办不存在或已处理: ${id}`)
183
- const client = this.client
184
- if (!client) throw new Error('codex 会话已关闭')
185
- const decision = outcome.cancelled === true
186
- ? 'cancel'
187
- : outcome.confirmed === true ? 'accept' : 'decline'
188
- client.respond(pending.rpcId, { decision })
189
- if (pending.timer) clearTimeout(pending.timer)
190
- this.pendingApprovals.delete(id)
191
- getHitlRegistry().resolve(kind, id, decision === 'cancel' ? 'cancelled' : 'answered')
192
- }
193
-
194
- /** 上下文治理(post-settle):越阈值 → thread/compact/start */
195
- async onTurnSettled(): Promise<void> {
196
- const client = this.client
197
- if (!client?.alive || !this.threadId || this.compacting) return
198
- const stats = this.getContextStats()
199
- const threshold = this.config.compactThreshold ?? 0.7
200
- if (!stats?.percent || stats.percent < threshold) return
201
- if (Date.now() - this.lastCompactAt < 5 * 60_000) return
202
- this.compacting = true
203
- this.lastCompactAt = Date.now()
204
- try {
205
- await client.request('thread/compact/start', { threadId: this.threadId }, 30_000)
206
- log.info(`[CodexAgent:${this.selfAgentId}] 上下文压缩已触发(percent=${(stats.percent * 100).toFixed(0)}%)`)
207
- }
208
- catch (err) {
209
- log.warn(`[CodexAgent:${this.selfAgentId}] compact 发起失败(放行):`, err instanceof Error ? err.message : err)
210
- }
211
- finally {
212
- this.compacting = false
213
- }
214
- }
215
-
216
- protected async* workerTurn(request: AgentRunRequest, ctx: AgentRunContext): AsyncGenerator<AgentEvent, void, unknown> {
217
- const taskId = request.taskId ?? (request.message.metadata?.['x-aw-task-id'] as string | undefined)
218
- if (!taskId) return
219
- this.toolState.currentTaskId = taskId
220
- try {
221
- await this.ensureClient(ctx)
222
- const taskText = request.message.parts.map((p) => {
223
- if ('text' in p) return p.text
224
- if ('data' in p) return JSON.stringify(p.data)
225
- if ('url' in p) return p.url
226
- if ('raw' in p) return p.raw
227
- return ''
228
- }).join('\n')
229
- const prompt = workerPrompt({
230
- agentName: this.agentName,
231
- channelId: this.channelId,
232
- taskId,
233
- taskText,
234
- memory: request.memory,
235
- ctxPrefix: await this.contextPrefix(),
236
- manual: systemManual(),
237
- })
238
- yield* this.streamTurn(prompt, taskId, this.config.promptTimeoutMs ?? 600_000, ctx.signal)
239
- }
240
- finally {
241
- this.toolState.currentTaskId = null
242
- }
243
- }
244
-
245
- protected async* peerTurn(request: AgentRunRequest, ctx: AgentRunContext): AsyncGenerator<AgentEvent, void, unknown> {
246
- await this.ensureClient(ctx)
247
- const msg = request.message
248
- const fromId = request.fromAgentId
249
- ?? (typeof msg.metadata?.['x-aw-from-label'] === 'string' ? msg.metadata['x-aw-from-label'] : undefined)
250
- ?? 'unknown'
251
- const requireReply = msg.metadata?.['x-aw-require-reply'] === 'true'
252
- const crossChannel = msg.metadata?.['x-aw-cross-channel'] === 'true'
253
- this.toolState.replyContext = requireReply && request.fromAgentId && !crossChannel
254
- ? { fromId: request.fromAgentId, messageId: msg.messageId }
255
- : null
256
- const msgText = msg.parts.map((p) => {
257
- if ('text' in p) return p.text
258
- if ('data' in p) return JSON.stringify(p.data)
259
- if ('url' in p) return p.url
260
- if ('raw' in p) return p.raw
261
- return ''
262
- }).join('\n')
263
- const prompt = peerPrompt({
264
- agentName: this.agentName,
265
- role: this.agentRole,
266
- channelId: this.channelId,
267
- ctxPrefix: await this.contextPrefix(),
268
- manual: systemManual(),
269
- memory: request.memory,
270
- fromId,
271
- messageId: msg.messageId,
272
- requireReply,
273
- isReply: typeof msg.metadata?.['x-aw-in-reply-to'] === 'string',
274
- crossChannel,
275
- fromChannel: typeof msg.metadata?.['x-aw-from-channel'] === 'string' ? String(msg.metadata['x-aw-from-channel']) : '',
276
- msgText,
277
- })
278
- yield* this.streamTurn(prompt, undefined, this.config.promptTimeoutMs ?? 600_000, ctx.signal)
279
- }
280
-
281
- // ===== 回合执行 =====
282
-
283
- /**
284
- * 流式回合:turn/start → 逐事件产出(delta/status)→ turn/completed 收口。
285
- * artifact 在 done 前注入(item/completed agentMessage 聚合文本)。
286
- */
287
- private async* streamTurn(prompt: string, taskId: string | undefined, timeoutMs: number, signal?: AbortSignal): AsyncGenerator<AgentEvent, void, unknown> {
288
- const client = this.client
289
- if (!client || !this.threadId) {
290
- yield { kind: 'error', error: { code: 'CODEX_NOT_READY', message: 'codex 会话未就绪' } }
291
- return
292
- }
293
- const queue: AgentEvent[] = []
294
- let isDone = false
295
- let resolveWait: (() => void) | null = null
296
- let lastActivity = Date.now()
297
- let agentText = ''
298
- this.turnActive = true
299
- this.turnId = null
300
-
301
- const enqueue = (e: AgentEvent): void => {
302
- if (e.kind === 'done' && !isDone) {
303
- if (agentText.trim()) {
304
- queue.push({
305
- kind: 'artifact',
306
- artifact: { artifactId: randomUUID(), name: 'output', parts: [{ text: agentText.trim() }] },
307
- lastChunk: true,
308
- totalChunks: 1,
309
- })
310
- }
311
- this.turnActive = false
312
- }
313
- if (e.kind === 'done' || e.kind === 'error') {
314
- isDone = true
315
- this.turnActive = false
316
- flushDelta()
317
- }
318
- queue.push(e)
319
- lastActivity = Date.now()
320
- resolveWait?.()
321
- resolveWait = null
322
- }
323
-
324
- const flushDelta = (): void => {
325
- if (this.deltaTimer) {
326
- clearTimeout(this.deltaTimer)
327
- this.deltaTimer = null
328
- }
329
- if (!this.deltaBuf) return
330
- const text = this.deltaBuf
331
- this.deltaBuf = ''
332
- queue.push({ kind: 'delta', delta: { text } })
333
- }
334
- const pushDelta = (text: string): void => {
335
- this.deltaBuf += text
336
- this.deltaTimer ??= setTimeout(() => {
337
- this.deltaTimer = null
338
- flushDelta()
339
- }, 50)
340
- }
341
-
342
- // 通知订阅(审批请求处理器已在 startClient 挂接,此处只处理回合通知)
343
- const unsub = client.onNotification((method, params) => {
344
- const p = (params ?? {}) as Record<string, unknown>
345
- lastActivity = Date.now()
346
- if (method === 'item/agentMessage/delta') {
347
- const delta = typeof p.delta === 'string' ? p.delta : ''
348
- if (delta) {
349
- this.turnActive = true
350
- pushDelta(delta)
351
- }
352
- return
353
- }
354
- if (method === 'turn/started') {
355
- const turn = p.turn as Record<string, unknown> | undefined
356
- this.turnId = typeof turn?.id === 'string' ? turn.id : this.turnId
357
- this.turnActive = true
358
- enqueue({ kind: 'status', status: { state: 'WORKING', timestamp: new Date().toISOString() } })
359
- return
360
- }
361
- if (method === 'item/started') {
362
- const item = p.item as Record<string, unknown> | undefined
363
- const t = String(item?.type ?? '')
364
- if (['commandExecution', 'mcpToolCall', 'fileChange', 'dynamicToolCall', 'webSearch', 'collabToolCall'].includes(t)) {
365
- const label = t === 'commandExecution'
366
- ? String(item?.command ?? 'command')
367
- : String(item?.tool ?? item?.title ?? t)
368
- enqueue({
369
- kind: 'status',
370
- status: {
371
- state: 'WORKING',
372
- message: {
373
- messageId: randomUUID(),
374
- contextId: this.channelId,
375
- role: 'ROLE_AGENT',
376
- parts: [{ text: `🔧 ${label}${toolArgsPreview(item?.input ?? item?.arguments ?? item?.command)}` }],
377
- },
378
- timestamp: new Date().toISOString(),
379
- },
380
- })
381
- }
382
- if (t === 'contextCompaction') this.compacting = true
383
- return
384
- }
385
- if (method === 'item/completed') {
386
- const item = p.item as Record<string, unknown> | undefined
387
- if (String(item?.type ?? '') === 'agentMessage' && typeof item?.text === 'string') {
388
- agentText += item.text
389
- }
390
- if (String(item?.type ?? '') === 'contextCompaction') this.compacting = false
391
- return
392
- }
393
- if (method === 'thread/tokenUsage/updated') {
394
- const usage = (p.usage ?? p.tokenUsage ?? p) as Record<string, unknown>
395
- const input = Number(usage.input_tokens ?? usage.inputTokens ?? usage.total_tokens ?? usage.totalTokens)
396
- if (Number.isFinite(input) && input > 0) this.lastUsage = { input, at: Date.now() }
397
- return
398
- }
399
- if (method === 'turn/completed') {
400
- const turn = (p.turn ?? {}) as Record<string, unknown>
401
- const status = String(turn.status ?? 'completed')
402
- if (status === 'failed') {
403
- const err = (turn.error ?? {}) as Record<string, unknown>
404
- const info = String(err.codexErrorInfo ?? '')
405
- enqueue({
406
- kind: 'error',
407
- error: {
408
- code: info ? `CODEX_${info}` : 'CODEX_LLM_ERROR',
409
- message: String(err.message ?? 'codex 回合失败'),
410
- },
411
- })
412
- }
413
- else {
414
- // completed / interrupted:中断按 done 收口(消息按已处理落账)
415
- enqueue({ kind: 'done', final: taskId ? { taskId } : undefined })
416
- }
417
- return
418
- }
419
- if (method === 'error') {
420
- enqueue({
421
- kind: 'error',
422
- error: { code: 'CODEX_ERROR', message: String(p.message ?? p.error ?? JSON.stringify(params).slice(0, 300)) },
423
- })
424
- }
425
- })
426
-
427
- // abort 传导
428
- const onAbort = (): void => {
429
- log.warn(`[CodexAgent:${this.selfAgentId}] run 被 abort → turn/interrupt,taskId=${taskId ?? '-'}`)
430
- void client.request('turn/interrupt', { threadId: this.threadId, ...(this.turnId ? { turnId: this.turnId } : {}) }, 15_000).catch(() => {})
431
- setTimeout(() => {
432
- if (!isDone) enqueue({ kind: 'done', final: taskId ? { taskId } : undefined })
433
- }, 5000)
434
- }
435
- if (signal?.aborted) onAbort()
436
- else signal?.addEventListener('abort', onAbort, { once: true })
437
-
438
- // 投递 turn
439
- try {
440
- await client.request('turn/start', {
441
- threadId: this.threadId,
442
- input: [{ type: 'text', text: prompt }],
443
- }, 60_000)
444
- }
445
- catch (err) {
446
- unsub()
447
- signal?.removeEventListener('abort', onAbort)
448
- this.turnActive = false
449
- yield {
450
- kind: 'error',
451
- error: {
452
- code: 'CODEX_PROMPT_FAILED',
453
- message: `codex turn/start 失败: ${err instanceof Error ? err.message : String(err)}`,
454
- },
455
- }
456
- return
457
- }
458
-
459
- try {
460
- while (!isDone || queue.length > 0) {
461
- if (queue.length === 0 && !isDone) {
462
- const remaining = timeoutMs - (Date.now() - lastActivity)
463
- if (remaining <= 0) {
464
- void client.request('turn/interrupt', { threadId: this.threadId }, 15_000).catch(() => {})
465
- enqueue({
466
- kind: 'error',
467
- error: { code: 'CODEX_TURN_STALLED', message: `回合停滞 ${Math.round(timeoutMs / 1000)}s 无事件,已中止` },
468
- })
469
- continue
470
- }
471
- await new Promise<void>((r) => {
472
- resolveWait = r
473
- setTimeout(() => {
474
- resolveWait = null
475
- r()
476
- }, remaining + 100)
477
- })
478
- }
479
- while (queue.length > 0) {
480
- yield queue.shift()!
481
- }
482
- }
483
- }
484
- finally {
485
- flushDelta()
486
- unsub()
487
- signal?.removeEventListener('abort', onAbort)
488
- this.turnActive = false
489
- this.turnId = null
490
- }
491
- }
492
-
493
- /** supervise 用:收齐整个回合的事件流(supervise 决策解析在调用方) */
494
- protected async collectTurnEvents(prompt: string, timeoutMs: number, signal?: AbortSignal): Promise<AgentEvent[]> {
495
- const events: AgentEvent[] = []
496
- for await (const e of this.streamTurn(prompt, undefined, timeoutMs, signal)) {
497
- events.push(e)
498
- if (e.kind === 'error') break
499
- }
500
- return events
501
- }
502
-
503
- // ===== HITL 登记 =====
504
-
505
- private registerApprovalHitl(rpcId: string | number, itemId: string, kindLabel: 'command' | 'file', p: Record<string, unknown>): void {
506
- const id = `codex-${itemId}`
507
- if (this.pendingApprovals.has(id)) return
508
- const title = kindLabel === 'command'
509
- ? `codex 命令审批:${String(p.command ?? '(未知命令)').slice(0, 200)}`
510
- : `codex 文件变更审批:${String(p.cwd ?? p.grantRoot ?? '')}`
511
- const registry = getHitlRegistry()
512
- registry.register({
513
- kind: 'codex-approval',
514
- id,
515
- agentId: this.selfAgentId,
516
- agentName: this.agentName,
517
- channelId: this.channelId,
518
- pid: this.client?.pid,
519
- method: 'confirm',
520
- title,
521
- detail: typeof p.reason === 'string' ? p.reason : undefined,
522
- createdAt: new Date().toISOString(),
523
- expiresAt: null,
524
- })
525
- const timeoutMs = harnessSettings().hitl_timeout_ms
526
- const timer = timeoutMs > 0
527
- ? setTimeout(() => {
528
- void this.respondHitl('codex-approval', id, { cancelled: true }).catch(() => {})
529
- registry.resolve('codex-approval', id, 'expired')
530
- }, timeoutMs)
531
- : null
532
- this.pendingApprovals.set(id, { rpcId, timer })
533
- }
534
-
535
- private registerUserInputHitl(rpcId: string | number, p: Record<string, unknown>): void {
536
- const id = `codex-input-${randomUUID().slice(0, 8)}`
537
- const questions = Array.isArray(p.questions) ? p.questions : []
538
- const first = (questions[0] ?? {}) as Record<string, unknown>
539
- const registry = getHitlRegistry()
540
- registry.register({
541
- kind: 'codex-approval',
542
- id,
543
- agentId: this.selfAgentId,
544
- agentName: this.agentName,
545
- channelId: this.channelId,
546
- pid: this.client?.pid,
547
- method: 'input',
548
- title: String(first.question ?? first.header ?? 'codex 提问'),
549
- detail: typeof p.reason === 'string' ? p.reason : undefined,
550
- createdAt: new Date().toISOString(),
551
- expiresAt: null,
552
- })
553
- const timeoutMs = harnessSettings().hitl_timeout_ms
554
- const timer = timeoutMs > 0
555
- ? setTimeout(() => {
556
- void this.respondUserInput(rpcId, id, { cancelled: true }).catch(() => {})
557
- }, timeoutMs)
558
- : null
559
- this.pendingApprovals.set(id, { rpcId, timer })
560
- }
561
-
562
- private async respondUserInput(rpcId: string | number, id: string, outcome: { cancelled?: boolean, value?: string }): Promise<void> {
563
- const pending = this.pendingApprovals.get(id)
564
- if (!pending) return
565
- const client = this.client
566
- if (!client) return
567
- if (outcome.cancelled) {
568
- client.respondError(rpcId, -32800, '人工取消')
569
- getHitlRegistry().resolve('codex-approval', id, 'cancelled')
570
- }
571
- else {
572
- client.respond(rpcId, { answers: [{ answer: outcome.value ?? '' }] })
573
- getHitlRegistry().resolve('codex-approval', id, 'answered')
574
- }
575
- if (pending.timer) clearTimeout(pending.timer)
576
- this.pendingApprovals.delete(id)
577
- }
578
-
579
- // ===== 客户端管理 =====
580
-
581
- private async ensureClient(ctx: AgentRunContext): Promise<void> {
582
- if (!this.workspace) this.workspace = ctx.workspace
583
- if (!this.agentInfo) {
584
- this.channelId = ctx.channelId
585
- this.agentRole = ctx.role
586
- this.refreshIdentity()
587
- }
588
- if (this.client?.alive && this.threadId) return
589
- if (this.client && !this.client.alive) {
590
- this.client = null
591
- this.threadId = null
592
- }
593
- this.clientStarting ??= this.startClient().finally(() => {
594
- this.clientStarting = null
595
- })
596
- await this.clientStarting
597
- }
598
-
599
- private async startClient(): Promise<void> {
600
- const command = this.config.command ?? harnessSettings().codex_command
601
- const client = new StdioJsonRpcClient({
602
- name: 'codex',
603
- command,
604
- args: [...(this.config.args ?? []), 'app-server'],
605
- cwd: this.config.cwd ?? process.cwd(),
606
- env: generateMcpBridgeEnv({
607
- agentId: this.selfAgentId,
608
- token: this.config.token,
609
- baseUrl: this.config.baseUrl,
610
- bridgePath: this.config.mcpBridgePath,
611
- extra: this.config.codexHome ? { CODEX_HOME: this.config.codexHome } : {},
612
- }).engineEnv,
613
- requestTimeoutMs: 60_000,
614
- })
615
- const pidRef = { pid: undefined as number | undefined }
616
- client.onExit((code) => {
617
- if (pidRef.pid) markHarnessProcessExit(pidRef.pid, code)
618
- this.threadId = null
619
- log.warn(`[CodexAgent:${this.selfAgentId}] codex app-server 退出(code=${code});下回合自动重生`)
620
- })
621
- await client.start()
622
- pidRef.pid = client.pid
623
- if (client.pid) {
624
- registerHarnessProcess(client.pid, { harness: 'codex', command, args: ['app-server'] })
625
- bindHarnessProcess(client.pid, { agentId: this.selfAgentId, channelId: this.channelId, name: this.agentName, role: this.agentRole })
626
- }
627
-
628
- // 握手
629
- await client.request('initialize', {
630
- clientInfo: { name: 'agentworkshop', title: 'AgentWorkShop', version: '0.8.0' },
631
- }, 30_000)
632
- client.notify('initialized', {})
633
-
634
- // MCP 桥装配(per-agent CODEX_HOME 的 config.toml [mcp_servers.aw])
635
- this.writeCodexHomeConfig()
636
-
637
- // 建会话
638
- const result = await client.request('thread/start', {
639
- ...(this.config.model ? { model: this.config.model } : {}),
640
- cwd: this.config.cwd ?? process.cwd(),
641
- approvalPolicy: this.config.approvalPolicy ?? 'on-request',
642
- sandbox: this.config.sandbox ?? 'workspace-write',
643
- }, 60_000) as Record<string, unknown>
644
- const thread = (result?.thread ?? result) as Record<string, unknown>
645
- this.threadId = typeof thread?.id === 'string' ? thread.id : null
646
- if (!this.threadId) throw new Error('codex thread/start 未返回 thread id')
647
-
648
- // 事件监听挂接(通知在 streamTurn 内按回合订阅;此处仅挂请求处理器)
649
- client.onRequest((req) => {
650
- if (req.method === 'item/commandExecution/requestApproval' || req.method === 'item/fileChange/requestApproval') {
651
- const p = (req.params ?? {}) as Record<string, unknown>
652
- this.registerApprovalHitl(req.id, String(p.itemId ?? randomUUID()), req.method === 'item/fileChange/requestApproval' ? 'file' : 'command', p)
653
- }
654
- else if (req.method === 'tool/requestUserInput') {
655
- const p = (req.params ?? {}) as Record<string, unknown>
656
- this.registerUserInputHitl(req.id, p)
657
- }
658
- else {
659
- client.respondError(req.id, -32601, `方法不存在: ${req.method}`)
660
- }
661
- })
662
-
663
- this.client = client
664
- }
665
-
666
- /**
667
- * per-agent CODEX_HOME config.toml:注册 MCP 桥(required=true,桥挂了宁可失败)。
668
- * 已有 config.toml(从全局目录种子化复制)→ 追加本段(保留用户的 provider/model
669
- * 配置,自定义网关/密钥不丢失);全新目录 → 独立写入。
670
- */
671
- private writeCodexHomeConfig(): void {
672
- let home = this.config.codexHome
673
- if (!home && this.config.effort) {
674
- // effort 需要配置文件面(model_reasoning_effort):自动种子化 per-agent CODEX_HOME
675
- // (拷贝全局凭据/网关配置,保登录与自定义 provider;追加 MCP 段 + 顶部 effort)
676
- home = join(tmpdir(), `aw-codex-${this.selfAgentId.slice(0, 8)}`)
677
- this.config.codexHome = home
678
- for (const f of ['auth.json', 'config.toml', 'cc-switch-model-catalog.json']) {
679
- try {
680
- const src = join(process.env.HOME ?? process.env.USERPROFILE ?? '', '.codex', f)
681
- if (existsSync(src)) copyFileSync(src, join(home, f))
682
- }
683
- catch { /* 单文件缺失忽略 */ }
684
- }
685
- }
686
- if (!home) return
687
- try {
688
- mkdirSync(home, { recursive: true })
689
- const configFile = join(home, 'config.toml')
690
- const env = generateMcpBridgeEnv({ agentId: this.selfAgentId, token: this.config.token, baseUrl: this.config.baseUrl, bridgePath: this.config.mcpBridgePath })
691
- const envLines = Object.entries(env.bridgeEnv).map(([k, v]) => ` ${k} = "${v}"`).join('\n')
692
- const section = [
693
- ``,
694
- `[mcp_servers.aw]`,
695
- `command = ${JSON.stringify(process.execPath)}`,
696
- `args = [${JSON.stringify(join(process.env.AW_PACKAGE_ROOT ?? process.cwd(), 'server', 'harness', 'aw-mcp-bridge.mjs'))}]`,
697
- `required = true`,
698
- ``,
699
- `[mcp_servers.aw.env]`,
700
- envLines,
701
- ``,
702
- ].join('\n')
703
- // effort 走 TOML 顶层键(model_reasoning_effort):必须位于任何 [table] 之前 → 顶部插入
704
- const effortLine = this.config.effort
705
- ? `model_reasoning_effort = ${JSON.stringify(this.config.effort)}
706
- `
707
- : ''
708
- if (existsSync(configFile)) {
709
- const existing = readFileSync(configFile, 'utf-8')
710
- const withoutDup = this.config.effort
711
- ? existing.replace(/^model_reasoning_effort = .*\n?/m, '')
712
- : existing
713
- if (withoutDup.includes('[mcp_servers.aw]')) {
714
- if (effortLine && !withoutDup.startsWith(effortLine.trim())) {
715
- writeFileSync(configFile, effortLine + withoutDup, 'utf-8')
716
- }
717
- return
718
- }
719
- writeFileSync(configFile, effortLine + withoutDup + section, 'utf-8')
720
- }
721
- else {
722
- writeFileSync(configFile, effortLine + section, 'utf-8')
723
- }
724
- }
725
- catch (err) {
726
- log.warn(`[CodexAgent:${this.selfAgentId}] CODEX_HOME config 写入失败(信任全局配置):`, err instanceof Error ? err.message : err)
727
- }
728
- }
729
- }
1
+ /**
2
+ * CodexAgentImpl — OpenAI Codex CLI(app-server)harness 的 AgentInterface 实现。
3
+ *
4
+ * 进程模型:每 Agent 一个 `codex app-server` 子进程(stdio NDJSON JSON-RPC v2,
5
+ * lazy spawn 跨消息复用);thread/start 建会话,turn/start 驱动回合。
6
+ *
7
+ * - prompt/工具面与 omp 全引擎同源(prompt-builder / host-tool-bridge);
8
+ * 工具经 stdio MCP 桥注入(config.toml 由 impl 装配 [mcp_servers.aw])
9
+ * - 事件映射:item/agentMessage/delta → delta;item/started → 🔧 status;
10
+ * turn/completed → artifact + done / error(codexErrorInfo 结构化)
11
+ * - HITL:item/commandExecution|fileChange/requestApproval(server→client 请求)
12
+ * → hitl-registry 登记 → respondHitl 应答(decision: accept/decline/cancel)
13
+ * - steer:turn 运行中 → turn/steer;无 active turn → 'deferred'
14
+ * - 上下文治理:thread/tokenUsage/updated 被动跟踪;越阈值 → thread/compact/start
15
+ *
16
+ * 协议权威:github.com/openai/codex codex-rs/app-server/README.md(v0.149 实测)。
17
+ */
18
+ import { createLogger } from '../logger'
19
+ import { randomUUID } from 'node:crypto'
20
+ import { join } from 'node:path'
21
+ import { mkdirSync, writeFileSync, existsSync, readFileSync, copyFileSync } from 'node:fs'
22
+ import { tmpdir } from 'node:os'
23
+ import type { AgentEvent, AgentInterface, AgentInfo, AgentRunContext, AgentRunRequest } from './agent-interface'
24
+ import type { AgentContextStats } from '../types/task'
25
+ import { registerHarnessProcess, bindHarnessProcess, markHarnessProcessExit, killHarnessProcess } from './harness-process'
26
+ import { peerPrompt, systemManual, toolArgsPreview, workerPrompt } from './prompt-builder'
27
+ import { getHitlRegistry } from './hitl-registry'
28
+ import { harnessSettings } from '../settings'
29
+ import { StdioJsonRpcClient } from './adapters/stdio-jsonrpc'
30
+ import { BaseAgentImpl } from './base-agent'
31
+ import { generateMcpBridgeEnv } from './harness-env'
32
+
33
+ const log = createLogger('workshop.codex')
34
+
35
+ /**
36
+ * 默认回合停滞上限:整轮无任何事件(含推理模型的静默思考期)达到该时长才中止。
37
+ * 600s 会误杀健康的沉默推理(实测推理档静默 >10 分钟后被打断,任务 FAILED→重试→CANCELED);
38
+ * 事件到达即刷新计时,真实卡死仍会在该窗口内被发现。
39
+ */
40
+ const CODEX_TURN_STALL_MS = 1_800_000
41
+
42
+ export interface CodexAgentConfig {
43
+ /** codex 可执行文件(默认取 harness.codex_command 设置) */
44
+ command?: string
45
+ /** 额外 CLI 参数(app-server 之前) */
46
+ args?: string[]
47
+ cwd?: string
48
+ /** 模型(如 gpt-5.2) */
49
+ model?: string
50
+ /** 审批策略(默认 on-request:沙箱内自由,越界请求审批 → HITL) */
51
+ approvalPolicy?: 'untrusted' | 'on-request' | 'never'
52
+ /** 沙箱(默认 workspace-write) */
53
+ sandbox?: 'read-only' | 'workspace-write' | 'danger-full-access'
54
+ /** CODEX_HOME(缺省进程环境;配置隔离/多账户时按 agent 指定;effort 未指定 home 时自动种子化) */
55
+ codexHome?: string
56
+ /** 思考 effort(model_reasoning_effort;需 CODEX_HOME 配置面,未指定 home 时自动种子化) */
57
+ effort?: string
58
+ /** 上下文窗口(usage 百分比计算) */
59
+ contextWindow?: number
60
+ /** 压缩阈值(0-1,默认 0.7) */
61
+ compactThreshold?: number
62
+ /**
63
+ * 回合停滞上限(ms):整轮无任何事件(推理模型静默思考也不产 delta)达到该时长才中止。
64
+ * 默认 1800000(30 分钟):codex 0.154 的推理档在负载下可静默思考 >10 分钟,
65
+ * 600s 会把健康的沉默推理误杀为 CODEX_TURN_STALLED(任务 FAILED→重试→CANCELED)。
66
+ * 有事件(delta/status/工具轨迹)即刷新计时,不放大真实卡死的发现延迟。
67
+ */
68
+ promptTimeoutMs?: number
69
+ superviseTimeoutMs?: number
70
+ systemPromptPrefix?: string
71
+ scenarioPrompt?: string
72
+ agentId?: string
73
+ name?: string
74
+ role?: 'lead' | 'worker'
75
+ channelId?: string
76
+ token?: string
77
+ baseUrl?: string
78
+ mcpBridgePath?: string
79
+ }
80
+
81
+ export class CodexAgentImpl extends BaseAgentImpl implements AgentInterface {
82
+ private readonly config: CodexAgentConfig
83
+ private agentInfo: AgentInfo | null = null
84
+ private readonly bridgeCtx = {
85
+ identity: { agentId: '', channelId: '', role: 'worker' as 'lead' | 'worker', name: 'agent' },
86
+ state: this.toolState,
87
+ getWorkspace: () => this.workspace,
88
+ }
89
+
90
+ private agentRole: 'lead' | 'worker' = 'worker'
91
+
92
+ private client: StdioJsonRpcClient | null = null
93
+ private clientStarting: Promise<void> | null = null
94
+ private threadId: string | null = null
95
+ private turnId: string | null = null
96
+
97
+ // 回合状态
98
+ private turnActive = false
99
+ private deltaBuf = ''
100
+ private deltaTimer: ReturnType<typeof setTimeout> | null = null
101
+ private lastUsage: { input: number, at: number } | null = null
102
+ private compacting = false
103
+ private lastCompactAt = 0
104
+ /** 待应答审批(approval request id → JSON-RPC id) */
105
+ private pendingApprovals = new Map<string, { rpcId: string | number, timer: ReturnType<typeof setTimeout> | null }>()
106
+
107
+ constructor(config: Record<string, unknown> = {}) {
108
+ super({
109
+ agentId: typeof config.agentId === 'string' ? config.agentId : '',
110
+ name: typeof config.name === 'string' ? config.name : undefined,
111
+ role: config.role === 'lead' ? 'lead' : 'worker',
112
+ channelId: typeof config.channelId === 'string' ? config.channelId : '',
113
+ })
114
+ this.config = config as CodexAgentConfig
115
+ }
116
+
117
+ protected get harnessId(): string {
118
+ return 'codex'
119
+ }
120
+
121
+ protected configRecord(): Record<string, unknown> {
122
+ return this.config
123
+ }
124
+
125
+ async dispose(): Promise<void> {
126
+ const client = this.client
127
+ this.client = null
128
+ this.threadId = null
129
+ if (client) {
130
+ const pid = client.pid
131
+ await client.dispose().catch(() => {})
132
+ if (pid) markHarnessProcessExit(pid, null)
133
+ }
134
+ for (const [id, p] of this.pendingApprovals) {
135
+ if (p.timer) clearTimeout(p.timer)
136
+ this.pendingApprovals.delete(id)
137
+ }
138
+ }
139
+
140
+ getProcessInfo(): { pid: number, alive: boolean, command: string } | null {
141
+ const client = this.client
142
+ const pid = client?.pid
143
+ if (!pid || !client) return null
144
+ return { pid, alive: client.alive, command: 'codex app-server' }
145
+ }
146
+
147
+ killProcess(): void {
148
+ const pid = this.client?.pid
149
+ if (pid) killHarnessProcess(pid)
150
+ else this.client?.kill()
151
+ }
152
+
153
+ reconcileProcess(): void {
154
+ this.client?.reconcile()
155
+ }
156
+
157
+ getContextStats(): AgentContextStats | null {
158
+ if (!this.lastUsage) return null
159
+ const window = this.config.contextWindow ?? null
160
+ return {
161
+ usedTokens: this.lastUsage.input,
162
+ contextWindow: window,
163
+ percent: window && window > 0 ? Math.min(1, this.lastUsage.input / window) : null,
164
+ compacting: this.compacting,
165
+ }
166
+ }
167
+
168
+ /** 实时注入:turn 运行中 → turn/steer;否则 'deferred'(消息保持 pending) */
169
+ async steer(text: string): Promise<'steer' | 'deferred'> {
170
+ const client = this.client
171
+ if (!client?.alive || !this.threadId || !this.turnActive) return 'deferred'
172
+ try {
173
+ await client.request('turn/steer', {
174
+ threadId: this.threadId,
175
+ input: [{ type: 'text', text }],
176
+ }, 15_000)
177
+ return 'steer'
178
+ }
179
+ catch {
180
+ // 无 active turn / 引擎拒绝 → 消息保持 pending 由消费循环处理
181
+ return 'deferred'
182
+ }
183
+ }
184
+
185
+ /** HITL 应答:审批请求 → JSON-RPC 应答(decision) */
186
+ async respondHitl(kind: string, id: string, outcome: {
187
+ confirmed?: boolean
188
+ cancelled?: boolean
189
+ value?: string
190
+ response?: string
191
+ comment?: string
192
+ }): Promise<void> {
193
+ if (kind !== 'codex-approval') return
194
+ const pending = this.pendingApprovals.get(id)
195
+ if (!pending) throw new Error(`待办不存在或已处理: ${id}`)
196
+ const client = this.client
197
+ if (!client) throw new Error('codex 会话已关闭')
198
+ const decision = outcome.cancelled === true
199
+ ? 'cancel'
200
+ : outcome.confirmed === true ? 'accept' : 'decline'
201
+ client.respond(pending.rpcId, { decision })
202
+ if (pending.timer) clearTimeout(pending.timer)
203
+ this.pendingApprovals.delete(id)
204
+ getHitlRegistry().resolve(kind, id, decision === 'cancel' ? 'cancelled' : 'answered')
205
+ }
206
+
207
+ /** 上下文治理(post-settle):越阈值 → thread/compact/start */
208
+ async onTurnSettled(): Promise<void> {
209
+ const client = this.client
210
+ if (!client?.alive || !this.threadId || this.compacting) return
211
+ const stats = this.getContextStats()
212
+ const threshold = this.config.compactThreshold ?? 0.7
213
+ if (!stats?.percent || stats.percent < threshold) return
214
+ if (Date.now() - this.lastCompactAt < 5 * 60_000) return
215
+ this.compacting = true
216
+ this.lastCompactAt = Date.now()
217
+ try {
218
+ await client.request('thread/compact/start', { threadId: this.threadId }, 30_000)
219
+ log.info(`[CodexAgent:${this.selfAgentId}] 上下文压缩已触发(percent=${(stats.percent * 100).toFixed(0)}%)`)
220
+ }
221
+ catch (err) {
222
+ log.warn(`[CodexAgent:${this.selfAgentId}] compact 发起失败(放行):`, err instanceof Error ? err.message : err)
223
+ }
224
+ finally {
225
+ this.compacting = false
226
+ }
227
+ }
228
+
229
+ protected async* workerTurn(request: AgentRunRequest, ctx: AgentRunContext): AsyncGenerator<AgentEvent, void, unknown> {
230
+ const taskId = request.taskId ?? (request.message.metadata?.['x-aw-task-id'] as string | undefined)
231
+ if (!taskId) return
232
+ this.toolState.currentTaskId = taskId
233
+ try {
234
+ await this.ensureClient(ctx)
235
+ const taskText = request.message.parts.map((p) => {
236
+ if ('text' in p) return p.text
237
+ if ('data' in p) return JSON.stringify(p.data)
238
+ if ('url' in p) return p.url
239
+ if ('raw' in p) return p.raw
240
+ return ''
241
+ }).join('\n')
242
+ const prompt = workerPrompt({
243
+ agentName: this.agentName,
244
+ channelId: this.channelId,
245
+ taskId,
246
+ taskText,
247
+ memory: request.memory,
248
+ ctxPrefix: await this.contextPrefix(),
249
+ manual: systemManual(),
250
+ })
251
+ yield* this.streamTurn(prompt, taskId, this.config.promptTimeoutMs ?? CODEX_TURN_STALL_MS, ctx.signal)
252
+ }
253
+ finally {
254
+ this.toolState.currentTaskId = null
255
+ }
256
+ }
257
+
258
+ protected async* peerTurn(request: AgentRunRequest, ctx: AgentRunContext): AsyncGenerator<AgentEvent, void, unknown> {
259
+ await this.ensureClient(ctx)
260
+ const msg = request.message
261
+ const fromId = request.fromAgentId
262
+ ?? (typeof msg.metadata?.['x-aw-from-label'] === 'string' ? msg.metadata['x-aw-from-label'] : undefined)
263
+ ?? 'unknown'
264
+ const requireReply = msg.metadata?.['x-aw-require-reply'] === 'true'
265
+ const crossChannel = msg.metadata?.['x-aw-cross-channel'] === 'true'
266
+ this.toolState.replyContext = requireReply && request.fromAgentId && !crossChannel
267
+ ? { fromId: request.fromAgentId, messageId: msg.messageId }
268
+ : null
269
+ const msgText = msg.parts.map((p) => {
270
+ if ('text' in p) return p.text
271
+ if ('data' in p) return JSON.stringify(p.data)
272
+ if ('url' in p) return p.url
273
+ if ('raw' in p) return p.raw
274
+ return ''
275
+ }).join('\n')
276
+ const prompt = peerPrompt({
277
+ agentName: this.agentName,
278
+ role: this.agentRole,
279
+ channelId: this.channelId,
280
+ ctxPrefix: await this.contextPrefix(),
281
+ manual: systemManual(),
282
+ memory: request.memory,
283
+ fromId,
284
+ messageId: msg.messageId,
285
+ requireReply,
286
+ isReply: typeof msg.metadata?.['x-aw-in-reply-to'] === 'string',
287
+ crossChannel,
288
+ fromChannel: typeof msg.metadata?.['x-aw-from-channel'] === 'string' ? String(msg.metadata['x-aw-from-channel']) : '',
289
+ msgText,
290
+ })
291
+ yield* this.streamTurn(prompt, undefined, this.config.promptTimeoutMs ?? CODEX_TURN_STALL_MS, ctx.signal)
292
+ }
293
+
294
+ // ===== 回合执行 =====
295
+
296
+ /**
297
+ * 流式回合:turn/start → 逐事件产出(delta/status)→ turn/completed 收口。
298
+ * artifact 在 done 前注入(item/completed agentMessage 聚合文本)。
299
+ */
300
+ private async* streamTurn(prompt: string, taskId: string | undefined, timeoutMs: number, signal?: AbortSignal): AsyncGenerator<AgentEvent, void, unknown> {
301
+ const client = this.client
302
+ if (!client || !this.threadId) {
303
+ yield { kind: 'error', error: { code: 'CODEX_NOT_READY', message: 'codex 会话未就绪' } }
304
+ return
305
+ }
306
+ const queue: AgentEvent[] = []
307
+ let isDone = false
308
+ let resolveWait: (() => void) | null = null
309
+ let lastActivity = Date.now()
310
+ let agentText = ''
311
+ this.turnActive = true
312
+ this.turnId = null
313
+
314
+ const enqueue = (e: AgentEvent): void => {
315
+ if (e.kind === 'done' && !isDone) {
316
+ if (agentText.trim()) {
317
+ queue.push({
318
+ kind: 'artifact',
319
+ artifact: { artifactId: randomUUID(), name: 'output', parts: [{ text: agentText.trim() }] },
320
+ lastChunk: true,
321
+ totalChunks: 1,
322
+ })
323
+ }
324
+ this.turnActive = false
325
+ }
326
+ if (e.kind === 'done' || e.kind === 'error') {
327
+ isDone = true
328
+ this.turnActive = false
329
+ flushDelta()
330
+ }
331
+ queue.push(e)
332
+ lastActivity = Date.now()
333
+ resolveWait?.()
334
+ resolveWait = null
335
+ }
336
+
337
+ const flushDelta = (): void => {
338
+ if (this.deltaTimer) {
339
+ clearTimeout(this.deltaTimer)
340
+ this.deltaTimer = null
341
+ }
342
+ if (!this.deltaBuf) return
343
+ const text = this.deltaBuf
344
+ this.deltaBuf = ''
345
+ queue.push({ kind: 'delta', delta: { text } })
346
+ }
347
+ const pushDelta = (text: string): void => {
348
+ this.deltaBuf += text
349
+ this.deltaTimer ??= setTimeout(() => {
350
+ this.deltaTimer = null
351
+ flushDelta()
352
+ }, 50)
353
+ }
354
+
355
+ // 通知订阅(审批请求处理器已在 startClient 挂接,此处只处理回合通知)
356
+ const unsub = client.onNotification((method, params) => {
357
+ const p = (params ?? {}) as Record<string, unknown>
358
+ lastActivity = Date.now()
359
+ if (method === 'item/agentMessage/delta') {
360
+ const delta = typeof p.delta === 'string' ? p.delta : ''
361
+ if (delta) {
362
+ this.turnActive = true
363
+ pushDelta(delta)
364
+ }
365
+ return
366
+ }
367
+ if (method === 'turn/started') {
368
+ const turn = p.turn as Record<string, unknown> | undefined
369
+ this.turnId = typeof turn?.id === 'string' ? turn.id : this.turnId
370
+ this.turnActive = true
371
+ enqueue({ kind: 'status', status: { state: 'WORKING', timestamp: new Date().toISOString() } })
372
+ return
373
+ }
374
+ if (method === 'item/started') {
375
+ const item = p.item as Record<string, unknown> | undefined
376
+ const t = String(item?.type ?? '')
377
+ if (['commandExecution', 'mcpToolCall', 'fileChange', 'dynamicToolCall', 'webSearch', 'collabToolCall'].includes(t)) {
378
+ const label = t === 'commandExecution'
379
+ ? String(item?.command ?? 'command')
380
+ : String(item?.tool ?? item?.title ?? t)
381
+ enqueue({
382
+ kind: 'status',
383
+ status: {
384
+ state: 'WORKING',
385
+ message: {
386
+ messageId: randomUUID(),
387
+ contextId: this.channelId,
388
+ role: 'ROLE_AGENT',
389
+ parts: [{ text: `🔧 ${label}${toolArgsPreview(item?.input ?? item?.arguments ?? item?.command)}` }],
390
+ },
391
+ timestamp: new Date().toISOString(),
392
+ },
393
+ })
394
+ }
395
+ if (t === 'contextCompaction') this.compacting = true
396
+ return
397
+ }
398
+ if (method === 'item/completed') {
399
+ const item = p.item as Record<string, unknown> | undefined
400
+ if (String(item?.type ?? '') === 'agentMessage' && typeof item?.text === 'string') {
401
+ agentText += item.text
402
+ }
403
+ if (String(item?.type ?? '') === 'contextCompaction') this.compacting = false
404
+ return
405
+ }
406
+ if (method === 'thread/tokenUsage/updated') {
407
+ const usage = (p.usage ?? p.tokenUsage ?? p) as Record<string, unknown>
408
+ const input = Number(usage.input_tokens ?? usage.inputTokens ?? usage.total_tokens ?? usage.totalTokens)
409
+ if (Number.isFinite(input) && input > 0) this.lastUsage = { input, at: Date.now() }
410
+ return
411
+ }
412
+ if (method === 'turn/completed') {
413
+ const turn = (p.turn ?? {}) as Record<string, unknown>
414
+ const status = String(turn.status ?? 'completed')
415
+ if (status === 'failed') {
416
+ const err = (turn.error ?? {}) as Record<string, unknown>
417
+ const info = String(err.codexErrorInfo ?? '')
418
+ enqueue({
419
+ kind: 'error',
420
+ error: {
421
+ code: info ? `CODEX_${info}` : 'CODEX_LLM_ERROR',
422
+ message: String(err.message ?? 'codex 回合失败'),
423
+ },
424
+ })
425
+ }
426
+ else {
427
+ // completed / interrupted:中断按 done 收口(消息按已处理落账)
428
+ enqueue({ kind: 'done', final: taskId ? { taskId } : undefined })
429
+ }
430
+ return
431
+ }
432
+ if (method === 'error') {
433
+ enqueue({
434
+ kind: 'error',
435
+ error: { code: 'CODEX_ERROR', message: String(p.message ?? p.error ?? JSON.stringify(params).slice(0, 300)) },
436
+ })
437
+ }
438
+ })
439
+
440
+ // abort 传导
441
+ const onAbort = (): void => {
442
+ log.warn(`[CodexAgent:${this.selfAgentId}] run 被 abort → turn/interrupt,taskId=${taskId ?? '-'}`)
443
+ void client.request('turn/interrupt', { threadId: this.threadId, ...(this.turnId ? { turnId: this.turnId } : {}) }, 15_000).catch(() => {})
444
+ setTimeout(() => {
445
+ if (!isDone) enqueue({ kind: 'done', final: taskId ? { taskId } : undefined })
446
+ }, 5000)
447
+ }
448
+ if (signal?.aborted) onAbort()
449
+ else signal?.addEventListener('abort', onAbort, { once: true })
450
+
451
+ // 投递 turn
452
+ try {
453
+ await client.request('turn/start', {
454
+ threadId: this.threadId,
455
+ input: [{ type: 'text', text: prompt }],
456
+ }, 60_000)
457
+ }
458
+ catch (err) {
459
+ unsub()
460
+ signal?.removeEventListener('abort', onAbort)
461
+ this.turnActive = false
462
+ yield {
463
+ kind: 'error',
464
+ error: {
465
+ code: 'CODEX_PROMPT_FAILED',
466
+ message: `codex turn/start 失败: ${err instanceof Error ? err.message : String(err)}`,
467
+ },
468
+ }
469
+ return
470
+ }
471
+
472
+ try {
473
+ while (!isDone || queue.length > 0) {
474
+ if (queue.length === 0 && !isDone) {
475
+ const remaining = timeoutMs - (Date.now() - lastActivity)
476
+ if (remaining <= 0) {
477
+ void client.request('turn/interrupt', { threadId: this.threadId }, 15_000).catch(() => {})
478
+ enqueue({
479
+ kind: 'error',
480
+ error: { code: 'CODEX_TURN_STALLED', message: `回合停滞 ${Math.round(timeoutMs / 1000)}s 无事件,已中止` },
481
+ })
482
+ continue
483
+ }
484
+ await new Promise<void>((r) => {
485
+ resolveWait = r
486
+ setTimeout(() => {
487
+ resolveWait = null
488
+ r()
489
+ }, remaining + 100)
490
+ })
491
+ }
492
+ while (queue.length > 0) {
493
+ yield queue.shift()!
494
+ }
495
+ }
496
+ }
497
+ finally {
498
+ flushDelta()
499
+ unsub()
500
+ signal?.removeEventListener('abort', onAbort)
501
+ this.turnActive = false
502
+ this.turnId = null
503
+ }
504
+ }
505
+
506
+ /** supervise 用:收齐整个回合的事件流(supervise 决策解析在调用方) */
507
+ protected async collectTurnEvents(prompt: string, timeoutMs: number, signal?: AbortSignal): Promise<AgentEvent[]> {
508
+ const events: AgentEvent[] = []
509
+ for await (const e of this.streamTurn(prompt, undefined, timeoutMs, signal)) {
510
+ events.push(e)
511
+ if (e.kind === 'error') break
512
+ }
513
+ return events
514
+ }
515
+
516
+ // ===== HITL 登记 =====
517
+
518
+ private registerApprovalHitl(rpcId: string | number, itemId: string, kindLabel: 'command' | 'file', p: Record<string, unknown>): void {
519
+ const id = `codex-${itemId}`
520
+ if (this.pendingApprovals.has(id)) return
521
+ const title = kindLabel === 'command'
522
+ ? `codex 命令审批:${String(p.command ?? '(未知命令)').slice(0, 200)}`
523
+ : `codex 文件变更审批:${String(p.cwd ?? p.grantRoot ?? '')}`
524
+ const registry = getHitlRegistry()
525
+ registry.register({
526
+ kind: 'codex-approval',
527
+ id,
528
+ agentId: this.selfAgentId,
529
+ agentName: this.agentName,
530
+ channelId: this.channelId,
531
+ pid: this.client?.pid,
532
+ method: 'confirm',
533
+ title,
534
+ detail: typeof p.reason === 'string' ? p.reason : undefined,
535
+ createdAt: new Date().toISOString(),
536
+ expiresAt: null,
537
+ })
538
+ const timeoutMs = harnessSettings().hitl_timeout_ms
539
+ const timer = timeoutMs > 0
540
+ ? setTimeout(() => {
541
+ void this.respondHitl('codex-approval', id, { cancelled: true }).catch(() => {})
542
+ registry.resolve('codex-approval', id, 'expired')
543
+ }, timeoutMs)
544
+ : null
545
+ this.pendingApprovals.set(id, { rpcId, timer })
546
+ }
547
+
548
+ private registerUserInputHitl(rpcId: string | number, p: Record<string, unknown>): void {
549
+ const id = `codex-input-${randomUUID().slice(0, 8)}`
550
+ const questions = Array.isArray(p.questions) ? p.questions : []
551
+ const first = (questions[0] ?? {}) as Record<string, unknown>
552
+ const registry = getHitlRegistry()
553
+ registry.register({
554
+ kind: 'codex-approval',
555
+ id,
556
+ agentId: this.selfAgentId,
557
+ agentName: this.agentName,
558
+ channelId: this.channelId,
559
+ pid: this.client?.pid,
560
+ method: 'input',
561
+ title: String(first.question ?? first.header ?? 'codex 提问'),
562
+ detail: typeof p.reason === 'string' ? p.reason : undefined,
563
+ createdAt: new Date().toISOString(),
564
+ expiresAt: null,
565
+ })
566
+ const timeoutMs = harnessSettings().hitl_timeout_ms
567
+ const timer = timeoutMs > 0
568
+ ? setTimeout(() => {
569
+ void this.respondUserInput(rpcId, id, { cancelled: true }).catch(() => {})
570
+ }, timeoutMs)
571
+ : null
572
+ this.pendingApprovals.set(id, { rpcId, timer })
573
+ }
574
+
575
+ private async respondUserInput(rpcId: string | number, id: string, outcome: { cancelled?: boolean, value?: string }): Promise<void> {
576
+ const pending = this.pendingApprovals.get(id)
577
+ if (!pending) return
578
+ const client = this.client
579
+ if (!client) return
580
+ if (outcome.cancelled) {
581
+ client.respondError(rpcId, -32800, '人工取消')
582
+ getHitlRegistry().resolve('codex-approval', id, 'cancelled')
583
+ }
584
+ else {
585
+ client.respond(rpcId, { answers: [{ answer: outcome.value ?? '' }] })
586
+ getHitlRegistry().resolve('codex-approval', id, 'answered')
587
+ }
588
+ if (pending.timer) clearTimeout(pending.timer)
589
+ this.pendingApprovals.delete(id)
590
+ }
591
+
592
+ // ===== 客户端管理 =====
593
+
594
+ private async ensureClient(ctx: AgentRunContext): Promise<void> {
595
+ if (!this.workspace) this.workspace = ctx.workspace
596
+ if (!this.agentInfo) {
597
+ this.channelId = ctx.channelId
598
+ this.agentRole = ctx.role
599
+ this.refreshIdentity()
600
+ }
601
+ if (this.client?.alive && this.threadId) return
602
+ if (this.client && !this.client.alive) {
603
+ this.client = null
604
+ this.threadId = null
605
+ }
606
+ this.clientStarting ??= this.startClient().finally(() => {
607
+ this.clientStarting = null
608
+ })
609
+ await this.clientStarting
610
+ }
611
+
612
+ private async startClient(): Promise<void> {
613
+ const command = this.config.command ?? harnessSettings().codex_command
614
+ const client = new StdioJsonRpcClient({
615
+ name: 'codex',
616
+ command,
617
+ args: [...(this.config.args ?? []), 'app-server'],
618
+ cwd: this.config.cwd ?? process.cwd(),
619
+ env: generateMcpBridgeEnv({
620
+ agentId: this.selfAgentId,
621
+ token: this.config.token,
622
+ baseUrl: this.config.baseUrl,
623
+ bridgePath: this.config.mcpBridgePath,
624
+ extra: this.config.codexHome ? { CODEX_HOME: this.config.codexHome } : {},
625
+ }).engineEnv,
626
+ requestTimeoutMs: 60_000,
627
+ })
628
+ const pidRef = { pid: undefined as number | undefined }
629
+ client.onExit((code) => {
630
+ if (pidRef.pid) markHarnessProcessExit(pidRef.pid, code)
631
+ this.threadId = null
632
+ log.warn(`[CodexAgent:${this.selfAgentId}] codex app-server 退出(code=${code});下回合自动重生`)
633
+ })
634
+ await client.start()
635
+ pidRef.pid = client.pid
636
+ if (client.pid) {
637
+ registerHarnessProcess(client.pid, { harness: 'codex', command, args: ['app-server'] })
638
+ bindHarnessProcess(client.pid, { agentId: this.selfAgentId, channelId: this.channelId, name: this.agentName, role: this.agentRole })
639
+ }
640
+
641
+ // 握手
642
+ await client.request('initialize', {
643
+ clientInfo: { name: 'agentworkshop', title: 'AgentWorkShop', version: '0.8.0' },
644
+ }, 30_000)
645
+ client.notify('initialized', {})
646
+
647
+ // MCP 桥装配(per-agent CODEX_HOME 的 config.toml [mcp_servers.aw])
648
+ this.writeCodexHomeConfig()
649
+
650
+ // 建会话
651
+ const result = await client.request('thread/start', {
652
+ ...(this.config.model ? { model: this.config.model } : {}),
653
+ cwd: this.config.cwd ?? process.cwd(),
654
+ approvalPolicy: this.config.approvalPolicy ?? 'on-request',
655
+ sandbox: this.config.sandbox ?? 'workspace-write',
656
+ }, 60_000) as Record<string, unknown>
657
+ const thread = (result?.thread ?? result) as Record<string, unknown>
658
+ this.threadId = typeof thread?.id === 'string' ? thread.id : null
659
+ if (!this.threadId) throw new Error('codex thread/start 未返回 thread id')
660
+
661
+ // 事件监听挂接(通知在 streamTurn 内按回合订阅;此处仅挂请求处理器)
662
+ client.onRequest((req) => {
663
+ if (req.method === 'item/commandExecution/requestApproval' || req.method === 'item/fileChange/requestApproval') {
664
+ const p = (req.params ?? {}) as Record<string, unknown>
665
+ this.registerApprovalHitl(req.id, String(p.itemId ?? randomUUID()), req.method === 'item/fileChange/requestApproval' ? 'file' : 'command', p)
666
+ }
667
+ else if (req.method === 'tool/requestUserInput') {
668
+ const p = (req.params ?? {}) as Record<string, unknown>
669
+ this.registerUserInputHitl(req.id, p)
670
+ }
671
+ else {
672
+ client.respondError(req.id, -32601, `方法不存在: ${req.method}`)
673
+ }
674
+ })
675
+
676
+ this.client = client
677
+ }
678
+
679
+ /**
680
+ * per-agent CODEX_HOME config.toml:注册 MCP 桥(required=true,桥挂了宁可失败)。
681
+ * 已有 config.toml(从全局目录种子化复制)→ 追加本段(保留用户的 provider/model
682
+ * 配置,自定义网关/密钥不丢失);全新目录 → 独立写入。
683
+ *
684
+ * 无条件种子化(不再仅当 effort 配置时才挂桥):MCP 桥是 codex worker 的 host
685
+ * 工具面(complete_task/ops_log/…)唯一入口;不挂桥的实例模型只能拿自带 shell
686
+ * 乱翻工作区,永远调不到 complete_task → 任务停滞被看门狗回收
687
+ * (实测 codex 任务终态 CANCELED、交付物却只落在频道消息里的根因)。
688
+ */
689
+ private writeCodexHomeConfig(): void {
690
+ let home = this.config.codexHome
691
+ if (!home) {
692
+ // 自动种子化 per-agent CODEX_HOME(拷贝全局凭据/网关配置,保登录与自定义
693
+ // provider;追加 MCP 段 + 顶部 effort)
694
+ home = join(tmpdir(), `aw-codex-${this.selfAgentId.slice(0, 8)}`)
695
+ this.config.codexHome = home
696
+ for (const f of ['auth.json', 'config.toml', 'cc-switch-model-catalog.json']) {
697
+ try {
698
+ const src = join(process.env.HOME ?? process.env.USERPROFILE ?? '', '.codex', f)
699
+ if (existsSync(src)) copyFileSync(src, join(home, f))
700
+ }
701
+ catch { /* 单文件缺失忽略 */ }
702
+ }
703
+ }
704
+ try {
705
+ mkdirSync(home, { recursive: true })
706
+ const configFile = join(home, 'config.toml')
707
+ const env = generateMcpBridgeEnv({ agentId: this.selfAgentId, token: this.config.token, baseUrl: this.config.baseUrl, bridgePath: this.config.mcpBridgePath })
708
+ const envLines = Object.entries(env.bridgeEnv).map(([k, v]) => ` ${k} = "${v}"`).join('\n')
709
+ const section = [
710
+ ``,
711
+ `[mcp_servers.aw]`,
712
+ `command = ${JSON.stringify(process.execPath)}`,
713
+ `args = [${JSON.stringify(join(process.env.AW_PACKAGE_ROOT ?? process.cwd(), 'server', 'harness', 'aw-mcp-bridge.mjs'))}]`,
714
+ `required = true`,
715
+ ``,
716
+ `[mcp_servers.aw.env]`,
717
+ envLines,
718
+ ``,
719
+ ].join('\n')
720
+ // effort 走 TOML 顶层键(model_reasoning_effort):必须位于任何 [table] 之前 → 顶部插入
721
+ const effortLine = this.config.effort
722
+ ? `model_reasoning_effort = ${JSON.stringify(this.config.effort)}
723
+ `
724
+ : ''
725
+ if (existsSync(configFile)) {
726
+ const existing = readFileSync(configFile, 'utf-8')
727
+ const withoutDup = this.config.effort
728
+ ? existing.replace(/^model_reasoning_effort = .*\n?/m, '')
729
+ : existing
730
+ if (withoutDup.includes('[mcp_servers.aw]')) {
731
+ if (effortLine && !withoutDup.startsWith(effortLine.trim())) {
732
+ writeFileSync(configFile, effortLine + withoutDup, 'utf-8')
733
+ }
734
+ return
735
+ }
736
+ writeFileSync(configFile, effortLine + withoutDup + section, 'utf-8')
737
+ }
738
+ else {
739
+ writeFileSync(configFile, effortLine + section, 'utf-8')
740
+ }
741
+ }
742
+ catch (err) {
743
+ log.warn(`[CodexAgent:${this.selfAgentId}] CODEX_HOME config 写入失败(信任全局配置):`, err instanceof Error ? err.message : err)
744
+ }
745
+ }
746
+ }