From 1803587ee0beedfa98d524f21236531d9ca61876 Mon Sep 17 00:00:00 2001 From: Arach Tchoupani Date: Fri, 5 Jun 2026 13:36:29 -0400 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8=20Add=20a=20Codex=20provider=20to?= =?UTF-8?q?=20the=20Scoutbot=20assistant=20service?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Abstract the assistant model call behind callAssistantModel with an openai | codex provider, gated by OPENSCOUT_SCOUTBOT_ASSISTANT_PROVIDER (auto). Sessions track responseProvider so OpenAI response-ids and Codex thread-ids are threaded correctly; brief TTS polish stays OpenAI-only. --- packages/web/server/scoutbot-assistant.ts | 174 ++++++++++++++++++---- 1 file changed, 147 insertions(+), 27 deletions(-) diff --git a/packages/web/server/scoutbot-assistant.ts b/packages/web/server/scoutbot-assistant.ts index 27b48aef..71e887b7 100644 --- a/packages/web/server/scoutbot-assistant.ts +++ b/packages/web/server/scoutbot-assistant.ts @@ -25,6 +25,7 @@ export type ScoutbotAssistantSession = ScoutbotAssistantSessionSummary & { export type ScoutbotAssistantConfig = { editable: true; model: string; + provider: ScoutbotAssistantProviderPreference; systemPrompt: string; }; @@ -127,6 +128,7 @@ export type ScoutbotAssistantContextSnapshot = { }; export type ScoutbotBriefCall = { + provider: ScoutbotAssistantProvider; model: string; systemPrompt: string; operatorRequest: string; @@ -193,11 +195,28 @@ type StoredSession = { createdAt: number; updatedAt: number; model: string; + responseProvider: ScoutbotAssistantProvider | null; previousResponseId: string | null; messages: ScoutbotAssistantMessage[]; archivedAt: number | null; }; +export type ScoutbotAssistantProvider = "openai" | "codex"; + +export type ScoutbotAssistantProviderPreference = "auto" | ScoutbotAssistantProvider; + +export type ScoutbotCodexAssistantInvocation = { + sessionId: string; + threadId?: string | null; + systemPrompt: string; + prompt: string; + timeoutMs?: number; +}; + +export type ScoutbotCodexAssistantInvoker = ( + input: ScoutbotCodexAssistantInvocation, +) => Promise<{ output: string; threadId: string }>; + type OpenAIResponsePayload = { id?: unknown; output_text?: unknown; @@ -275,6 +294,7 @@ export function createScoutbotAssistantService(input: { currentDirectory: string; loadContext: (route?: unknown) => Promise> | Record; resolveApiKey?: () => Promise | string | null | undefined; + invokeCodex?: ScoutbotCodexAssistantInvoker; env?: NodeJS.ProcessEnv; fetchImpl?: typeof fetch; }): ScoutbotAssistantService { @@ -289,6 +309,7 @@ export function createScoutbotAssistantService(input: { ) ?? DEFAULT_MODEL; let systemPrompt = firstNonEmptyString(env.OPENSCOUT_SCOUTBOT_ASSISTANT_PROMPT) ?? DEFAULT_SYSTEM_PROMPT; + const providerPreference = normalizeProviderPreference(env.OPENSCOUT_SCOUTBOT_ASSISTANT_PROVIDER); const activeSessionLimit = clampInteger( env.OPENSCOUT_SCOUTBOT_ACTIVE_SESSION_LIMIT, DEFAULT_ACTIVE_SESSION_LIMIT, @@ -334,7 +355,7 @@ export function createScoutbotAssistantService(input: { archivedCount: sessions.filter((session) => session.archivedAt !== null).length, totalCount: sessions.length, }, - config: { editable: true, model, systemPrompt }, + config: { editable: true, model, provider: providerPreference, systemPrompt }, }); const enforceSessionRetention = (): void => { const active = activeSessions(); @@ -380,13 +401,13 @@ export function createScoutbotAssistantService(input: { }); return { - getConfig: () => ({ editable: true, model, systemPrompt }), + getConfig: () => ({ editable: true, model, provider: providerPreference, systemPrompt }), updateConfig: (next) => { const nextModel = next.model?.trim(); const nextPrompt = next.systemPrompt?.trim(); if (nextModel) model = nextModel; if (nextPrompt) systemPrompt = nextPrompt; - return { editable: true, model, systemPrompt }; + return { editable: true, model, provider: providerPreference, systemPrompt }; }, getSessionState: snapshot, resetSession: () => { @@ -420,22 +441,20 @@ export function createScoutbotAssistantService(input: { throw new ScoutbotAssistantError("body is required", 400); } - const apiKey = await resolveApiKey(); - if (!apiKey) { - throw new ScoutbotAssistantError("An OpenAI API key is required for Scoutbot assistant. Add one in Settings > Credentials or set OPENAI_API_KEY.", 503); - } - const session = ensureSession(); const context = await contextSnapshot(route); - - const response = await callOpenAIResponse({ - apiKey, - baseUrl: firstNonEmptyString(env.OPENAI_BASE_URL, env.OPENSCOUT_OPENAI_BASE_URL) + const response = await callAssistantModel({ + apiKey: await resolveApiKey(), + codexInvoker: input.invokeCodex, + providerPreference, + openAIBaseUrl: firstNonEmptyString(env.OPENAI_BASE_URL, env.OPENSCOUT_OPENAI_BASE_URL) ?? DEFAULT_OPENAI_BASE_URL, fetchImpl, model, systemPrompt, - previousResponseId: session.previousResponseId, + sessionId: session.id, + previousResponseId: session.responseProvider === "openai" ? session.previousResponseId : null, + threadId: session.responseProvider === "codex" ? session.previousResponseId : null, body: trimmed, context, }); @@ -462,6 +481,7 @@ export function createScoutbotAssistantService(input: { session.messages.splice(0, Math.max(0, session.messages.length - MAX_MESSAGES_PER_SESSION)); session.updatedAt = assistantMessage.createdAt; session.model = model; + session.responseProvider = response.provider; session.previousResponseId = response.id ?? session.previousResponseId; if (session.title === "New Scout Session") { session.title = titleFromRequest(trimmed); @@ -475,25 +495,25 @@ export function createScoutbotAssistantService(input: { }; }, createBrief: async ({ route, ttlMs, mode = "tour", onCaptured }) => { - const apiKey = await resolveApiKey(); - if (!apiKey) { - throw new ScoutbotAssistantError("An OpenAI API key is required for Scoutbot assistant. Add one in Settings > Credentials or set OPENAI_API_KEY.", 503); - } - const now = Date.now(); const resolvedTtlMs = clampNumber(ttlMs ?? DEFAULT_BRIEF_TTL_MS, MIN_BRIEF_TTL_MS, MAX_BRIEF_TTL_MS); const context = await contextSnapshot(route); const resolvedSystemPrompt = briefSystemPrompt(systemPrompt, mode); const operatorRequest = briefOperatorRequest(resolvedTtlMs, mode); const analystStart = Date.now(); - const response = await callOpenAIResponse({ + const apiKey = await resolveApiKey(); + const response = await callAssistantModel({ apiKey, - baseUrl: firstNonEmptyString(env.OPENAI_BASE_URL, env.OPENSCOUT_OPENAI_BASE_URL) + codexInvoker: input.invokeCodex, + providerPreference, + openAIBaseUrl: firstNonEmptyString(env.OPENAI_BASE_URL, env.OPENSCOUT_OPENAI_BASE_URL) ?? DEFAULT_OPENAI_BASE_URL, fetchImpl, model, systemPrompt: resolvedSystemPrompt, + sessionId: `brief-${mode}`, previousResponseId: null, + threadId: null, body: operatorRequest, context, }); @@ -514,7 +534,7 @@ export function createScoutbotAssistantService(input: { // cadence. On failure we keep the derived narration and skip TTS // polish — the brief is still readable. let presenterTelemetry: BriefPresenterTelemetry | undefined; - if (brief.markdown) { + if (brief.markdown && response.provider === "openai" && apiKey) { const presenterStart = Date.now(); if (!presenterRateGuardAllow(presenterStart)) { // SCO-037 step 6: cost cap. Don't burn the relay budget if briefs @@ -589,6 +609,7 @@ export function createScoutbotAssistantService(input: { onCaptured({ snapshot: context, call: { + provider: response.provider, model, systemPrompt: resolvedSystemPrompt, operatorRequest, @@ -616,6 +637,87 @@ export class ScoutbotAssistantError extends Error { const OPENAI_CALL_TIMEOUT_MS = 60_000; +async function callAssistantModel(input: { + apiKey?: string | null; + codexInvoker?: ScoutbotCodexAssistantInvoker; + providerPreference: ScoutbotAssistantProviderPreference; + openAIBaseUrl: string; + fetchImpl: typeof fetch; + model: string; + systemPrompt: string; + sessionId: string; + previousResponseId: string | null; + threadId: string | null; + body: string; + context: ScoutbotAssistantContextSnapshot; +}): Promise<{ + provider: ScoutbotAssistantProvider; + id: string | null; + text: string; + usage: BriefTokenUsage | null; +}> { + const apiKey = input.apiKey?.trim(); + if (input.providerPreference !== "codex" && apiKey) { + const response = await callOpenAIResponse({ + apiKey, + baseUrl: input.openAIBaseUrl, + fetchImpl: input.fetchImpl, + model: input.model, + systemPrompt: input.systemPrompt, + previousResponseId: input.previousResponseId, + body: input.body, + context: input.context, + }); + return { provider: "openai", ...response }; + } + + if (input.providerPreference !== "openai" && input.codexInvoker) { + try { + const response = await callCodexResponse({ + invokeCodex: input.codexInvoker, + sessionId: input.sessionId, + threadId: input.threadId, + systemPrompt: input.systemPrompt, + body: input.body, + context: input.context, + }); + return { provider: "codex", id: response.threadId, text: response.output, usage: null }; + } catch (error) { + if (error instanceof ScoutbotAssistantError) throw error; + throw new ScoutbotAssistantError( + `Local Codex fallback failed for Scoutbot assistant: ${errorMessage(error)}. Check that Codex is installed and signed in.`, + 503, + ); + } + } + + if (input.providerPreference === "codex") { + throw new ScoutbotAssistantError("Codex is configured for Scoutbot assistant, but the local Codex runtime is not available.", 503); + } + + throw new ScoutbotAssistantError( + "Scoutbot assistant needs either a local Codex runtime or an OpenAI API key. Install or sign in to Codex, or add OPENAI_API_KEY.", + 503, + ); +} + +async function callCodexResponse(input: { + invokeCodex: ScoutbotCodexAssistantInvoker; + sessionId: string; + threadId: string | null; + systemPrompt: string; + body: string; + context: ScoutbotAssistantContextSnapshot; +}): Promise<{ output: string; threadId: string }> { + return input.invokeCodex({ + sessionId: input.sessionId, + threadId: input.threadId, + systemPrompt: input.systemPrompt, + prompt: buildAssistantUserPrompt(input.body, input.context), + timeoutMs: OPENAI_CALL_TIMEOUT_MS, + }); +} + async function callOpenAIResponse(input: { apiKey: string; baseUrl: string; @@ -650,12 +752,7 @@ async function callOpenAIResponse(input: { content: [ { type: "input_text", - text: [ - `Operator request:\n${input.body}`, - "", - "Current Scout control-plane snapshot:", - JSON.stringify(input.context), - ].join("\n"), + text: buildAssistantUserPrompt(input.body, input.context), }, ], }, @@ -1483,6 +1580,7 @@ function createSession(model: string): StoredSession { createdAt: now, updatedAt: now, model, + responseProvider: null, previousResponseId: null, messages: [], archivedAt: null, @@ -1523,6 +1621,28 @@ function clampInteger(value: string | undefined | null, fallback: number, min: n return Math.min(max, Math.max(min, parsed)); } +function normalizeProviderPreference(value: string | undefined | null): ScoutbotAssistantProviderPreference { + const normalized = value?.trim().toLowerCase(); + if (normalized === "openai" || normalized === "codex") return normalized; + return "auto"; +} + +function buildAssistantUserPrompt( + body: string, + context: ScoutbotAssistantContextSnapshot, +): string { + return [ + `Operator request:\n${body}`, + "", + "Current Scout control-plane snapshot:", + JSON.stringify(context), + ].join("\n"); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + function firstNonEmptyString(...values: Array): string | undefined { for (const value of values) { const trimmed = value?.trim(); From 2e66832971422a40198e811bb44b27acfdab1065 Mon Sep 17 00:00:00 2001 From: Arach Tchoupani Date: Fri, 5 Jun 2026 13:36:29 -0400 Subject: [PATCH 2/2] =?UTF-8?q?=E2=9C=A8=20Fall=20back=20to=20local=20Code?= =?UTF-8?q?x=20when=20no=20OpenAI=20key=20is=20configured?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire a default ScoutbotCodexAssistantInvoker that drives the codex app-server (read-only, never-approve) so the assistant answers without an OpenAI key. Overridable via the scoutbotAssistant.invokeCodex option for tests. --- .../create-openscout-web-server.test.ts | 62 ++++++++++++++++--- .../web/server/create-openscout-web-server.ts | 57 ++++++++++++++++- 2 files changed, 107 insertions(+), 12 deletions(-) diff --git a/packages/web/server/create-openscout-web-server.test.ts b/packages/web/server/create-openscout-web-server.test.ts index 504a9932..3d897529 100644 --- a/packages/web/server/create-openscout-web-server.test.ts +++ b/packages/web/server/create-openscout-web-server.test.ts @@ -1968,10 +1968,16 @@ describe("createOpenScoutWebServer", () => { expect(dismissed.reminders.find((reminder) => reminder.id === due.reminder.id)?.status).toBe("dismissed"); }); - test("returns a setup error when Scoutbot assistant has no OpenAI key", async () => { + test("falls back to local Codex when Scoutbot assistant has no OpenAI key", async () => { useIsolatedOpenScoutHome(); delete process.env.OPENAI_API_KEY; let fetchCalled = false; + const codexCalls: Array<{ + sessionId: string; + threadId?: string | null; + prompt: string; + systemPrompt: string; + }> = []; globalThis.fetch = (async () => { fetchCalled = true; return new Response("{}", { status: 200 }); @@ -1981,6 +1987,20 @@ describe("createOpenScoutWebServer", () => { currentDirectory: "/tmp/openscout", assetMode: "static", staticRoot: makeStaticRoot(), + scoutbotAssistant: { + invokeCodex: async (input) => { + codexCalls.push({ + sessionId: input.sessionId, + threadId: input.threadId, + prompt: input.prompt, + systemPrompt: input.systemPrompt, + }); + return { + output: "Codex fallback works.", + threadId: "codex-thread-1", + }; + }, + }, }); const response = await server.app.request("http://localhost/api/scoutbot/chat", { @@ -1989,17 +2009,28 @@ describe("createOpenScoutWebServer", () => { body: JSON.stringify({ body: "state?" }), }); - expect(response.status).toBe(503); - expect(await response.json()).toEqual({ - error: "An OpenAI API key is required for Scoutbot assistant. Add one in Settings > Credentials or set OPENAI_API_KEY.", - }); + expect(response.status).toBe(200); + const json = await response.json() as { + reply: { body: string }; + responseId: string | null; + session: { messages: Array<{ role: string; body: string }> }; + }; + expect(json.reply.body).toBe("Codex fallback works."); + expect(json.responseId).toBe("codex-thread-1"); + expect(json.session.messages.map((message) => message.role)).toEqual(["user", "assistant"]); expect(fetchCalled).toBe(false); + expect(codexCalls).toHaveLength(1); + expect(codexCalls[0].threadId).toBeNull(); + expect(codexCalls[0].prompt).toContain("Operator request:"); + expect(codexCalls[0].prompt).toContain("Current Scout control-plane snapshot"); + expect(codexCalls[0].systemPrompt).toContain("not a peer agent"); }); - test("does not use a transient request supplied OpenAI key for Scoutbot assistant", async () => { + test("ignores a transient request supplied OpenAI key and still uses configured providers", async () => { useIsolatedOpenScoutHome(); delete process.env.OPENAI_API_KEY; let fetchCalled = false; + const codexCalls: Array<{ prompt: string }> = []; globalThis.fetch = (async (_input, init) => { fetchCalled = true; void init; @@ -2016,6 +2047,15 @@ describe("createOpenScoutWebServer", () => { currentDirectory: "/tmp/openscout", assetMode: "static", staticRoot: makeStaticRoot(), + scoutbotAssistant: { + invokeCodex: async (input) => { + codexCalls.push({ prompt: input.prompt }); + return { + output: "Request key ignored; Codex handled this.", + threadId: "codex-thread-request-key", + }; + }, + }, }); const response = await server.app.request("http://localhost/api/scoutbot/chat", { @@ -2027,11 +2067,13 @@ describe("createOpenScoutWebServer", () => { }), }); - expect(response.status).toBe(503); - expect(await response.json()).toEqual({ - error: "An OpenAI API key is required for Scoutbot assistant. Add one in Settings > Credentials or set OPENAI_API_KEY.", - }); + expect(response.status).toBe(200); + const json = await response.json() as { reply: { body: string }; responseId: string | null }; + expect(json.reply.body).toContain("Codex handled this"); + expect(json.responseId).toBe("codex-thread-request-key"); expect(fetchCalled).toBe(false); + expect(codexCalls).toHaveLength(1); + expect(codexCalls[0].prompt).not.toContain("sk-request-test"); }); test("saves and uses the local Scoutbot OpenAI credential store", async () => { diff --git a/packages/web/server/create-openscout-web-server.ts b/packages/web/server/create-openscout-web-server.ts index fe4854dc..6f976144 100644 --- a/packages/web/server/create-openscout-web-server.ts +++ b/packages/web/server/create-openscout-web-server.ts @@ -115,6 +115,7 @@ import { import { createScoutbotAssistantService, ScoutbotAssistantError, + type ScoutbotCodexAssistantInvoker, type ScoutbotBrief, type ScoutbotBriefCapture, type ScoutbotBriefObservation, @@ -138,7 +139,7 @@ import { startScoutbotRunner, type ScoutbotRunnerHandle, } from "./scoutbot/runner.ts"; -import { SCOUTBOT_AGENT_ID } from "./scoutbot/role.ts"; +import { SCOUTBOT_AGENT_ID, SCOUTBOT_REASONING_EFFORT } from "./scoutbot/role.ts"; import { loadServiceBudgets } from "./service-budgets.ts"; import { buildWorkMaterialsInventory, @@ -186,8 +187,12 @@ import { saveOpenScoutOnboardingProject, skipOpenScoutOnboarding, } from "@openscout/runtime/onboarding"; -import { relayAgentRuntimeDirectory } from "@openscout/runtime/support-paths"; +import { relayAgentLogsDirectory, relayAgentRuntimeDirectory } from "@openscout/runtime/support-paths"; import { readSessionCatalogSync } from "@openscout/runtime/claude-stream-json"; +import { + invokeCodexAppServerAgent, + normalizeCodexAppServerLaunchArgs, +} from "@openscout/runtime/codex-app-server"; function parseConversationKinds(value: string | undefined): ConversationKind[] | undefined { const trimmed = value?.trim(); @@ -287,6 +292,9 @@ export type CreateOpenScoutWebServerOptions = { createVantageHandoff?: (request: OpenScoutVantageHandoffInput) => Promise; terminalRelayHealthcheck?: () => Promise; revealPath?: (targetPath: string) => Promise | void; + scoutbotAssistant?: { + invokeCodex?: ScoutbotCodexAssistantInvoker; + }; scoutbot?: { enabled?: boolean; brokerBaseUrl?: string; @@ -1885,6 +1893,49 @@ async function resolveScoutbotCredentialState( }; } +function createDefaultScoutbotCodexInvoker(currentDirectory: string): ScoutbotCodexAssistantInvoker { + return async (input) => { + const runtimeName = `scoutbot-assistant-${sanitizeSupportPathSegment(input.sessionId)}`; + const result = await invokeCodexAppServerAgent({ + agentName: "scoutbot-assistant", + sessionId: input.sessionId, + cwd: currentDirectory, + systemPrompt: input.systemPrompt, + runtimeDirectory: relayAgentRuntimeDirectory(runtimeName), + logsDirectory: relayAgentLogsDirectory(runtimeName), + launchArgs: buildScoutbotAssistantCodexLaunchArgs(process.env), + ...(input.threadId ? { threadId: input.threadId } : {}), + prompt: input.prompt, + timeoutMs: input.timeoutMs, + approvalPolicy: "never", + sandbox: "read-only", + }); + return { + output: result.output, + threadId: result.threadId, + }; + }; +} + +function buildScoutbotAssistantCodexLaunchArgs(env: NodeJS.ProcessEnv): string[] { + const args: string[] = []; + const model = env.OPENSCOUT_SCOUTBOT_CODEX_MODEL?.trim(); + const reasoningEffort = env.OPENSCOUT_SCOUTBOT_CODEX_REASONING_EFFORT?.trim() + || SCOUTBOT_REASONING_EFFORT; + if (model) args.push("--model", model); + if (reasoningEffort) args.push("--reasoning-effort", reasoningEffort); + return normalizeCodexAppServerLaunchArgs(args); +} + +function sanitizeSupportPathSegment(value: string): string { + const sanitized = value + .trim() + .replace(/[^A-Za-z0-9._-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 80); + return sanitized || "default"; +} + function renderScoutLocalPortal(input: { requestUrl: string; portalHost: string; @@ -2133,6 +2184,8 @@ export async function createOpenScoutWebServer( const config = await loadScoutRelayConfig().catch(() => null); return config?.openaiApiKey ?? scoutbotCredentials.getOpenAIKey(); }, + invokeCodex: options.scoutbotAssistant?.invokeCodex + ?? createDefaultScoutbotCodexInvoker(currentDirectory), }); let scoutbotRunner: ScoutbotRunnerHandle | null = null; if (options.scoutbot?.enabled) {