-
Notifications
You must be signed in to change notification settings - Fork 155
feat(trigger): botmux 驱动 codex-app 三能力(per-turn model/effort + usage + 结构化澄清 awaiting_input) #638
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
a87c1d3
5e933c7
7637961
c740795
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -21,6 +21,8 @@ interface Args { | |
| botName?: string; | ||
| botOpenId?: string; | ||
| locale?: string; | ||
| model?: string; | ||
| reasoningEffort?: string; | ||
| } | ||
|
|
||
| interface PendingRequest { | ||
|
|
@@ -70,6 +72,8 @@ function parseArgs(argv: string[]): Args { | |
| else if (key === '--bot-name' && val !== undefined) { out.botName = val; i++; } | ||
| else if (key === '--bot-open-id' && val !== undefined) { out.botOpenId = val; i++; } | ||
| else if (key === '--locale' && val !== undefined) { out.locale = val; i++; } | ||
| else if (key === '--model' && val !== undefined) { out.model = val; i++; } | ||
| else if (key === '--reasoning-effort' && val !== undefined) { out.reasoningEffort = val; i++; } | ||
| } | ||
| if (!out.sessionId) throw new Error('--session-id is required'); | ||
| return out; | ||
|
|
@@ -267,6 +271,114 @@ let codexVersionChecked = false; | |
| let codexVersion: CodexVersion | undefined; | ||
| let cleanVersionWarningShown = false; | ||
|
|
||
| /** Structured interaction requests (requestUserInput / elicitation) that are | ||
| * held open awaiting a human/programmatic answer instead of being auto-declined. | ||
| * Keyed by a botmux-minted interactionId. `finish` maps the answer text to the | ||
| * codex-app protocol-shaped respond payload and replies to the app-server. */ | ||
| interface PendingInteraction { | ||
| interactionId: string; | ||
| kind: 'clarification' | 'confirmation' | 'authentication'; | ||
| finish: (answerText: string) => void; | ||
| } | ||
| const pendingInteractions = new Map<string, PendingInteraction>(); | ||
| let interactionSeq = 0; | ||
|
|
||
| /** codex-app's requestUserInput answer schema (Codex 0.145 generated types) is | ||
| * `{ answers: { [questionId]: { answers: string[] } } }` — a per-question map, | ||
| * each holding a string array. v1 collapses the single free-text reply (per the | ||
| * botmux↔riff plain-text contract) into the FIRST question's answers array; a | ||
| * multi-question schema is logged so it surfaces rather than silently dropping. */ | ||
| function mapAnswerToRequestUserInput(params: any, answerText: string): { answers: Record<string, { answers: string[] }> } { | ||
| const fieldIds: string[] = Array.isArray(params?.questions) | ||
| ? params.questions.map((q: any) => q?.id).filter((x: any) => typeof x === 'string') | ||
| : []; | ||
| if (fieldIds.length > 1) { | ||
| writeLine(`[codex-app] requestUserInput has ${fieldIds.length} questions; single text answer maps to first (id=${fieldIds[0]})`); | ||
| } | ||
| const key = fieldIds[0] ?? 'answer'; | ||
| return { answers: { [key]: { answers: [answerText] } } }; | ||
| } | ||
|
|
||
| /** Best-effort question text from a codex-app interaction request. The exact | ||
| * param shape varies by codex version; try the common carriers and fall back | ||
| * to the caller's default. */ | ||
| function extractInteractionQuestion(params: any): string | undefined { | ||
| if (!params || typeof params !== 'object') return undefined; | ||
| const candidates = [ | ||
| params.message, | ||
| params.prompt, | ||
| params.question, | ||
| Array.isArray(params.questions) ? params.questions[0]?.prompt ?? params.questions[0]?.question ?? params.questions[0]?.label : undefined, | ||
| ]; | ||
| for (const c of candidates) { | ||
| if (typeof c === 'string' && c.trim()) return c.trim(); | ||
| } | ||
| return undefined; | ||
| } | ||
|
|
||
| function extractInteractionDetails(params: any): string | undefined { | ||
| if (!params || typeof params !== 'object') return undefined; | ||
| const d = params.details ?? params.description ?? params.context; | ||
| return typeof d === 'string' && d.trim() ? d.trim() : undefined; | ||
| } | ||
|
|
||
| /** Map an elicitation carrying OAuth/login links into the riff authChallenge | ||
| * shape. Returns undefined when no link-bearing structure is present (→ the | ||
| * interaction is treated as a plain clarification). */ | ||
| function extractAuthChallenge(params: any): { links: { url: string; label?: string }[]; userCode?: string; instructions?: string; expiresAt?: string } | undefined { | ||
| if (!params || typeof params !== 'object') return undefined; | ||
| const rawLinks = params.links ?? params.authLinks ?? (params.url ? [{ url: params.url }] : undefined); | ||
| if (!Array.isArray(rawLinks)) return undefined; | ||
| const links = rawLinks | ||
| .map((l: any) => (typeof l === 'string' ? { url: l } : (l && typeof l.url === 'string' ? { url: l.url, label: typeof l.label === 'string' ? l.label : undefined } : undefined))) | ||
| .filter((l: any): l is { url: string; label?: string } => !!l); | ||
| if (!links.length) return undefined; | ||
| return { | ||
| links, | ||
| userCode: typeof params.userCode === 'string' ? params.userCode : undefined, | ||
| instructions: typeof params.instructions === 'string' ? params.instructions : undefined, | ||
| expiresAt: typeof params.expiresAt === 'string' ? params.expiresAt : undefined, | ||
| }; | ||
| } | ||
|
|
||
| /** Register a held interaction and emit the awaiting_input marker to the worker | ||
| * (→ daemon → trigger-result). turnId is the runner's active botmux turn so the | ||
| * caller can correlate its answer. */ | ||
| function beginInteraction(input: { | ||
| kind: 'clarification' | 'confirmation' | 'authentication'; | ||
| question: string; | ||
| details?: string; | ||
| authChallenge?: { links: { url: string; label?: string }[]; userCode?: string; instructions?: string; expiresAt?: string }; | ||
| finish: (answerText: string) => void; | ||
| }): void { | ||
| const interactionId = `cai_${args.sessionId}_${++interactionSeq}`; | ||
| pendingInteractions.set(interactionId, { interactionId, kind: input.kind, finish: input.finish }); | ||
| emitMarker('awaiting_input', { | ||
| interactionId, | ||
| turnId: activeTurn?.nativeTurnId ?? undefined, | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1 — 这里发的是 app-server native turn UUID,不是 botmux/riff 的 turn identity。 同文件 final marker 已明确 native id 会破坏 wait map/回复路由,交互同样应携带本次 queued input 冻结的 |
||
| kind: input.kind, | ||
| question: input.question, | ||
| ...(input.details ? { details: input.details } : {}), | ||
| ...(input.authChallenge ? { authChallenge: input.authChallenge } : {}), | ||
| }); | ||
| } | ||
|
|
||
| /** Resolve a held interaction with answer text (from the runner input channel). | ||
| * Unknown/stale ids are ignored (the turn may have moved on). */ | ||
| function answerInteraction(interactionId: string, text: string): void { | ||
| const pending = pendingInteractions.get(interactionId); | ||
| if (!pending) { | ||
| writeLine(`[codex-app] answer for unknown interaction ${interactionId} (ignored)`); | ||
| return; | ||
| } | ||
| pendingInteractions.delete(interactionId); | ||
| try { | ||
| pending.finish(text); | ||
| } catch (err: any) { | ||
| writeLine(`[codex-app] failed to deliver interaction answer: ${err?.message ?? err}`); | ||
| } | ||
| } | ||
|
|
||
| function detectedCodexVersion(): CodexVersion | undefined { | ||
| if (codexVersionChecked) return codexVersion; | ||
| codexVersionChecked = true; | ||
|
|
@@ -313,11 +425,35 @@ function handleServerRequest(msg: JsonObject): boolean { | |
| return true; | ||
| } | ||
| if (method === 'item/tool/requestUserInput') { | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1 — 这里会挂住所有 codex-app 会话,不只 async trigger。 同一个 runner 也服务普通飞书会话、同步 trigger 和其他非轮询调用方;当前没有任何 |
||
| client.respond(msg.id, { answers: {} }); | ||
| // Structured clarification: hold the request open and surface it as an | ||
| // awaiting_input interaction. The answer text arrives later via the runner | ||
| // input channel and is mapped back to codex-app's keyed `answers` shape. | ||
| const params: any = msg.params ?? {}; | ||
| const question = extractInteractionQuestion(params) | ||
| ?? 'The agent needs additional input to continue.'; | ||
| const details = extractInteractionDetails(params); | ||
| beginInteraction({ | ||
| kind: 'clarification', | ||
| question, | ||
| details, | ||
| finish: (text) => client.respond(msg.id, mapAnswerToRequestUserInput(params, text)), | ||
| }); | ||
| return true; | ||
| } | ||
| if (method === 'mcpServer/elicitation/request') { | ||
| client.respond(msg.id, { action: 'cancel', content: null, _meta: null }); | ||
| const params: any = msg.params ?? {}; | ||
| const question = extractInteractionQuestion(params) | ||
| ?? (typeof params.message === 'string' ? params.message : 'The agent is requesting input.'); | ||
| const authChallenge = extractAuthChallenge(params); | ||
| beginInteraction({ | ||
| kind: authChallenge ? 'authentication' : 'clarification', | ||
| question, | ||
| details: extractInteractionDetails(params), | ||
| authChallenge, | ||
| // v1: any answered text = accept + single-field content; cancel is only | ||
| // used on timeout/no-answer (handled daemon-side, not here). | ||
| finish: (text) => client.respond(msg.id, { action: 'accept', content: { answer: text }, _meta: null }), | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1 — 这个映射对通用 elicitation 不成立。 Codex 0.145 的 params 是 tagged union:form/openai-form 携带 |
||
| }); | ||
| return true; | ||
| } | ||
| if (method === 'item/tool/call') { | ||
|
|
@@ -420,7 +556,14 @@ async function ensureThread(): Promise<string> { | |
| cwd: args.cwd, | ||
| approvalPolicy: 'never', | ||
| sandbox: 'danger-full-access', | ||
| config: { shell_environment_policy: { inherit: 'all' } }, | ||
| config: { | ||
| shell_environment_policy: { inherit: 'all' }, | ||
| // model_reasoning_effort is a codex config key (no top-level thread field). | ||
| // 'xhigh' collapses to codex's max 'high'. | ||
| ...(args.reasoningEffort ? { model_reasoning_effort: args.reasoningEffort === 'xhigh' ? 'high' : args.reasoningEffort } : {}), | ||
| }, | ||
| // Per-turn model override → thread-level model (app-server's documented spot). | ||
| ...(args.model && args.model.trim() ? { model: args.model.trim() } : {}), | ||
| serviceName: 'botmux', | ||
| developerInstructions: appDeveloperInstructions(args), | ||
| ephemeral: false, | ||
|
|
@@ -549,6 +692,12 @@ function enqueueLine(line: string): void { | |
| const encoded = trimmed.slice('::botmux-codex-app:'.length); | ||
| try { | ||
| const decoded = JSON.parse(Buffer.from(encoded, 'base64').toString('utf8')); | ||
| if (decoded?.type === 'answer' && typeof decoded.interactionId === 'string') { | ||
| // Programmatic/human answer to a held structured interaction — resolve | ||
| // the codex-app request rather than enqueue a new turn. | ||
| answerInteraction(decoded.interactionId, typeof decoded.text === 'string' ? decoded.text : ''); | ||
| return; | ||
| } | ||
| if (decoded?.type === 'message' && typeof decoded.content === 'string') { | ||
| const codexAppInput = isCodexAppTurnInput(decoded.codexAppInput) | ||
| ? decoded.codexAppInput | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P1 — pending map 没有协议级回收/恢复。 app-server 会在请求被响应或因 turn lifecycle 清理时发
serverRequest/resolved,但 runner 不消费它,也不在 turn completion/interrupt/restart 清理或重放。未答请求会留下 stale closure;daemon 的 awaiting 仅内存,daemon/worker 重连后 runner 可能仍 hold,但新 daemon 已丢 marker并永久报告 running;多个并发请求还会被 daemon 单槽覆盖。请用 requestId↔interactionId 关联消费 resolved,并定义 restart 的 replay 或 fail-closed 策略。