/** * agentic-app-template's code-mode worker entry (`@openai/agents`). Mirrors * `@guuey/host`'s `run-openai.ts` — the platform's own OpenAI wiring over this * exact snapshot shape: build an `Agent` from the resolved system prompt + * MCP endpoints, run it streamed, emit every raw `RunStreamEvent` to the * Router via `emit.native`, and resolve the turn's final text from * `stream.finalOutput`. * * `MaxTurnsExceededError` handling mirrors `run-openai.ts`'s INTENT — there * max-turns is a soft terminal (`done(finalText, "max_turns")` after a * sentinel), NOT a hard `error`; the partial output is still the turn's * result. `serveNative`'s handler contract has neither a stopReason nor a * sentinel channel (a return is always `done(.., "end_turn")`; a throw is a * hard `error` that would discard the partial text), so the cutoff is made * visible by appending a note to the returned result instead of rethrowing. */ import { Agent, MaxTurnsExceededError, MCPServerStreamableHttp, run, setDefaultOpenAIKey } from "@openai/agents"; import type { MCPServer } from "@openai/agents"; import { toolGateErrors, toolGatePredicate } from "@guuey/config"; import { MODEL_TEXT_INPUT_KIND_CAPABILITIES, TOOL_GATES_CAPABILITY, mcpToolCustomData, modelVisibleToolFilterWith, openaiTurnInput, readLaunchEnvelope, serveNative, } from "@guuey/worker"; import { loadAgent, systemPrompt, mcpEndpoints, withHistory } from "./agent-config.js"; /** * The model credential (guuey#2139). A platform launch delivers it privately * on a descriptor (`readLaunchEnvelope`, read once, here); otherwise it comes * from `OPENAI_API_KEY`, as before. It is handed to the SDK in memory and * never written to `process.env`, so nothing this worker spawns inherits it. */ const envelope = await readLaunchEnvelope(); const modelKey = envelope?.model?.token ?? (process.env.OPENAI_API_KEY || undefined); if (modelKey) setDefaultOpenAIKey(modelKey); await serveNative( async (invoke, emit) => { const agent = loadAgent(invoke); const endpoints = mcpEndpoints(invoke, agent); // A denylist entry that cannot be read makes the gates keep no MCP tool // this turn (they fail closed); name it so it can be fixed. for (const error of toolGateErrors(agent.tools)) console.error(`[guuey] ${error}`); const mcpServers: MCPServerStreamableHttp[] = Object.entries(endpoints).map( ([name, ep]) => new MCPServerStreamableHttp({ url: ep.url, name, ...(Object.keys(ep.headers).length > 0 ? { requestInit: { headers: ep.headers } } : {}), // The ONLY channel that carries an MCP result's structuredContent + // _meta onto the wire (guuey#981): the card's ui:// locator and the // ggui render-cache marker ride here. Without it a code-mode OpenAI // app shows "✓ ggui render" and no card. customDataExtractor: mcpToolCustomData, // The model is offered a tool only when both hold: // - MCP Apps visibility: a tool whose `_meta.ui.visibility` omits // "model" (the rendered view's own calls) is not offered. // - The agent's tool gates (`tools.allowlist` / `tools.denylist` in // guuey.json, as narrowed by the turn's mode) keep it on this // server, and the platform does not withhold it this turn // (`invoke.withheldTools`). Deny wins; `["@none"]` keeps nothing. toolFilter: modelVisibleToolFilterWith(toolGatePredicate(agent.tools, name, invoke.withheldTools)), }) ); try { for (const server of mcpServers) { await server.connect(); } const sdkAgent = new Agent({ name: "guuey-agent", instructions: systemPrompt(invoke, agent) ?? "You are a helpful assistant.", mcpServers: mcpServers as MCPServer[], ...(agent.model ? { model: agent.model } : {}), }); // The turn's images (if any) as input_image parts, then the text. const stream = await run(sdkAgent, openaiTurnInput(invoke, withHistory(invoke)), { stream: true, ...(agent.runtime?.maxTurns ? { maxTurns: agent.runtime.maxTurns } : {}), }); for await (const event of stream) { emit.native(JSON.parse(JSON.stringify(event))); } try { await stream.completed; } catch (err) { if (err instanceof MaxTurnsExceededError) { // Soft terminal (see module doc): keep whatever partial output the // stream produced and make the cutoff visible in the result text. const partial = typeof stream.finalOutput === "string" ? stream.finalOutput : ""; const note = "[agent stopped: maxTurns limit reached]"; return partial ? `${partial}\n\n${note}` : note; } throw err; } return typeof stream.finalOutput === "string" ? stream.finalOutput : ""; } finally { await Promise.allSettled(mcpServers.map((s) => s.close())); } }, { framework: "openai-agents-sdk", sdkName: "@openai/agents", // Every turn's text goes through withHistory (agent-config.ts), so this // worker handles a turn sent without text; the Router reads it here. // It also applies the agent's tool gates (each server's `toolFilter` above), so every hello // says so with tool-gates.v1 (guuey#2670). capabilities: [...MODEL_TEXT_INPUT_KIND_CAPABILITIES, TOOL_GATES_CAPABILITY], // The turn's images reach the model (openaiTurnInput above) and are stored // in the turn's session storage, so the hello says so. images: true, } );