diff --git a/CHANGELOG.md b/CHANGELOG.md index 03231e70700..013818b038e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ Docs: https://docs.openclaw.ai - Models/CLI: split `openclaw models list` row-source orchestration and registry loading into narrower helpers without changing list output behavior. (#70867) Thanks @shakkernerd. - Models/commands: deprecate `/models add` so chat attempts now return a deprecation message instead of writing model configuration, and remove the add action from `/models` provider menus. - Codex harness/context-engine: run context-engine bootstrap, assembly, post-turn maintenance, and engine-owned compaction in Codex app-server sessions while keeping native Codex thread state and compaction auditable. (#70809) Thanks @jalehman. +- Codex runtime plan: consolidate contract-first Pi/Codex parity coverage and accept legacy Codex auth-provider aliases in app-server profile login and refresh paths. (#71096) Thanks @100yenadmin. - Plugins/Google Meet: add a bundled participant plugin with personal Google auth, explicit meeting URL joins, Chrome and Twilio transports, and realtime voice support. (#70765) Thanks @steipete. - Plugins/Google Meet: default Chrome realtime sessions to OpenAI plus SoX `rec`/`play` audio bridge commands, so the usual setup only needs the plugin enabled and `OPENAI_API_KEY`. - Plugins/Google Meet: add a `chrome-node` transport so a paired macOS node, such as a Parallels VM, can own Chrome, BlackHole, and SoX while the Gateway machine keeps the agent and model key. diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 0b9962c95ec..8bd817b5a35 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -3e0d36fbe1db58f01c297a35c9a26d1037471720a8e71dc7149d108bf0f9bf40 plugin-sdk-api-baseline.json -aa4065f3efaf8ed6f7641ad7384039123e5bbb21a3e682f7599ca75195ceb8cd plugin-sdk-api-baseline.jsonl +c4a62f081d0b9fcfd5e76a843547411bba0fdc129c1c143e7f4c4f6294b040b9 plugin-sdk-api-baseline.json +a62c9aea45d5694a851380ff6b35b7fb2ffd9fc4dfa3f0c567a8e1c97094475e plugin-sdk-api-baseline.jsonl diff --git a/extensions/codex/prompt-overlay-runtime-contract.test.ts b/extensions/codex/prompt-overlay-runtime-contract.test.ts new file mode 100644 index 00000000000..e35d64022e2 --- /dev/null +++ b/extensions/codex/prompt-overlay-runtime-contract.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { + codexPromptOverlayContext, + GPT5_CONTRACT_MODEL_ID, + NON_GPT5_CONTRACT_MODEL_ID, + sharedGpt5PersonalityConfig, +} from "../../test/helpers/agents/prompt-overlay-runtime-contract.js"; +import { buildCodexProvider } from "./provider.js"; + +describe("Codex prompt overlay runtime contract", () => { + it("adds the shared GPT-5 behavior contract to Codex GPT-5 provider runs", () => { + const provider = buildCodexProvider(); + const contribution = provider.resolveSystemPromptContribution?.( + codexPromptOverlayContext({ modelId: GPT5_CONTRACT_MODEL_ID }), + ); + + expect(contribution?.stablePrefix).toContain(""); + expect(contribution?.sectionOverrides?.interaction_style).toContain( + "This is a live chat, not a memo.", + ); + }); + + it("respects shared GPT-5 prompt overlay config for Codex runs", () => { + const provider = buildCodexProvider(); + const contribution = provider.resolveSystemPromptContribution?.( + codexPromptOverlayContext({ + modelId: GPT5_CONTRACT_MODEL_ID, + config: sharedGpt5PersonalityConfig("off"), + }), + ); + + expect(contribution?.stablePrefix).toContain(""); + expect(contribution?.sectionOverrides).toEqual({}); + }); + + it("does not add the shared GPT-5 overlay to non-GPT-5 Codex provider runs", () => { + const provider = buildCodexProvider(); + + expect( + provider.resolveSystemPromptContribution?.( + codexPromptOverlayContext({ modelId: NON_GPT5_CONTRACT_MODEL_ID }), + ), + ).toBeUndefined(); + }); +}); diff --git a/extensions/codex/src/app-server/auth-bridge.test.ts b/extensions/codex/src/app-server/auth-bridge.test.ts index a1051f444d6..9b44c60ba50 100644 --- a/extensions/codex/src/app-server/auth-bridge.test.ts +++ b/extensions/codex/src/app-server/auth-bridge.test.ts @@ -1,8 +1,55 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { bridgeCodexAppServerStartOptions } from "./auth-bridge.js"; +import { upsertAuthProfile } from "openclaw/plugin-sdk/provider-auth"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + applyCodexAppServerAuthProfile, + bridgeCodexAppServerStartOptions, + refreshCodexAppServerAuthTokens, +} from "./auth-bridge.js"; + +const oauthMocks = vi.hoisted(() => ({ + refreshOpenAICodexToken: vi.fn(), +})); + +const providerRuntimeMocks = vi.hoisted(() => ({ + formatProviderAuthProfileApiKeyWithPlugin: vi.fn(), + refreshProviderOAuthCredentialWithPlugin: vi.fn( + async (params: { context: { refresh: string } }) => { + const refreshed = await oauthMocks.refreshOpenAICodexToken(params.context.refresh); + return refreshed + ? { + ...params.context, + ...refreshed, + type: "oauth", + provider: "openai-codex", + } + : undefined; + }, + ), +})); + +vi.mock("@mariozechner/pi-ai/oauth", () => ({ + getOAuthApiKey: vi.fn(), + getOAuthProviders: () => [], + loginOpenAICodex: vi.fn(), + refreshOpenAICodexToken: oauthMocks.refreshOpenAICodexToken, +})); + +vi.mock("../../../../src/plugins/provider-runtime.runtime.js", () => ({ + formatProviderAuthProfileApiKeyWithPlugin: + providerRuntimeMocks.formatProviderAuthProfileApiKeyWithPlugin, + refreshProviderOAuthCredentialWithPlugin: + providerRuntimeMocks.refreshProviderOAuthCredentialWithPlugin, +})); + +afterEach(() => { + vi.unstubAllEnvs(); + oauthMocks.refreshOpenAICodexToken.mockReset(); + providerRuntimeMocks.formatProviderAuthProfileApiKeyWithPlugin.mockReset(); + providerRuntimeMocks.refreshProviderOAuthCredentialWithPlugin.mockClear(); +}); describe("bridgeCodexAppServerStartOptions", () => { it("leaves Codex app-server start options unchanged", async () => { @@ -30,4 +77,290 @@ describe("bridgeCodexAppServerStartOptions", () => { await fs.rm(agentDir, { recursive: true, force: true }); } }); + + it("applies an OpenAI Codex OAuth profile through app-server login", async () => { + const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-")); + const request = vi.fn(async () => ({ type: "chatgptAuthTokens" })); + try { + upsertAuthProfile({ + agentDir, + profileId: "openai-codex:work", + credential: { + type: "oauth", + provider: "openai-codex", + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 24 * 60 * 60_000, + accountId: "account-123", + email: "codex@example.test", + }, + }); + + await applyCodexAppServerAuthProfile({ + client: { request } as never, + agentDir, + authProfileId: "openai-codex:work", + }); + + expect(request).toHaveBeenCalledWith("account/login/start", { + type: "chatgptAuthTokens", + accessToken: "access-token", + chatgptAccountId: "account-123", + chatgptPlanType: null, + }); + } finally { + await fs.rm(agentDir, { recursive: true, force: true }); + } + }); + + it("refreshes an expired OpenAI Codex OAuth profile before app-server login", async () => { + const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-")); + const request = vi.fn(async () => ({ type: "chatgptAuthTokens" })); + oauthMocks.refreshOpenAICodexToken.mockResolvedValueOnce({ + access: "fresh-access-token", + refresh: "fresh-refresh-token", + expires: Date.now() + 60_000, + accountId: "account-456", + }); + try { + upsertAuthProfile({ + agentDir, + profileId: "openai-codex:work", + credential: { + type: "oauth", + provider: "openai-codex", + access: "expired-access-token", + refresh: "refresh-token", + expires: Date.now() - 60_000, + accountId: "account-123", + email: "codex@example.test", + }, + }); + + await applyCodexAppServerAuthProfile({ + client: { request } as never, + agentDir, + authProfileId: "openai-codex:work", + }); + + expect(oauthMocks.refreshOpenAICodexToken).toHaveBeenCalledWith("refresh-token"); + expect(request).toHaveBeenCalledWith("account/login/start", { + type: "chatgptAuthTokens", + accessToken: "fresh-access-token", + chatgptAccountId: "account-456", + chatgptPlanType: null, + }); + } finally { + await fs.rm(agentDir, { recursive: true, force: true }); + } + }); + + it("applies an OpenAI Codex api-key profile backed by a secret ref", async () => { + const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-")); + const request = vi.fn(async () => ({ type: "apiKey" })); + vi.stubEnv("OPENAI_CODEX_API_KEY", "ref-backed-api-key"); + try { + upsertAuthProfile({ + agentDir, + profileId: "openai-codex:work", + credential: { + type: "api_key", + provider: "openai-codex", + keyRef: { source: "env", provider: "default", id: "OPENAI_CODEX_API_KEY" }, + }, + }); + + await applyCodexAppServerAuthProfile({ + client: { request } as never, + agentDir, + authProfileId: "openai-codex:work", + }); + + expect(request).toHaveBeenCalledWith("account/login/start", { + type: "apiKey", + apiKey: "ref-backed-api-key", + }); + } finally { + await fs.rm(agentDir, { recursive: true, force: true }); + } + }); + + it("applies an OpenAI Codex token profile backed by a secret ref", async () => { + const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-")); + const request = vi.fn(async () => ({ type: "chatgptAuthTokens" })); + vi.stubEnv("OPENAI_CODEX_TOKEN", "ref-backed-access-token"); + try { + upsertAuthProfile({ + agentDir, + profileId: "openai-codex:work", + credential: { + type: "token", + provider: "openai-codex", + tokenRef: { source: "env", provider: "default", id: "OPENAI_CODEX_TOKEN" }, + email: "codex@example.test", + }, + }); + + await applyCodexAppServerAuthProfile({ + client: { request } as never, + agentDir, + authProfileId: "openai-codex:work", + }); + + expect(request).toHaveBeenCalledWith("account/login/start", { + type: "chatgptAuthTokens", + accessToken: "ref-backed-access-token", + chatgptAccountId: "codex@example.test", + chatgptPlanType: null, + }); + } finally { + await fs.rm(agentDir, { recursive: true, force: true }); + } + }); + + it("accepts a legacy Codex auth-provider alias for app-server login", async () => { + const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-")); + const request = vi.fn(async () => ({ type: "chatgptAuthTokens" })); + try { + upsertAuthProfile({ + agentDir, + profileId: "openai-codex:work", + credential: { + type: "token", + provider: "codex-cli", + token: "legacy-access-token", + email: "legacy-codex@example.test", + }, + }); + + await applyCodexAppServerAuthProfile({ + client: { request } as never, + agentDir, + authProfileId: "openai-codex:work", + }); + + expect(request).toHaveBeenCalledWith("account/login/start", { + type: "chatgptAuthTokens", + accessToken: "legacy-access-token", + chatgptAccountId: "legacy-codex@example.test", + chatgptPlanType: null, + }); + } finally { + await fs.rm(agentDir, { recursive: true, force: true }); + } + }); + + it("answers app-server ChatGPT token refresh requests from the bound profile", async () => { + const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-")); + oauthMocks.refreshOpenAICodexToken.mockResolvedValueOnce({ + access: "refreshed-access-token", + refresh: "refreshed-refresh-token", + expires: Date.now() + 60_000, + accountId: "account-789", + }); + try { + upsertAuthProfile({ + agentDir, + profileId: "openai-codex:work", + credential: { + type: "oauth", + provider: "openai-codex", + access: "stale-access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + accountId: "account-123", + email: "codex@example.test", + }, + }); + + await expect( + refreshCodexAppServerAuthTokens({ + agentDir, + authProfileId: "openai-codex:work", + }), + ).resolves.toEqual({ + accessToken: "refreshed-access-token", + chatgptAccountId: "account-789", + chatgptPlanType: null, + }); + expect(oauthMocks.refreshOpenAICodexToken).toHaveBeenCalledWith("refresh-token"); + } finally { + await fs.rm(agentDir, { recursive: true, force: true }); + } + }); + + it("accepts a refreshed Codex OAuth credential when the stored provider is a legacy alias", async () => { + const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-")); + oauthMocks.refreshOpenAICodexToken.mockResolvedValueOnce({ + access: "refreshed-alias-access-token", + refresh: "refreshed-alias-refresh-token", + expires: Date.now() + 60_000, + accountId: "account-alias", + }); + try { + upsertAuthProfile({ + agentDir, + profileId: "openai-codex:work", + credential: { + type: "oauth", + provider: "codex-cli", + access: "stale-alias-access-token", + refresh: "alias-refresh-token", + expires: Date.now() + 60_000, + accountId: "account-legacy", + email: "legacy-codex@example.test", + }, + }); + + await expect( + refreshCodexAppServerAuthTokens({ + agentDir, + authProfileId: "openai-codex:work", + }), + ).resolves.toEqual({ + accessToken: "refreshed-alias-access-token", + chatgptAccountId: "account-alias", + chatgptPlanType: null, + }); + expect(oauthMocks.refreshOpenAICodexToken).toHaveBeenCalledWith("alias-refresh-token"); + } finally { + await fs.rm(agentDir, { recursive: true, force: true }); + } + }); + + it("preserves a stored ChatGPT plan type when building token login params", async () => { + const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-")); + const request = vi.fn(async () => ({ type: "chatgptAuthTokens" })); + try { + upsertAuthProfile({ + agentDir, + profileId: "openai-codex:work", + credential: { + type: "oauth", + provider: "openai-codex", + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 24 * 60 * 60_000, + accountId: "account-123", + email: "codex@example.test", + chatgptPlanType: "pro", + } as never, + }); + + await applyCodexAppServerAuthProfile({ + client: { request } as never, + agentDir, + authProfileId: "openai-codex:work", + }); + + expect(request).toHaveBeenCalledWith("account/login/start", { + type: "chatgptAuthTokens", + accessToken: "access-token", + chatgptAccountId: "account-123", + chatgptPlanType: "pro", + }); + } finally { + await fs.rm(agentDir, { recursive: true, force: true }); + } + }); }); diff --git a/extensions/codex/src/app-server/auth-bridge.ts b/extensions/codex/src/app-server/auth-bridge.ts index 054a8b53c02..241fb3b2684 100644 --- a/extensions/codex/src/app-server/auth-bridge.ts +++ b/extensions/codex/src/app-server/auth-bridge.ts @@ -1,4 +1,18 @@ +import { + ensureAuthProfileStore, + loadAuthProfileStoreForSecretsRuntime, + resolveProviderIdForAuth, + resolveApiKeyForProfile, + saveAuthProfileStore, + type AuthProfileCredential, + type OAuthCredential, +} from "openclaw/plugin-sdk/agent-runtime"; +import type { CodexAppServerClient } from "./client.js"; import type { CodexAppServerStartOptions } from "./config.js"; +import type { ChatgptAuthTokensRefreshResponse } from "./protocol-generated/typescript/v2/ChatgptAuthTokensRefreshResponse.js"; +import type { LoginAccountParams } from "./protocol-generated/typescript/v2/LoginAccountParams.js"; + +const CODEX_APP_SERVER_AUTH_PROVIDER = "openai-codex"; export async function bridgeCodexAppServerStartOptions(params: { startOptions: CodexAppServerStartOptions; @@ -9,3 +23,170 @@ export async function bridgeCodexAppServerStartOptions(params: { void params.authProfileId; return params.startOptions; } + +export async function applyCodexAppServerAuthProfile(params: { + client: CodexAppServerClient; + agentDir: string; + authProfileId?: string; +}): Promise { + const loginParams = await resolveCodexAppServerAuthProfileLoginParams({ + agentDir: params.agentDir, + authProfileId: params.authProfileId, + }); + if (!loginParams) { + return; + } + await params.client.request("account/login/start", loginParams); +} + +export function resolveCodexAppServerAuthProfileLoginParams(params: { + agentDir: string; + authProfileId?: string; +}): Promise { + return resolveCodexAppServerAuthProfileLoginParamsInternal(params); +} + +export async function refreshCodexAppServerAuthTokens(params: { + agentDir: string; + authProfileId?: string; +}): Promise { + const loginParams = await resolveCodexAppServerAuthProfileLoginParamsInternal({ + ...params, + forceOAuthRefresh: true, + }); + if (!loginParams || loginParams.type !== "chatgptAuthTokens") { + throw new Error("Codex app-server ChatGPT token refresh requires an OAuth auth profile."); + } + return { + accessToken: loginParams.accessToken, + chatgptAccountId: loginParams.chatgptAccountId, + chatgptPlanType: loginParams.chatgptPlanType ?? null, + }; +} + +async function resolveCodexAppServerAuthProfileLoginParamsInternal(params: { + agentDir: string; + authProfileId?: string; + forceOAuthRefresh?: boolean; +}): Promise { + const profileId = params.authProfileId?.trim(); + if (!profileId) { + return undefined; + } + const store = ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false }); + const credential = store.profiles[profileId]; + if (!credential) { + throw new Error(`Codex app-server auth profile "${profileId}" was not found.`); + } + if (!isCodexAppServerAuthProvider(credential.provider)) { + throw new Error( + `Codex app-server auth profile "${profileId}" must belong to provider "openai-codex" or a supported alias.`, + ); + } + const loginParams = await resolveLoginParamsForCredential(profileId, credential, { + agentDir: params.agentDir, + forceOAuthRefresh: params.forceOAuthRefresh === true, + }); + if (!loginParams) { + throw new Error( + `Codex app-server auth profile "${profileId}" does not contain usable credentials.`, + ); + } + return loginParams; +} + +async function resolveLoginParamsForCredential( + profileId: string, + credential: AuthProfileCredential, + params: { agentDir: string; forceOAuthRefresh: boolean }, +): Promise { + if (credential.type === "api_key") { + const resolved = await resolveApiKeyForProfile({ + store: ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false }), + profileId, + agentDir: params.agentDir, + }); + const apiKey = resolved?.apiKey?.trim(); + return apiKey ? { type: "apiKey", apiKey } : undefined; + } + if (credential.type === "token") { + const resolved = await resolveApiKeyForProfile({ + store: ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false }), + profileId, + agentDir: params.agentDir, + }); + const accessToken = resolved?.apiKey?.trim(); + return accessToken + ? buildChatgptAuthTokensParams(profileId, credential, accessToken) + : undefined; + } + const resolvedCredential = await resolveOAuthCredentialForCodexAppServer(profileId, credential, { + agentDir: params.agentDir, + forceRefresh: params.forceOAuthRefresh, + }); + const accessToken = resolvedCredential.access?.trim(); + return accessToken + ? buildChatgptAuthTokensParams(profileId, resolvedCredential, accessToken) + : undefined; +} + +async function resolveOAuthCredentialForCodexAppServer( + profileId: string, + credential: OAuthCredential, + params: { agentDir: string; forceRefresh: boolean }, +): Promise { + const store = ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false }); + if (params.forceRefresh) { + store.profiles[profileId] = { ...credential, expires: 0 }; + saveAuthProfileStore(store, params.agentDir); + } + const resolved = await resolveApiKeyForProfile({ + store, + profileId, + agentDir: params.agentDir, + }); + const refreshed = loadAuthProfileStoreForSecretsRuntime(params.agentDir).profiles[profileId]; + const storedCredential = store.profiles[profileId]; + const candidate = + refreshed?.type === "oauth" && isCodexAppServerAuthProvider(refreshed.provider) + ? refreshed + : storedCredential?.type === "oauth" && + isCodexAppServerAuthProvider(storedCredential.provider) + ? storedCredential + : credential; + return resolved?.apiKey ? { ...candidate, access: resolved.apiKey } : candidate; +} + +function isCodexAppServerAuthProvider(provider: string): boolean { + return resolveProviderIdForAuth(provider) === CODEX_APP_SERVER_AUTH_PROVIDER; +} + +function buildChatgptAuthTokensParams( + profileId: string, + credential: AuthProfileCredential, + accessToken: string, +): LoginAccountParams { + return { + type: "chatgptAuthTokens", + accessToken, + chatgptAccountId: resolveChatgptAccountId(profileId, credential), + chatgptPlanType: resolveChatgptPlanType(credential), + }; +} + +function resolveChatgptPlanType(credential: AuthProfileCredential): string | null { + const record = credential as Record; + const planType = record.chatgptPlanType ?? record.planType; + return typeof planType === "string" && planType.trim() ? planType.trim() : null; +} + +function resolveChatgptAccountId(profileId: string, credential: AuthProfileCredential): string { + if ("accountId" in credential && typeof credential.accountId === "string") { + const accountId = credential.accountId.trim(); + if (accountId) { + return accountId; + } + } + const email = credential.email?.trim(); + return email || profileId; +} diff --git a/extensions/codex/src/app-server/auth-profile-runtime-contract.test.ts b/extensions/codex/src/app-server/auth-profile-runtime-contract.test.ts new file mode 100644 index 00000000000..df8fa307ca4 --- /dev/null +++ b/extensions/codex/src/app-server/auth-profile-runtime-contract.test.ts @@ -0,0 +1,210 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { + abortAgentHarnessRun, + type EmbeddedRunAttemptParams, +} from "openclaw/plugin-sdk/agent-harness"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { AUTH_PROFILE_RUNTIME_CONTRACT } from "../../../../test/helpers/agents/auth-profile-runtime-contract.js"; +import { runCodexAppServerAttempt, __testing } from "./run-attempt.js"; +import { readCodexAppServerBinding, writeCodexAppServerBinding } from "./session-binding.js"; +import { createCodexTestModel } from "./test-support.js"; + +function createParams(sessionFile: string, workspaceDir: string): EmbeddedRunAttemptParams { + return { + prompt: AUTH_PROFILE_RUNTIME_CONTRACT.workspacePrompt, + sessionId: AUTH_PROFILE_RUNTIME_CONTRACT.sessionId, + sessionKey: AUTH_PROFILE_RUNTIME_CONTRACT.sessionKey, + sessionFile, + workspaceDir, + runId: AUTH_PROFILE_RUNTIME_CONTRACT.runId, + provider: AUTH_PROFILE_RUNTIME_CONTRACT.codexHarnessProvider, + modelId: "gpt-5.4-codex", + model: createCodexTestModel(AUTH_PROFILE_RUNTIME_CONTRACT.codexHarnessProvider), + thinkLevel: "medium", + disableTools: true, + timeoutMs: 5_000, + authStorage: {} as never, + modelRegistry: {} as never, + } as EmbeddedRunAttemptParams; +} + +function threadStartResult(threadId = "thread-auth-contract") { + return { + thread: { + id: threadId, + forkedFromId: null, + preview: "", + ephemeral: false, + modelProvider: "openai", + createdAt: 1, + updatedAt: 1, + status: { type: "idle" }, + path: null, + cwd: "", + cliVersion: "0.118.0", + source: "unknown", + agentNickname: null, + agentRole: null, + gitInfo: null, + name: null, + turns: [], + }, + model: "gpt-5.4-codex", + modelProvider: "openai", + serviceTier: null, + cwd: "", + instructionSources: [], + approvalPolicy: "never", + approvalsReviewer: "user", + sandbox: { type: "dangerFullAccess" }, + permissionProfile: null, + reasoningEffort: null, + }; +} + +function turnStartResult(turnId = "turn-auth-contract") { + return { + turn: { + id: turnId, + status: "inProgress", + items: [], + error: null, + startedAt: null, + completedAt: null, + durationMs: null, + }, + }; +} + +function createCodexAuthProfileHarness(params: { startMethod: "thread/start" | "thread/resume" }) { + const seenAuthProfileIds: Array = []; + const requests: Array<{ method: string; params: unknown }> = []; + let notify: (notification: unknown) => Promise = async () => undefined; + __testing.setCodexAppServerClientFactoryForTests(async (_startOptions, authProfileId) => { + seenAuthProfileIds.push(authProfileId); + return { + request: vi.fn(async (method: string, requestParams?: unknown) => { + requests.push({ method, params: requestParams }); + if (method === params.startMethod) { + return threadStartResult(); + } + if (method === "turn/start") { + return turnStartResult(); + } + throw new Error(`unexpected method: ${method}`); + }), + addNotificationHandler: (handler: (notification: unknown) => Promise) => { + notify = handler; + return () => undefined; + }, + addRequestHandler: () => () => undefined, + } as never; + }); + return { + seenAuthProfileIds, + async waitForMethod(method: string) { + await vi.waitFor(() => expect(requests.some((entry) => entry.method === method)).toBe(true), { + interval: 1, + }); + }, + async completeTurn() { + await notify({ + method: "turn/completed", + params: { + threadId: "thread-auth-contract", + turnId: "turn-auth-contract", + turn: { id: "turn-auth-contract", status: "completed" }, + }, + }); + }, + }; +} + +describe("Auth profile runtime contract - Codex app-server adapter", () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-auth-contract-")); + }); + + afterEach(async () => { + abortAgentHarnessRun(AUTH_PROFILE_RUNTIME_CONTRACT.sessionId); + __testing.resetCodexAppServerClientFactoryForTests(); + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it("passes the exact OpenAI Codex auth profile into app-server startup", async () => { + const harness = createCodexAuthProfileHarness({ startMethod: "thread/start" }); + const sessionFile = path.join(tmpDir, "session.jsonl"); + const params = createParams(sessionFile, tmpDir); + params.authProfileId = AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId; + + const run = runCodexAppServerAttempt(params); + await vi.waitFor( + () => + expect(harness.seenAuthProfileIds).toEqual([ + AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId, + ]), + { interval: 1 }, + ); + await harness.waitForMethod("turn/start"); + await harness.completeTurn(); + await run; + }); + + it("reuses a bound OpenAI Codex auth profile when resume params omit authProfileId", async () => { + const harness = createCodexAuthProfileHarness({ startMethod: "thread/resume" }); + const sessionFile = path.join(tmpDir, "session.jsonl"); + await writeCodexAppServerBinding(sessionFile, { + threadId: "thread-auth-contract", + cwd: tmpDir, + authProfileId: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId, + dynamicToolsFingerprint: "[]", + }); + // authProfileId is intentionally omitted to exercise the resume-bound profile path. + const params = createParams(sessionFile, tmpDir); + + const run = runCodexAppServerAttempt(params); + await vi.waitFor( + () => + expect(harness.seenAuthProfileIds).toEqual([ + AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId, + ]), + { interval: 1 }, + ); + await harness.waitForMethod("turn/start"); + await harness.completeTurn(); + await run; + }); + + it("prefers an explicit runtime auth profile over a stale persisted binding", async () => { + const harness = createCodexAuthProfileHarness({ startMethod: "thread/resume" }); + const sessionFile = path.join(tmpDir, "session.jsonl"); + await writeCodexAppServerBinding(sessionFile, { + threadId: "thread-auth-contract", + cwd: tmpDir, + authProfileId: "openai-codex:stale", + dynamicToolsFingerprint: "[]", + }); + const params = createParams(sessionFile, tmpDir); + params.authProfileId = AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId; + + const run = runCodexAppServerAttempt(params); + await vi.waitFor( + () => + expect(harness.seenAuthProfileIds).toEqual([ + AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId, + ]), + { interval: 1 }, + ); + await harness.waitForMethod("turn/start"); + await harness.completeTurn(); + await run; + + await expect(readCodexAppServerBinding(sessionFile)).resolves.toMatchObject({ + authProfileId: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId, + }); + }); +}); diff --git a/extensions/codex/src/app-server/config.ts b/extensions/codex/src/app-server/config.ts index 00a31b8e81a..def3f666ffd 100644 --- a/extensions/codex/src/app-server/config.ts +++ b/extensions/codex/src/app-server/config.ts @@ -167,7 +167,10 @@ export function resolveCodexAppServerRuntimeOptions( }; } -export function codexAppServerStartOptionsKey(options: CodexAppServerStartOptions): string { +export function codexAppServerStartOptionsKey( + options: CodexAppServerStartOptions, + params: { authProfileId?: string } = {}, +): string { return JSON.stringify({ transport: options.transport, command: options.command, @@ -179,6 +182,7 @@ export function codexAppServerStartOptionsKey(options: CodexAppServerStartOption ), env: Object.entries(options.env ?? {}).toSorted(([left], [right]) => left.localeCompare(right)), clearEnv: [...(options.clearEnv ?? [])].toSorted(), + authProfileId: params.authProfileId ?? null, }); } diff --git a/extensions/codex/src/app-server/delivery-no-reply-runtime-contract.test.ts b/extensions/codex/src/app-server/delivery-no-reply-runtime-contract.test.ts new file mode 100644 index 00000000000..f8ef9fad911 --- /dev/null +++ b/extensions/codex/src/app-server/delivery-no-reply-runtime-contract.test.ts @@ -0,0 +1,80 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { SessionManager } from "@mariozechner/pi-coding-agent"; +import type { EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness"; +import { afterEach, describe, expect, it } from "vitest"; +import { isSilentReplyPayloadText } from "../../../../src/auto-reply/tokens.js"; +import { DELIVERY_NO_REPLY_RUNTIME_CONTRACT } from "../../../../test/helpers/agents/delivery-no-reply-runtime-contract.js"; +import { CodexAppServerEventProjector } from "./event-projector.js"; +import { createCodexTestModel } from "./test-support.js"; + +const THREAD_ID = "thread-delivery-contract"; +const TURN_ID = "turn-delivery-contract"; +const tempDirs = new Set(); + +type ProjectorNotification = Parameters[0]; + +async function createParams(): Promise { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-delivery-contract-")); + tempDirs.add(tempDir); + const sessionFile = path.join(tempDir, "session.jsonl"); + SessionManager.open(sessionFile); + return { + prompt: DELIVERY_NO_REPLY_RUNTIME_CONTRACT.prompt, + sessionId: DELIVERY_NO_REPLY_RUNTIME_CONTRACT.sessionId, + sessionKey: DELIVERY_NO_REPLY_RUNTIME_CONTRACT.sessionKey, + sessionFile, + workspaceDir: tempDir, + runId: DELIVERY_NO_REPLY_RUNTIME_CONTRACT.runId, + provider: "codex", + modelId: "gpt-5.4-codex", + model: createCodexTestModel("codex"), + thinkLevel: "medium", + } as EmbeddedRunAttemptParams; +} + +function forCurrentTurn( + method: ProjectorNotification["method"], + params: Record, +): ProjectorNotification { + return { + method, + params: { threadId: THREAD_ID, turnId: TURN_ID, ...params }, + } as ProjectorNotification; +} + +afterEach(async () => { + for (const tempDir of tempDirs) { + await fs.rm(tempDir, { recursive: true, force: true }); + } + tempDirs.clear(); +}); + +describe("Delivery/NO_REPLY runtime contract - Codex app-server adapter", () => { + it.each([ + DELIVERY_NO_REPLY_RUNTIME_CONTRACT.silentText, + ` ${DELIVERY_NO_REPLY_RUNTIME_CONTRACT.silentText} `, + DELIVERY_NO_REPLY_RUNTIME_CONTRACT.jsonSilentText, + ])("preserves silent terminal text %s for shared delivery suppression", async (text) => { + const projector = new CodexAppServerEventProjector(await createParams(), THREAD_ID, TURN_ID); + await projector.handleNotification( + forCurrentTurn("item/agentMessage/delta", { + itemId: "msg-1", + delta: text, + }), + ); + + const result = projector.buildResult({ + didSendViaMessagingTool: false, + messagingToolSentTexts: [], + messagingToolSentMediaUrls: [], + messagingToolSentTargets: [], + toolMediaUrls: [], + toolAudioAsVoice: false, + }); + + expect(result.assistantTexts).toEqual([text.trim()]); + expect(isSilentReplyPayloadText(result.assistantTexts[0])).toBe(true); + }); +}); diff --git a/extensions/codex/src/app-server/event-projector.ts b/extensions/codex/src/app-server/event-projector.ts index 6dac9bb4682..8fee9b1932b 100644 --- a/extensions/codex/src/app-server/event-projector.ts +++ b/extensions/codex/src/app-server/event-projector.ts @@ -34,6 +34,10 @@ export type CodexAppServerToolTelemetry = { successfulCronAdds?: number; }; +type AgentHarnessResultClassification = NonNullable< + EmbeddedRunAttemptResult["agentHarnessResultClassification"] +>; + const ZERO_USAGE: Usage = { input: 0, output: 0, @@ -60,6 +64,25 @@ const CURRENT_TOKEN_USAGE_KEYS = [ const MAX_TOOL_OUTPUT_DELTA_MESSAGES_PER_ITEM = 20; +function classifyTerminalResult(params: { + assistantTexts: string[]; + reasoningText: string; + planText: string; + promptError: unknown; + turnCompleted: boolean; +}): AgentHarnessResultClassification | undefined { + if (!params.turnCompleted || params.promptError || params.assistantTexts.length > 0) { + return undefined; + } + if (params.planText.trim()) { + return "planning-only"; + } + if (params.reasoningText.trim()) { + return "reasoning-only"; + } + return "empty"; +} + export class CodexAppServerEventProjector { private readonly assistantTextByItem = new Map(); private readonly assistantItemOrder: string[] = []; @@ -192,6 +215,13 @@ export class CodexAppServerEventProjector { const promptError = this.promptError ?? (turnFailed ? (this.completedTurn?.error?.message ?? "codex app-server turn failed") : null); + const agentHarnessResultClassification = classifyTerminalResult({ + assistantTexts, + reasoningText, + planText, + promptError, + turnCompleted: Boolean(this.completedTurn), + }); return { aborted: this.aborted || turnInterrupted, externalAbort: false, @@ -201,6 +231,7 @@ export class CodexAppServerEventProjector { promptError, promptErrorSource: promptError ? this.promptErrorSource || "prompt" : null, sessionIdUsed: this.params.sessionId, + ...(agentHarnessResultClassification ? { agentHarnessResultClassification } : {}), bootstrapPromptWarningSignaturesSeen: this.params.bootstrapPromptWarningSignaturesSeen, bootstrapPromptWarningSignature: this.params.bootstrapPromptWarningSignature, messagesSnapshot, diff --git a/extensions/codex/src/app-server/models.test.ts b/extensions/codex/src/app-server/models.test.ts index a9a18127b15..a1144f7508c 100644 --- a/extensions/codex/src/app-server/models.test.ts +++ b/extensions/codex/src/app-server/models.test.ts @@ -4,6 +4,7 @@ import { createClientHarness } from "./test-support.js"; const mocks = vi.hoisted(() => { const authBridge = { + applyAuthProfile: vi.fn(async () => undefined), startOptions: vi.fn(async ({ startOptions }) => startOptions), }; const providerAuth = { @@ -13,6 +14,7 @@ const mocks = vi.hoisted(() => { }); vi.mock("./auth-bridge.js", () => ({ + applyCodexAppServerAuthProfile: mocks.authBridge.applyAuthProfile, bridgeCodexAppServerStartOptions: mocks.authBridge.startOptions, })); @@ -34,6 +36,7 @@ describe("listCodexAppServerModels", () => { afterEach(() => { resetSharedCodexAppServerClientForTests(); vi.restoreAllMocks(); + mocks.authBridge.applyAuthProfile.mockClear(); mocks.authBridge.startOptions.mockClear(); mocks.providerAuth.agentDir.mockClear(); }); diff --git a/extensions/codex/src/app-server/openclaw-owned-tool-runtime-contract.test.ts b/extensions/codex/src/app-server/openclaw-owned-tool-runtime-contract.test.ts new file mode 100644 index 00000000000..d2d56300a77 --- /dev/null +++ b/extensions/codex/src/app-server/openclaw-owned-tool-runtime-contract.test.ts @@ -0,0 +1,408 @@ +import type { AnyAgentTool } from "openclaw/plugin-sdk/agent-harness"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { wrapToolWithBeforeToolCallHook } from "../../../../src/agents/pi-tools.before-tool-call.js"; +import { + installCodexToolResultMiddleware, + installOpenClawOwnedToolHooks, + mediaToolResult, + resetOpenClawOwnedToolHooks, + textToolResult, +} from "../../../../test/helpers/agents/openclaw-owned-tool-runtime-contract.js"; +import { createCodexDynamicToolBridge } from "./dynamic-tools.js"; + +function createContractTool(overrides: Partial): AnyAgentTool { + return { + name: "exec", + description: "Run a command.", + parameters: { type: "object", properties: {} }, + execute: vi.fn(), + ...overrides, + } as unknown as AnyAgentTool; +} + +describe("OpenClaw-owned tool runtime contract — Codex app-server adapter", () => { + afterEach(() => { + resetOpenClawOwnedToolHooks(); + }); + + it("wraps unwrapped dynamic tools with before/after tool hooks", async () => { + const adjustedParams = { mode: "safe" }; + const mergedParams = { command: "pwd", mode: "safe" }; + const hooks = installOpenClawOwnedToolHooks({ adjustedParams }); + const execute = vi.fn(async () => textToolResult("done", { ok: true })); + const bridge = createCodexDynamicToolBridge({ + tools: [createContractTool({ name: "exec", execute })], + signal: new AbortController().signal, + hookContext: { + agentId: "agent-1", + sessionId: "session-1", + sessionKey: "agent:agent-1:session-1", + runId: "run-contract", + }, + }); + + const result = await bridge.handleToolCall({ + threadId: "thread-1", + turnId: "turn-1", + callId: "call-contract", + namespace: null, + tool: "exec", + arguments: { command: "pwd" }, + }); + + expect(result).toEqual({ + success: true, + contentItems: [{ type: "inputText", text: "done" }], + }); + expect(hooks.beforeToolCall).toHaveBeenCalledWith( + expect.objectContaining({ + toolName: "exec", + toolCallId: "call-contract", + runId: "run-contract", + params: { command: "pwd" }, + }), + expect.objectContaining({ + agentId: "agent-1", + sessionId: "session-1", + sessionKey: "agent:agent-1:session-1", + runId: "run-contract", + toolCallId: "call-contract", + }), + ); + expect(execute).toHaveBeenCalledWith( + "call-contract", + mergedParams, + expect.any(AbortSignal), + undefined, + ); + await vi.waitFor(() => { + expect(hooks.afterToolCall).toHaveBeenCalledWith( + expect.objectContaining({ + toolName: "exec", + toolCallId: "call-contract", + params: mergedParams, + result: expect.objectContaining({ + content: [{ type: "text", text: "done" }], + details: { ok: true }, + }), + }), + expect.objectContaining({ + agentId: "agent-1", + sessionId: "session-1", + sessionKey: "agent:agent-1:session-1", + runId: "run-contract", + toolCallId: "call-contract", + }), + ); + }); + }); + + it("runs tool_result middleware before after_tool_call observes the result", async () => { + const adjustedParams = { mode: "safe" }; + const mergedParams = { command: "status", mode: "safe" }; + const hooks = installOpenClawOwnedToolHooks({ adjustedParams }); + const middleware = installCodexToolResultMiddleware((event) => { + expect(event).toMatchObject({ + toolName: "exec", + toolCallId: "call-middleware", + args: { command: "status" }, + result: { + content: [{ type: "text", text: "raw output" }], + details: { stage: "execute" }, + }, + }); + return textToolResult("compacted output", { stage: "middleware" }); + }); + const execute = vi.fn(async () => textToolResult("raw output", { stage: "execute" })); + const bridge = createCodexDynamicToolBridge({ + tools: [createContractTool({ name: "exec", execute })], + signal: new AbortController().signal, + hookContext: { + agentId: "agent-1", + sessionId: "session-1", + sessionKey: "agent:agent-1:session-1", + runId: "run-middleware", + }, + }); + + const result = await bridge.handleToolCall({ + threadId: "thread-1", + turnId: "turn-1", + callId: "call-middleware", + namespace: null, + tool: "exec", + arguments: { command: "status" }, + }); + + expect(result).toEqual({ + success: true, + contentItems: [{ type: "inputText", text: "compacted output" }], + }); + expect(execute).toHaveBeenCalledWith( + "call-middleware", + mergedParams, + expect.any(AbortSignal), + undefined, + ); + expect(middleware.middleware).toHaveBeenCalledTimes(1); + await vi.waitFor(() => { + expect(hooks.afterToolCall).toHaveBeenCalledWith( + expect.objectContaining({ + toolName: "exec", + toolCallId: "call-middleware", + params: mergedParams, + result: expect.objectContaining({ + content: [{ type: "text", text: "compacted output" }], + details: { stage: "middleware" }, + }), + }), + expect.objectContaining({ + runId: "run-middleware", + toolCallId: "call-middleware", + }), + ); + }); + }); + + it("fails closed when before_tool_call blocks a dynamic tool", async () => { + const hooks = installOpenClawOwnedToolHooks({ blockReason: "blocked by policy" }); + const execute = vi.fn(async () => textToolResult("should not run")); + const bridge = createCodexDynamicToolBridge({ + tools: [createContractTool({ name: "message", execute })], + signal: new AbortController().signal, + hookContext: { runId: "run-blocked" }, + }); + + const result = await bridge.handleToolCall({ + threadId: "thread-1", + turnId: "turn-1", + callId: "call-blocked", + namespace: null, + tool: "message", + arguments: { + action: "send", + text: "blocked", + provider: "telegram", + to: "chat-1", + }, + }); + + expect(result).toEqual({ + success: false, + contentItems: [{ type: "inputText", text: "blocked by policy" }], + }); + expect(execute).not.toHaveBeenCalled(); + expect(bridge.telemetry.didSendViaMessagingTool).toBe(false); + await vi.waitFor(() => { + expect(hooks.afterToolCall).toHaveBeenCalledWith( + expect.objectContaining({ + toolName: "message", + toolCallId: "call-blocked", + params: { + action: "send", + text: "blocked", + provider: "telegram", + to: "chat-1", + }, + error: "blocked by policy", + }), + expect.objectContaining({ + runId: "run-blocked", + toolCallId: "call-blocked", + }), + ); + }); + }); + + it("reports dynamic tool execution errors through after_tool_call", async () => { + const adjustedParams = { timeoutSec: 1 }; + const mergedParams = { command: "false", timeoutSec: 1 }; + const hooks = installOpenClawOwnedToolHooks({ adjustedParams }); + const execute = vi.fn(async () => { + throw new Error("tool failed"); + }); + const bridge = createCodexDynamicToolBridge({ + tools: [createContractTool({ name: "exec", execute })], + signal: new AbortController().signal, + hookContext: { runId: "run-error" }, + }); + + const result = await bridge.handleToolCall({ + threadId: "thread-1", + turnId: "turn-1", + callId: "call-error", + namespace: null, + tool: "exec", + arguments: { command: "false" }, + }); + + expect(result).toEqual({ + success: false, + contentItems: [{ type: "inputText", text: "tool failed" }], + }); + expect(execute).toHaveBeenCalledWith( + "call-error", + mergedParams, + expect.any(AbortSignal), + undefined, + ); + await vi.waitFor(() => { + expect(hooks.afterToolCall).toHaveBeenCalledWith( + expect.objectContaining({ + toolName: "exec", + toolCallId: "call-error", + params: mergedParams, + error: "tool failed", + }), + expect.objectContaining({ + runId: "run-error", + toolCallId: "call-error", + }), + ); + }); + }); + + it("records successful Codex messaging text, media, and target telemetry", async () => { + const hooks = installOpenClawOwnedToolHooks(); + const execute = vi.fn(async () => textToolResult("Sent.")); + const bridge = createCodexDynamicToolBridge({ + tools: [createContractTool({ name: "message", execute })], + signal: new AbortController().signal, + hookContext: { runId: "run-message" }, + }); + + const result = await bridge.handleToolCall({ + threadId: "thread-1", + turnId: "turn-1", + callId: "call-message", + namespace: null, + tool: "message", + arguments: { + action: "send", + text: "hello from Codex", + mediaUrl: "/tmp/codex-reply.png", + provider: "telegram", + to: "chat-1", + threadId: "thread-ts-1", + }, + }); + + expect(result).toEqual({ + success: true, + contentItems: [{ type: "inputText", text: "Sent." }], + }); + expect(bridge.telemetry).toMatchObject({ + didSendViaMessagingTool: true, + messagingToolSentTexts: ["hello from Codex"], + messagingToolSentMediaUrls: ["/tmp/codex-reply.png"], + messagingToolSentTargets: [ + { + tool: "message", + provider: "telegram", + to: "chat-1", + threadId: "thread-ts-1", + }, + ], + }); + await vi.waitFor(() => { + expect(hooks.afterToolCall).toHaveBeenCalledWith( + expect.objectContaining({ + toolName: "message", + toolCallId: "call-message", + params: expect.objectContaining({ + text: "hello from Codex", + mediaUrl: "/tmp/codex-reply.png", + }), + }), + expect.objectContaining({ + runId: "run-message", + toolCallId: "call-message", + }), + ); + }); + }); + + it("records successful Codex media artifacts from tool results", async () => { + const hooks = installOpenClawOwnedToolHooks(); + const execute = vi.fn(async () => + mediaToolResult("Generated media reply.", "/tmp/reply.opus", true), + ); + const bridge = createCodexDynamicToolBridge({ + tools: [createContractTool({ name: "tts", execute })], + signal: new AbortController().signal, + hookContext: { runId: "run-media" }, + }); + + const result = await bridge.handleToolCall({ + threadId: "thread-1", + turnId: "turn-1", + callId: "call-media", + namespace: null, + tool: "tts", + arguments: { text: "hello" }, + }); + + expect(result).toEqual({ + success: true, + contentItems: [{ type: "inputText", text: "Generated media reply." }], + }); + expect(bridge.telemetry.toolMediaUrls).toEqual(["/tmp/reply.opus"]); + expect(bridge.telemetry.toolAudioAsVoice).toBe(true); + await vi.waitFor(() => { + expect(hooks.afterToolCall).toHaveBeenCalledWith( + expect.objectContaining({ + toolName: "tts", + toolCallId: "call-media", + result: expect.objectContaining({ + details: { + media: { + mediaUrl: "/tmp/reply.opus", + audioAsVoice: true, + }, + }, + }), + }), + expect.objectContaining({ + runId: "run-media", + toolCallId: "call-media", + }), + ); + }); + }); + + it("does not double-wrap dynamic tools that already have before_tool_call", async () => { + const adjustedParams = { mode: "safe" }; + const mergedParams = { command: "pwd", mode: "safe" }; + const hooks = installOpenClawOwnedToolHooks({ adjustedParams }); + const execute = vi.fn(async () => textToolResult("done")); + const tool = wrapToolWithBeforeToolCallHook(createContractTool({ name: "exec", execute }), { + runId: "run-wrapped", + }); + const bridge = createCodexDynamicToolBridge({ + tools: [tool], + signal: new AbortController().signal, + hookContext: { runId: "run-wrapped" }, + }); + + const result = await bridge.handleToolCall({ + threadId: "thread-1", + turnId: "turn-1", + callId: "call-wrapped", + namespace: null, + tool: "exec", + arguments: { command: "pwd" }, + }); + + expect(result).toEqual({ + success: true, + contentItems: [{ type: "inputText", text: "done" }], + }); + expect(hooks.beforeToolCall).toHaveBeenCalledTimes(1); + expect(execute).toHaveBeenCalledWith( + "call-wrapped", + mergedParams, + expect.any(AbortSignal), + undefined, + ); + }); +}); diff --git a/extensions/codex/src/app-server/outcome-fallback-runtime-contract.test.ts b/extensions/codex/src/app-server/outcome-fallback-runtime-contract.test.ts new file mode 100644 index 00000000000..c24008f65ef --- /dev/null +++ b/extensions/codex/src/app-server/outcome-fallback-runtime-contract.test.ts @@ -0,0 +1,352 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { SessionManager } from "@mariozechner/pi-coding-agent"; +import type { EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness"; +import { afterEach, describe, expect, it } from "vitest"; +import { classifyEmbeddedPiRunResultForModelFallback } from "../../../../src/agents/pi-embedded-runner/result-fallback-classifier.js"; +import { + createContractRunResult, + OUTCOME_FALLBACK_RUNTIME_CONTRACT, +} from "../../../../test/helpers/agents/outcome-fallback-runtime-contract.js"; +import { + CodexAppServerEventProjector, + type CodexAppServerToolTelemetry, +} from "./event-projector.js"; +import { createCodexTestModel } from "./test-support.js"; + +const THREAD_ID = "thread-outcome-contract"; +const TURN_ID = "turn-outcome-contract"; +const tempDirs = new Set(); + +type ProjectorNotification = Parameters[0]; +type ProjectedAttemptResult = ReturnType; + +async function createParams(): Promise { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-outcome-contract-")); + tempDirs.add(tempDir); + const sessionFile = path.join(tempDir, "session.jsonl"); + SessionManager.open(sessionFile); + return { + prompt: OUTCOME_FALLBACK_RUNTIME_CONTRACT.prompt, + sessionId: OUTCOME_FALLBACK_RUNTIME_CONTRACT.sessionId, + sessionKey: OUTCOME_FALLBACK_RUNTIME_CONTRACT.sessionKey, + sessionFile, + workspaceDir: tempDir, + runId: OUTCOME_FALLBACK_RUNTIME_CONTRACT.runId, + provider: "codex", + modelId: OUTCOME_FALLBACK_RUNTIME_CONTRACT.primaryModel, + model: createCodexTestModel("codex"), + thinkLevel: "medium", + } as EmbeddedRunAttemptParams; +} + +async function createProjector(): Promise { + return new CodexAppServerEventProjector(await createParams(), THREAD_ID, TURN_ID); +} + +function buildToolTelemetry( + overrides: Partial = {}, +): CodexAppServerToolTelemetry { + return { + didSendViaMessagingTool: false, + messagingToolSentTexts: [], + messagingToolSentMediaUrls: [], + messagingToolSentTargets: [], + toolMediaUrls: [], + toolAudioAsVoice: false, + ...overrides, + }; +} + +function forCurrentTurn( + method: ProjectorNotification["method"], + params: Record, +): ProjectorNotification { + return { + method, + params: { threadId: THREAD_ID, turnId: TURN_ID, ...params }, + } as ProjectorNotification; +} + +function classifyProjectedAttemptResult(result: ProjectedAttemptResult) { + const finalAssistantText = result.assistantTexts.join("\n\n").trim(); + return classifyEmbeddedPiRunResultForModelFallback({ + provider: "codex", + model: OUTCOME_FALLBACK_RUNTIME_CONTRACT.primaryModel, + result: createContractRunResult({ + ...result, + meta: { + durationMs: 1, + aborted: result.aborted, + agentHarnessResultClassification: result.agentHarnessResultClassification, + finalAssistantRawText: finalAssistantText || undefined, + finalAssistantVisibleText: finalAssistantText || undefined, + }, + }), + }); +} + +afterEach(async () => { + for (const tempDir of tempDirs) { + await fs.rm(tempDir, { recursive: true, force: true }); + } + tempDirs.clear(); +}); + +describe("Outcome/fallback runtime contract - Codex app-server adapter", () => { + it("preserves an empty terminal turn for OpenClaw-owned fallback classification", async () => { + const projector = await createProjector(); + await projector.handleNotification( + forCurrentTurn("turn/completed", { + turn: { id: TURN_ID, status: "completed", items: [] }, + }), + ); + + const result = projector.buildResult(buildToolTelemetry()); + + expect(result.assistantTexts).toEqual([]); + expect(result.lastAssistant).toBeUndefined(); + expect(result.promptError).toBeNull(); + }); + + it("preserves exact NO_REPLY as assistant text instead of classifying in the adapter", async () => { + const projector = await createProjector(); + await projector.handleNotification( + forCurrentTurn("item/agentMessage/delta", { + itemId: "msg-1", + delta: "NO_REPLY", + }), + ); + await projector.handleNotification( + forCurrentTurn("turn/completed", { + turn: { + id: TURN_ID, + status: "completed", + items: [{ type: "agentMessage", id: "msg-1", text: "NO_REPLY" }], + }, + }), + ); + + const result = projector.buildResult(buildToolTelemetry()); + + expect(result.assistantTexts).toEqual(["NO_REPLY"]); + expect(result.lastAssistant?.content).toEqual([{ type: "text", text: "NO_REPLY" }]); + expect(result.promptError).toBeNull(); + }); + + it("preserves reasoning-only terminal turns for OpenClaw-owned fallback classification", async () => { + const projector = await createProjector(); + await projector.handleNotification( + forCurrentTurn("item/reasoning/textDelta", { + itemId: "reasoning-1", + delta: OUTCOME_FALLBACK_RUNTIME_CONTRACT.reasoningOnlyText, + }), + ); + await projector.handleNotification( + forCurrentTurn("turn/completed", { + turn: { + id: TURN_ID, + status: "completed", + items: [{ type: "reasoning", id: "reasoning-1" }], + }, + }), + ); + + const result = projector.buildResult(buildToolTelemetry()); + + expect(result.assistantTexts).toEqual([]); + expect(result.lastAssistant).toBeUndefined(); + expect(result.promptError).toBeNull(); + expect(result.messagesSnapshot).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + role: "assistant", + content: [ + { + type: "text", + text: `Codex reasoning:\n${OUTCOME_FALLBACK_RUNTIME_CONTRACT.reasoningOnlyText}`, + }, + ], + }), + ]), + ); + }); + + it("preserves planning-only terminal turns for OpenClaw-owned fallback classification", async () => { + const projector = await createProjector(); + await projector.handleNotification( + forCurrentTurn("item/plan/delta", { + itemId: "plan-1", + delta: OUTCOME_FALLBACK_RUNTIME_CONTRACT.planningOnlyText, + }), + ); + await projector.handleNotification( + forCurrentTurn("turn/completed", { + turn: { + id: TURN_ID, + status: "completed", + items: [ + { + type: "plan", + id: "plan-1", + text: OUTCOME_FALLBACK_RUNTIME_CONTRACT.planningOnlyText, + }, + ], + }, + }), + ); + + const result = projector.buildResult(buildToolTelemetry()); + + expect(result.assistantTexts).toEqual([]); + expect(result.lastAssistant).toBeUndefined(); + expect(result.promptError).toBeNull(); + expect(result.messagesSnapshot).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + role: "assistant", + content: [ + { + type: "text", + text: `Codex plan:\n${OUTCOME_FALLBACK_RUNTIME_CONTRACT.planningOnlyText}`, + }, + ], + }), + ]), + ); + }); + + it("preserves tool side-effect telemetry so fallback can stay disabled", async () => { + const projector = await createProjector(); + + const result = projector.buildResult( + buildToolTelemetry({ + didSendViaMessagingTool: true, + messagingToolSentTexts: ["sent out of band"], + }), + ); + + expect(result.assistantTexts).toEqual([]); + expect(result.didSendViaMessagingTool).toBe(true); + expect(result.messagingToolSentTexts).toEqual(["sent out of band"]); + }); + + it.each([ + { + name: "empty", + classification: "empty", + expectedCode: "empty_result", + build: async () => { + const projector = await createProjector(); + await projector.handleNotification( + forCurrentTurn("turn/completed", { + turn: { id: TURN_ID, status: "completed", items: [] }, + }), + ); + return projector.buildResult(buildToolTelemetry()); + }, + }, + { + name: "reasoning-only", + classification: "reasoning-only", + expectedCode: "reasoning_only_result", + build: async () => { + const projector = await createProjector(); + await projector.handleNotification( + forCurrentTurn("item/reasoning/textDelta", { + itemId: "reasoning-1", + delta: OUTCOME_FALLBACK_RUNTIME_CONTRACT.reasoningOnlyText, + }), + ); + await projector.handleNotification( + forCurrentTurn("turn/completed", { + turn: { + id: TURN_ID, + status: "completed", + items: [{ type: "reasoning", id: "reasoning-1" }], + }, + }), + ); + return projector.buildResult(buildToolTelemetry()); + }, + }, + { + name: "planning-only", + classification: "planning-only", + expectedCode: "planning_only_result", + build: async () => { + const projector = await createProjector(); + await projector.handleNotification( + forCurrentTurn("item/plan/delta", { + itemId: "plan-1", + delta: OUTCOME_FALLBACK_RUNTIME_CONTRACT.planningOnlyText, + }), + ); + await projector.handleNotification( + forCurrentTurn("turn/completed", { + turn: { + id: TURN_ID, + status: "completed", + items: [ + { + type: "plan", + id: "plan-1", + text: OUTCOME_FALLBACK_RUNTIME_CONTRACT.planningOnlyText, + }, + ], + }, + }), + ); + return projector.buildResult(buildToolTelemetry()); + }, + }, + ] as const)( + "keeps $name terminal turns fallback-ready with adapter-produced classification", + async ({ build, classification, expectedCode }) => { + const result = await build(); + + expect(result.agentHarnessResultClassification).toBe(classification); + expect(classifyProjectedAttemptResult(result)).toMatchObject({ + reason: "format", + code: expectedCode, + }); + }, + ); + + it("keeps exact NO_REPLY classified as an intentional silent terminal reply", async () => { + const projector = await createProjector(); + await projector.handleNotification( + forCurrentTurn("item/agentMessage/delta", { + itemId: "msg-1", + delta: "NO_REPLY", + }), + ); + await projector.handleNotification( + forCurrentTurn("turn/completed", { + turn: { + id: TURN_ID, + status: "completed", + items: [{ type: "agentMessage", id: "msg-1", text: "NO_REPLY" }], + }, + }), + ); + + const result = projector.buildResult(buildToolTelemetry()); + + expect(classifyProjectedAttemptResult(result)).toBeNull(); + }); + + it("keeps tool side effects classified as non-fallback terminal outcomes", async () => { + const projector = await createProjector(); + const result = projector.buildResult( + buildToolTelemetry({ + didSendViaMessagingTool: true, + messagingToolSentTexts: ["sent out of band"], + }), + ); + + expect(result.agentHarnessResultClassification).toBeUndefined(); + expect(classifyProjectedAttemptResult(result)).toBeNull(); + }); +}); diff --git a/extensions/codex/src/app-server/run-attempt.ts b/extensions/codex/src/app-server/run-attempt.ts index f4e04a13ea0..03116521112 100644 --- a/extensions/codex/src/app-server/run-attempt.ts +++ b/extensions/codex/src/app-server/run-attempt.ts @@ -34,6 +34,7 @@ import { type NativeHookRelayRegistrationHandle, } from "openclaw/plugin-sdk/agent-harness-runtime"; import { handleCodexAppServerApprovalRequest } from "./approval-bridge.js"; +import { refreshCodexAppServerAuthTokens } from "./auth-bridge.js"; import { createCodexAppServerClientFactoryTestHooks, defaultCodexAppServerClientFactory, @@ -149,7 +150,10 @@ export async function runCodexAppServerAttempt( : undefined; let yieldDetected = false; const startupBinding = await readCodexAppServerBinding(params.sessionFile); - const startupAuthProfileId = params.authProfileId ?? startupBinding?.authProfileId; + const startupAuthProfileId = + params.runtimePlan?.auth.forwardedAuthProfileId ?? + params.authProfileId ?? + startupBinding?.authProfileId; const tools = await buildDynamicTools({ params, resolvedWorkspace, @@ -373,6 +377,12 @@ export async function runCodexAppServerAttempt( const notificationCleanup = client.addNotificationHandler(enqueueNotification); const requestCleanup = client.addRequestHandler(async (request) => { + if (request.method === "account/chatgptAuthTokens/refresh") { + return refreshCodexAppServerAuthTokens({ + agentDir, + authProfileId: startupAuthProfileId, + }); + } if (!turnId) { return undefined; } @@ -486,7 +496,11 @@ export async function runCodexAppServerAttempt( sessionId: params.sessionId, provider: params.provider, model: params.modelId, - resolvedRef: `${params.provider}/${params.modelId}`, + resolvedRef: + params.runtimePlan?.observability.resolvedRef ?? `${params.provider}/${params.modelId}`, + ...(params.runtimePlan?.observability.harnessId + ? { harnessId: params.runtimePlan.observability.harnessId } + : {}), assistantTexts: [], }, ctx: hookContext, @@ -642,7 +656,11 @@ export async function runCodexAppServerAttempt( sessionId: params.sessionId, provider: params.provider, model: params.modelId, - resolvedRef: `${params.provider}/${params.modelId}`, + resolvedRef: + params.runtimePlan?.observability.resolvedRef ?? `${params.provider}/${params.modelId}`, + ...(params.runtimePlan?.observability.harnessId + ? { harnessId: params.runtimePlan.observability.harnessId } + : {}), assistantTexts: result.assistantTexts, ...(result.lastAssistant ? { lastAssistant: result.lastAssistant } : {}), ...(result.attemptUsage ? { usage: result.attemptUsage } : {}), @@ -821,16 +839,23 @@ async function buildDynamicTools(input: DynamicToolBuildParams) { params.toolsAllow && params.toolsAllow.length > 0 ? visionFilteredTools.filter((tool) => params.toolsAllow?.includes(tool.name)) : visionFilteredTools; - return normalizeProviderToolSchemas({ - tools: filteredTools, - provider: params.provider, - config: params.config, - workspaceDir: input.effectiveWorkspace, - env: process.env, - modelId: params.modelId, - modelApi: params.model.api, - model: params.model, - }); + return ( + params.runtimePlan?.tools.normalize(filteredTools, { + workspaceDir: input.effectiveWorkspace, + modelApi: params.model.api, + model: params.model, + }) ?? + normalizeProviderToolSchemas({ + tools: filteredTools, + provider: params.provider, + config: params.config, + workspaceDir: input.effectiveWorkspace, + env: process.env, + modelId: params.modelId, + modelApi: params.model.api, + model: params.model, + }) + ); } async function withCodexStartupTimeout(params: { diff --git a/extensions/codex/src/app-server/schema-normalization-runtime-contract.test.ts b/extensions/codex/src/app-server/schema-normalization-runtime-contract.test.ts new file mode 100644 index 00000000000..87084efdc60 --- /dev/null +++ b/extensions/codex/src/app-server/schema-normalization-runtime-contract.test.ts @@ -0,0 +1,168 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import type { EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + createParameterFreeTool, + createPermissiveTool, + normalizedParameterFreeSchema, +} from "../../../../test/helpers/agents/schema-normalization-runtime-contract.js"; +import { createCodexTestModel } from "./test-support.js"; +import { startOrResumeThread } from "./thread-lifecycle.js"; + +let tempDir: string; + +function createParams(sessionFile: string, workspaceDir: string): EmbeddedRunAttemptParams { + return { + prompt: "hello", + sessionId: "session-1", + sessionKey: "agent:main:session-1", + sessionFile, + workspaceDir, + runId: "run-1", + provider: "codex", + modelId: "gpt-5.4", + model: createCodexTestModel("codex"), + thinkLevel: "medium", + disableTools: true, + timeoutMs: 5_000, + authStorage: {} as never, + modelRegistry: {} as never, + } as EmbeddedRunAttemptParams; +} + +function createAppServerOptions(): Parameters[0]["appServer"] { + return { + start: { + transport: "stdio", + command: "codex", + args: ["app-server"], + headers: {}, + }, + requestTimeoutMs: 60_000, + approvalPolicy: "never", + approvalsReviewer: "user", + sandbox: "workspace-write", + }; +} + +function threadStartResult(threadId = "thread-1") { + return { + thread: { + id: threadId, + forkedFromId: null, + preview: "", + ephemeral: false, + modelProvider: "openai", + createdAt: 1, + updatedAt: 1, + status: { type: "idle" }, + path: null, + cwd: tempDir, + cliVersion: "0.118.0", + source: "unknown", + agentNickname: null, + agentRole: null, + gitInfo: null, + name: null, + turns: [], + }, + model: "gpt-5.4", + modelProvider: "openai", + serviceTier: null, + cwd: tempDir, + instructionSources: [], + approvalPolicy: "never", + approvalsReviewer: "user", + sandbox: { type: "dangerFullAccess" }, + permissionProfile: null, + reasoningEffort: null, + }; +} + +describe("Codex app-server dynamic tool schema boundary contract", () => { + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-schema-contract-")); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + it("passes prepared executable dynamic tool schemas through thread start unchanged", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + const workspaceDir = path.join(tempDir, "workspace"); + const parameterFreeTool = createParameterFreeTool("message"); + const dynamicTool = { + name: parameterFreeTool.name, + description: parameterFreeTool.description, + inputSchema: normalizedParameterFreeSchema(), + }; + const request = vi.fn(async (method: string) => { + if (method === "thread/start") { + return threadStartResult(); + } + throw new Error(`unexpected method: ${method}`); + }); + + await startOrResumeThread({ + client: { request } as never, + params: createParams(sessionFile, workspaceDir), + cwd: workspaceDir, + dynamicTools: [dynamicTool], + appServer: createAppServerOptions(), + }); + + expect(request).toHaveBeenCalledWith( + "thread/start", + expect.objectContaining({ + dynamicTools: [dynamicTool], + }), + ); + }); + + it("treats dynamic tool schema changes as thread-fingerprint changes", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + const workspaceDir = path.join(tempDir, "workspace"); + const appServer = createAppServerOptions(); + let nextThreadId = 1; + const request = vi.fn(async (method: string) => { + if (method === "thread/start") { + return threadStartResult(`thread-${nextThreadId++}`); + } + throw new Error(`unexpected method: ${method}`); + }); + + await startOrResumeThread({ + client: { request } as never, + params: createParams(sessionFile, workspaceDir), + cwd: workspaceDir, + dynamicTools: [ + { + name: "message", + description: "Permissive test tool", + inputSchema: { type: "object" }, + }, + ], + appServer, + }); + const permissiveTool = createPermissiveTool("message"); + await startOrResumeThread({ + client: { request } as never, + params: createParams(sessionFile, workspaceDir), + cwd: workspaceDir, + dynamicTools: [ + { + name: permissiveTool.name, + description: permissiveTool.description, + inputSchema: permissiveTool.parameters, + }, + ], + appServer, + }); + + expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start", "thread/start"]); + }); +}); diff --git a/extensions/codex/src/app-server/shared-client.test.ts b/extensions/codex/src/app-server/shared-client.test.ts index 1f1a835aa13..95b6cd9fc74 100644 --- a/extensions/codex/src/app-server/shared-client.test.ts +++ b/extensions/codex/src/app-server/shared-client.test.ts @@ -5,10 +5,12 @@ import { createClientHarness } from "./test-support.js"; const mocks = vi.hoisted(() => ({ bridgeCodexAppServerStartOptions: vi.fn(async ({ startOptions }) => startOptions), + applyCodexAppServerAuthProfile: vi.fn(async () => undefined), resolveOpenClawAgentDir: vi.fn(() => "/tmp/openclaw-agent"), })); vi.mock("./auth-bridge.js", () => ({ + applyCodexAppServerAuthProfile: mocks.applyCodexAppServerAuthProfile, bridgeCodexAppServerStartOptions: mocks.bridgeCodexAppServerStartOptions, })); @@ -51,6 +53,7 @@ describe("shared Codex app-server client", () => { vi.useRealTimers(); vi.restoreAllMocks(); mocks.bridgeCodexAppServerStartOptions.mockClear(); + mocks.applyCodexAppServerAuthProfile.mockClear(); mocks.resolveOpenClawAgentDir.mockClear(); }); @@ -118,6 +121,11 @@ describe("shared Codex app-server client", () => { authProfileId: "openai-codex:work", }), ); + expect(mocks.applyCodexAppServerAuthProfile).toHaveBeenCalledWith( + expect.objectContaining({ + authProfileId: "openai-codex:work", + }), + ); }); it("restarts the shared client when the bridged auth token changes", async () => { diff --git a/extensions/codex/src/app-server/shared-client.ts b/extensions/codex/src/app-server/shared-client.ts index 98d6c072713..1338e52d92c 100644 --- a/extensions/codex/src/app-server/shared-client.ts +++ b/extensions/codex/src/app-server/shared-client.ts @@ -1,5 +1,5 @@ import { resolveOpenClawAgentDir } from "openclaw/plugin-sdk/provider-auth"; -import { bridgeCodexAppServerStartOptions } from "./auth-bridge.js"; +import { applyCodexAppServerAuthProfile, bridgeCodexAppServerStartOptions } from "./auth-bridge.js"; import { CodexAppServerClient } from "./client.js"; import { codexAppServerStartOptionsKey, @@ -35,7 +35,9 @@ export async function getSharedCodexAppServerClient(options?: { agentDir: resolveOpenClawAgentDir(), authProfileId: options?.authProfileId, }); - const key = codexAppServerStartOptionsKey(startOptions); + const key = codexAppServerStartOptionsKey(startOptions, { + authProfileId: options?.authProfileId, + }); if (state.key && state.key !== key) { clearSharedCodexAppServerClient(); } @@ -48,6 +50,11 @@ export async function getSharedCodexAppServerClient(options?: { client.addCloseHandler(clearSharedClientIfCurrent); try { await client.initialize(); + await applyCodexAppServerAuthProfile({ + client, + agentDir: resolveOpenClawAgentDir(), + authProfileId: options?.authProfileId, + }); return client; } catch (error) { // Startup failures happen before callers own the shared client, so close @@ -84,6 +91,11 @@ export async function createIsolatedCodexAppServerClient(options?: { const initialize = client.initialize(); try { await withTimeout(initialize, options?.timeoutMs ?? 0, "codex app-server initialize timed out"); + await applyCodexAppServerAuthProfile({ + client, + agentDir: resolveOpenClawAgentDir(), + authProfileId: options?.authProfileId, + }); return client; } catch (error) { client.close(); diff --git a/extensions/codex/src/app-server/thread-lifecycle.ts b/extensions/codex/src/app-server/thread-lifecycle.ts index 3bbbb5bb111..3b8ea703d7a 100644 --- a/extensions/codex/src/app-server/thread-lifecycle.ts +++ b/extensions/codex/src/app-server/thread-lifecycle.ts @@ -219,16 +219,45 @@ function stabilizeJsonValue(value: JsonValue): JsonValue { } export function buildDeveloperInstructions(params: EmbeddedRunAttemptParams): string { + const promptOverlay = renderCodexRuntimePromptOverlay(params); const sections = [ "You are running inside OpenClaw. Use OpenClaw dynamic tools for messaging, cron, sessions, and host actions when available.", "Preserve the user's existing channel/session context. If sending a channel reply, use the OpenClaw messaging tool instead of describing that you would reply.", - renderCodexPromptOverlay({ modelId: params.modelId }), + promptOverlay, params.extraSystemPrompt, params.skillsSnapshot?.prompt, ]; return sections.filter((section) => typeof section === "string" && section.trim()).join("\n\n"); } +function renderCodexRuntimePromptOverlay(params: EmbeddedRunAttemptParams): string | undefined { + const contribution = params.runtimePlan?.prompt.resolveSystemPromptContribution({ + config: params.config, + agentDir: params.agentDir, + workspaceDir: params.workspaceDir, + provider: params.provider, + modelId: params.modelId, + promptMode: "full", + agentId: params.agentId, + }); + if (!contribution) { + return renderCodexPromptOverlay({ + config: params.config, + providerId: params.provider, + modelId: params.modelId, + }); + } + return [ + contribution.stablePrefix, + ...Object.values(contribution.sectionOverrides ?? {}), + contribution.dynamicSuffix, + ] + .filter( + (section): section is string => typeof section === "string" && section.trim().length > 0, + ) + .join("\n\n"); +} + function buildUserInput( params: EmbeddedRunAttemptParams, promptText: string = params.prompt, diff --git a/extensions/codex/src/app-server/transcript-repair-runtime-contract.test.ts b/extensions/codex/src/app-server/transcript-repair-runtime-contract.test.ts new file mode 100644 index 00000000000..4f8850f4c77 --- /dev/null +++ b/extensions/codex/src/app-server/transcript-repair-runtime-contract.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { + assistantHistoryMessage, + currentPromptHistoryMessage, + mediaOnlyHistoryMessage, + structuredHistoryMessage, +} from "../../../../test/helpers/agents/transcript-repair-runtime-contract.js"; +import { projectContextEngineAssemblyForCodex } from "./context-engine-projection.js"; + +describe("Codex transcript projection runtime contract", () => { + it("drops only the duplicate trailing current prompt while preserving prior structured context", () => { + const prompt = "newest inbound message"; + + const result = projectContextEngineAssemblyForCodex({ + prompt, + originalHistoryMessages: [structuredHistoryMessage()], + assembledMessages: [ + structuredHistoryMessage(), + assistantHistoryMessage(), + currentPromptHistoryMessage(prompt), + ], + }); + + expect(result.promptText).toContain("Current user request:\nnewest inbound message"); + expect(result.promptText).toContain("[user]\nolder structured context\n[image omitted]"); + expect(result.promptText).toContain("[assistant]\nack"); + expect(result.promptText).not.toContain("[user]\nnewest inbound message"); + }); + + it("keeps media-only user history visible as omitted media instead of dropping the turn", () => { + const result = projectContextEngineAssemblyForCodex({ + prompt: "newest inbound message", + originalHistoryMessages: [mediaOnlyHistoryMessage()], + assembledMessages: [ + mediaOnlyHistoryMessage(), + currentPromptHistoryMessage("newest inbound message"), + ], + }); + + expect(result.promptText).toContain("[user]\n[image omitted]"); + expect(result.promptText).not.toContain("data:image/png"); + expect(result.promptText).not.toContain("bbbb"); + }); +}); diff --git a/src/agents/auth-profile-runtime-contract.test.ts b/src/agents/auth-profile-runtime-contract.test.ts new file mode 100644 index 00000000000..a7709644a90 --- /dev/null +++ b/src/agents/auth-profile-runtime-contract.test.ts @@ -0,0 +1,406 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + AUTH_PROFILE_RUNTIME_CONTRACT, + createAuthAliasManifestRegistry, + expectedForwardedAuthProfile, +} from "../../test/helpers/agents/auth-profile-runtime-contract.js"; +import type { SessionEntry } from "../config/sessions.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type * as ManifestRegistryModule from "../plugins/manifest-registry.js"; +import { runAgentAttempt } from "./command/attempt-execution.js"; +import type { EmbeddedPiRunResult } from "./pi-embedded.js"; +import { resolveProviderIdForAuth } from "./provider-auth-aliases.js"; + +type LoadPluginManifestRegistry = typeof ManifestRegistryModule.loadPluginManifestRegistry; + +const loadPluginManifestRegistry = vi.hoisted(() => + vi.fn(() => ({ + plugins: [], + diagnostics: [], + })), +); +const runCliAgentMock = vi.hoisted(() => vi.fn()); +const runEmbeddedPiAgentMock = vi.hoisted(() => vi.fn()); + +vi.mock("../plugins/manifest-registry.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadPluginManifestRegistry, + }; +}); + +vi.mock("./cli-runner.js", () => ({ + runCliAgent: runCliAgentMock, +})); + +vi.mock("./model-selection.js", () => ({ + isCliProvider: (provider: string) => { + const normalized = provider.trim().toLowerCase(); + return ( + normalized === AUTH_PROFILE_RUNTIME_CONTRACT.claudeCliProvider || + normalized === AUTH_PROFILE_RUNTIME_CONTRACT.codexCliProvider + ); + }, + normalizeProviderId: (provider: string) => provider.trim().toLowerCase(), +})); + +vi.mock("./pi-embedded.js", () => ({ + runEmbeddedPiAgent: runEmbeddedPiAgentMock, +})); + +function makeCliResult(text: string): EmbeddedPiRunResult { + return { + payloads: [{ text }], + meta: { + durationMs: 5, + finalAssistantVisibleText: text, + agentMeta: { + sessionId: AUTH_PROFILE_RUNTIME_CONTRACT.sessionId, + provider: AUTH_PROFILE_RUNTIME_CONTRACT.codexCliProvider, + model: "gpt-5.4", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total: 0, + }, + }, + executionTrace: { + winnerProvider: AUTH_PROFILE_RUNTIME_CONTRACT.codexCliProvider, + winnerModel: "gpt-5.4", + fallbackUsed: false, + runner: "cli", + }, + }, + }; +} + +function makeEmbeddedResult(text: string): EmbeddedPiRunResult { + return { + payloads: [{ text }], + meta: { + durationMs: 5, + finalAssistantVisibleText: text, + agentMeta: { + sessionId: AUTH_PROFILE_RUNTIME_CONTRACT.sessionId, + provider: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProvider, + model: "gpt-5.4", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total: 0, + }, + }, + executionTrace: { + winnerProvider: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProvider, + winnerModel: "gpt-5.4", + fallbackUsed: false, + runner: "embedded", + }, + }, + }; +} + +async function runAuthContractAttempt(params: { + tmpDir: string; + storePath: string; + providerOverride: string; + authProfileProvider: string; + authProfileOverride: string; + cfg?: OpenClawConfig; +}) { + const cfg = params.cfg ?? ({} as OpenClawConfig); + const sessionEntry: SessionEntry = { + sessionId: AUTH_PROFILE_RUNTIME_CONTRACT.sessionId, + updatedAt: Date.now(), + authProfileOverride: params.authProfileOverride, + authProfileOverrideSource: "user", + }; + const sessionStore: Record = { + [AUTH_PROFILE_RUNTIME_CONTRACT.sessionKey]: sessionEntry, + }; + await fs.writeFile(params.storePath, JSON.stringify(sessionStore, null, 2), "utf-8"); + + await runAgentAttempt({ + providerOverride: params.providerOverride, + modelOverride: "gpt-5.4", + cfg, + sessionEntry, + sessionId: sessionEntry.sessionId, + sessionKey: AUTH_PROFILE_RUNTIME_CONTRACT.sessionKey, + sessionAgentId: "main", + sessionFile: path.join(params.tmpDir, "session.jsonl"), + workspaceDir: params.tmpDir, + body: AUTH_PROFILE_RUNTIME_CONTRACT.workspacePrompt, + isFallbackRetry: false, + resolvedThinkLevel: "medium", + timeoutMs: 1_000, + runId: AUTH_PROFILE_RUNTIME_CONTRACT.runId, + opts: { senderIsOwner: false } as Parameters[0]["opts"], + runContext: {} as Parameters[0]["runContext"], + spawnedBy: undefined, + messageChannel: undefined, + skillsSnapshot: undefined, + resolvedVerboseLevel: undefined, + agentDir: params.tmpDir, + onAgentEvent: vi.fn(), + authProfileProvider: params.authProfileProvider, + sessionStore, + storePath: params.storePath, + sessionHasHistory: false, + }); + + return { + aliasLookupParams: { + config: cfg, + workspaceDir: params.tmpDir, + }, + }; +} + +describe("Auth profile runtime contract - Pi and CLI adapter", () => { + let tmpDir: string; + let storePath: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-contract-")); + storePath = path.join(tmpDir, "sessions.json"); + loadPluginManifestRegistry.mockReset().mockReturnValue(createAuthAliasManifestRegistry()); + runCliAgentMock.mockReset(); + runEmbeddedPiAgentMock.mockReset(); + runCliAgentMock.mockResolvedValue(makeCliResult("ok")); + runEmbeddedPiAgentMock.mockResolvedValue(makeEmbeddedResult("ok")); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it.each([ + [AUTH_PROFILE_RUNTIME_CONTRACT.openAiProvider, AUTH_PROFILE_RUNTIME_CONTRACT.openAiProvider], + [ + AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProvider, + AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProvider, + ], + [ + AUTH_PROFILE_RUNTIME_CONTRACT.codexCliProvider, + AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProvider, + ], + [ + AUTH_PROFILE_RUNTIME_CONTRACT.codexHarnessProvider, + AUTH_PROFILE_RUNTIME_CONTRACT.codexHarnessProvider, + ], + ] as const)( + "resolves %s through the provider auth alias resolver using a mocked manifest", + (provider, expectedAuthProvider) => { + expect( + resolveProviderIdForAuth(provider, { + config: {} as OpenClawConfig, + workspaceDir: tmpDir, + }), + ).toBe(expectedAuthProvider); + }, + ); + + it("forwards an OpenAI Codex auth profile when the selected provider is codex-cli", async () => { + const { aliasLookupParams } = await runAuthContractAttempt({ + tmpDir, + storePath, + providerOverride: AUTH_PROFILE_RUNTIME_CONTRACT.codexCliProvider, + authProfileProvider: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProvider, + authProfileOverride: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId, + }); + + expect(runCliAgentMock).toHaveBeenCalledTimes(1); + expect(runCliAgentMock.mock.calls[0]?.[0]?.authProfileId).toBe( + expectedForwardedAuthProfile({ + provider: AUTH_PROFILE_RUNTIME_CONTRACT.codexCliProvider, + authProfileProvider: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProvider, + aliasLookupParams, + sessionAuthProfileId: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId, + }), + ); + }); + + it("forwards an OpenAI Codex auth profile when the auth provider is the legacy codex-cli alias", async () => { + const { aliasLookupParams } = await runAuthContractAttempt({ + tmpDir, + storePath, + providerOverride: AUTH_PROFILE_RUNTIME_CONTRACT.codexCliProvider, + authProfileProvider: AUTH_PROFILE_RUNTIME_CONTRACT.codexCliProvider, + authProfileOverride: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId, + }); + + expect(runCliAgentMock).toHaveBeenCalledTimes(1); + expect(runCliAgentMock.mock.calls[0]?.[0]?.authProfileId).toBe( + expectedForwardedAuthProfile({ + provider: AUTH_PROFILE_RUNTIME_CONTRACT.codexCliProvider, + authProfileProvider: AUTH_PROFILE_RUNTIME_CONTRACT.codexCliProvider, + aliasLookupParams, + sessionAuthProfileId: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId, + }), + ); + }); + + it("does not leak an OpenAI API-key auth profile into the Codex CLI alias", async () => { + await runAuthContractAttempt({ + tmpDir, + storePath, + providerOverride: AUTH_PROFILE_RUNTIME_CONTRACT.codexCliProvider, + authProfileProvider: AUTH_PROFILE_RUNTIME_CONTRACT.openAiProvider, + authProfileOverride: AUTH_PROFILE_RUNTIME_CONTRACT.openAiProfileId, + }); + + expect(runCliAgentMock).toHaveBeenCalledTimes(1); + expect(runCliAgentMock.mock.calls[0]?.[0]?.authProfileId).toBeUndefined(); + }); + + it("does not leak an OpenAI Codex auth profile into an unrelated CLI provider", async () => { + await runAuthContractAttempt({ + tmpDir, + storePath, + providerOverride: AUTH_PROFILE_RUNTIME_CONTRACT.claudeCliProvider, + authProfileProvider: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProvider, + authProfileOverride: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId, + }); + + expect(runCliAgentMock).toHaveBeenCalledTimes(1); + expect(runCliAgentMock.mock.calls[0]?.[0]?.authProfileId).toBeUndefined(); + }); + + it("does not let a configured Codex harness leak OpenAI Codex auth into unrelated CLI providers", async () => { + await runAuthContractAttempt({ + tmpDir, + storePath, + providerOverride: AUTH_PROFILE_RUNTIME_CONTRACT.claudeCliProvider, + authProfileProvider: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProvider, + authProfileOverride: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId, + cfg: { + agents: { + defaults: { + embeddedHarness: { runtime: "codex", fallback: "none" }, + }, + }, + } as OpenClawConfig, + }); + + expect(runCliAgentMock).toHaveBeenCalledTimes(1); + expect(runCliAgentMock.mock.calls[0]?.[0]?.authProfileId).toBeUndefined(); + }); + + it("forwards an OpenAI Codex auth profile through the embedded Pi path", async () => { + await runAuthContractAttempt({ + tmpDir, + storePath, + providerOverride: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProvider, + authProfileProvider: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProvider, + authProfileOverride: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId, + }); + + expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); + expect(runEmbeddedPiAgentMock.mock.calls[0]?.[0]?.authProfileId).toBe( + AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId, + ); + }); + + it("accepts the legacy codex-cli auth-provider alias on the embedded OpenAI Codex path", async () => { + const { aliasLookupParams } = await runAuthContractAttempt({ + tmpDir, + storePath, + providerOverride: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProvider, + authProfileProvider: AUTH_PROFILE_RUNTIME_CONTRACT.codexCliProvider, + authProfileOverride: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId, + }); + + expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); + expect(runEmbeddedPiAgentMock.mock.calls[0]?.[0]?.authProfileId).toBe( + expectedForwardedAuthProfile({ + provider: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProvider, + authProfileProvider: AUTH_PROFILE_RUNTIME_CONTRACT.codexCliProvider, + aliasLookupParams, + sessionAuthProfileId: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId, + }), + ); + }); + + it("forwards an OpenAI auth profile through the embedded OpenAI path", async () => { + await runAuthContractAttempt({ + tmpDir, + storePath, + providerOverride: AUTH_PROFILE_RUNTIME_CONTRACT.openAiProvider, + authProfileProvider: AUTH_PROFILE_RUNTIME_CONTRACT.openAiProvider, + authProfileOverride: AUTH_PROFILE_RUNTIME_CONTRACT.openAiProfileId, + }); + + expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); + expect(runEmbeddedPiAgentMock.mock.calls[0]?.[0]?.authProfileId).toBe( + AUTH_PROFILE_RUNTIME_CONTRACT.openAiProfileId, + ); + }); + + it("does not leak an OpenAI Codex auth profile into an unrelated embedded provider", async () => { + await runAuthContractAttempt({ + tmpDir, + storePath, + providerOverride: AUTH_PROFILE_RUNTIME_CONTRACT.openAiProvider, + authProfileProvider: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProvider, + authProfileOverride: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId, + }); + + expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); + expect(runEmbeddedPiAgentMock.mock.calls[0]?.[0]?.authProfileId).toBeUndefined(); + }); + + it("preserves OpenAI Codex auth profiles through the real codex/* harness startup path", async () => { + await runAuthContractAttempt({ + tmpDir, + storePath, + providerOverride: AUTH_PROFILE_RUNTIME_CONTRACT.codexHarnessProvider, + authProfileProvider: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProvider, + authProfileOverride: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId, + cfg: { + agents: { + defaults: { + embeddedHarness: { runtime: "codex", fallback: "none" }, + }, + }, + } as OpenClawConfig, + }); + + expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); + expect(runEmbeddedPiAgentMock.mock.calls[0]?.[0]).toMatchObject({ + agentHarnessId: "codex", + authProfileId: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId, + }); + }); + + it("validates openai/* forced through the Codex harness can use OpenAI Codex OAuth profiles", async () => { + await runAuthContractAttempt({ + tmpDir, + storePath, + providerOverride: AUTH_PROFILE_RUNTIME_CONTRACT.openAiProvider, + authProfileProvider: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProvider, + authProfileOverride: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId, + cfg: { + agents: { + defaults: { + embeddedHarness: { runtime: "codex", fallback: "none" }, + }, + }, + } as OpenClawConfig, + }); + + expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); + expect(runEmbeddedPiAgentMock.mock.calls[0]?.[0]).toMatchObject({ + agentHarnessId: "codex", + authProfileId: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId, + }); + }); +}); diff --git a/src/agents/codex-native-web-search.test.ts b/src/agents/codex-native-web-search.test.ts index 33db6d4f018..256d3b2185a 100644 --- a/src/agents/codex-native-web-search.test.ts +++ b/src/agents/codex-native-web-search.test.ts @@ -5,6 +5,7 @@ import { patchCodexNativeWebSearchPayload, resolveCodexNativeSearchActivation, resolveCodexNativeWebSearchConfig, + isCodexNativeWebSearchRelevant, shouldSuppressManagedWebSearchTool, } from "./codex-native-web-search.js"; @@ -230,9 +231,7 @@ describe("shouldSuppressManagedWebSearchTool", () => { }); describe("isCodexNativeWebSearchRelevant", () => { - it("treats a default model with model-level openai-codex-responses api as relevant", async () => { - const { isCodexNativeWebSearchRelevant } = await import("./codex-native-web-search.js"); - + it("treats a default model with model-level openai-codex-responses api as relevant", () => { expect( isCodexNativeWebSearchRelevant({ config: { diff --git a/src/agents/command/attempt-execution.ts b/src/agents/command/attempt-execution.ts index 94202d8e6f7..f5580c1d61e 100644 --- a/src/agents/command/attempt-execution.ts +++ b/src/agents/command/attempt-execution.ts @@ -18,7 +18,7 @@ import { resolveAgentHarnessPolicy } from "../harness/selection.js"; import { isCliProvider } from "../model-selection.js"; import { prepareSessionManagerForRun } from "../pi-embedded-runner/session-manager-init.js"; import { runEmbeddedPiAgent, type EmbeddedPiRunResult } from "../pi-embedded.js"; -import { resolveProviderIdForAuth } from "../provider-auth-aliases.js"; +import { buildAgentRuntimeAuthPlan } from "../runtime-plan/auth.js"; import { buildWorkspaceSkillSnapshot } from "../skills.js"; import { buildUsageWithNoCost } from "../stream-message-shared.js"; import { @@ -272,18 +272,24 @@ export function runAgentAttempt(params: { sessionId: params.sessionId, sessionKey: params.sessionKey ?? params.sessionId, }); - const providerAuthKey = resolveProviderIdForAuth(params.providerOverride, { + const agentHarnessPolicy = resolveAgentHarnessPolicy({ + provider: params.providerOverride, + modelId: params.modelOverride, + config: params.cfg, + agentId: params.sessionAgentId, + sessionKey: params.sessionKey ?? params.sessionId, + }); + const runtimeAuthPlan = buildAgentRuntimeAuthPlan({ + provider: params.providerOverride, + authProfileProvider: params.authProfileProvider, + sessionAuthProfileId: params.sessionEntry?.authProfileOverride, config: params.cfg, workspaceDir: params.workspaceDir, + harnessId: sessionPinnedAgentHarnessId, + harnessRuntime: agentHarnessPolicy.runtime, + allowHarnessAuthProfileForwarding: !isCliProvider(params.providerOverride, params.cfg), }); - const authProfileProviderKey = resolveProviderIdForAuth(params.authProfileProvider, { - config: params.cfg, - workspaceDir: params.workspaceDir, - }); - const authProfileId = - providerAuthKey === authProfileProviderKey - ? params.sessionEntry?.authProfileOverride - : undefined; + const authProfileId = runtimeAuthPlan.forwardedAuthProfileId; if (isCliProvider(params.providerOverride, params.cfg)) { const cliSessionBinding = getCliSessionBinding(params.sessionEntry, params.providerOverride); const resolveReusableCliSessionBinding = async () => { diff --git a/src/agents/openai-tool-schema.test.ts b/src/agents/openai-tool-schema.test.ts new file mode 100644 index 00000000000..3d6501287e4 --- /dev/null +++ b/src/agents/openai-tool-schema.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { + isStrictOpenAIJsonSchemaCompatible, + normalizeStrictOpenAIJsonSchema, + resolveOpenAIStrictToolFlagForInventory, +} from "./openai-tool-schema.js"; + +describe("OpenAI strict tool schema normalization", () => { + it("does not close permissive nested object schemas implicitly", () => { + const schema = { + type: "object", + properties: { + metadata: { + type: "object", + }, + }, + required: ["metadata"], + }; + + const normalized = normalizeStrictOpenAIJsonSchema(schema) as { + additionalProperties?: boolean; + properties?: { metadata?: { additionalProperties?: boolean } }; + }; + + expect(normalized.additionalProperties).toBe(false); + expect(normalized.properties?.metadata).not.toHaveProperty("additionalProperties"); + expect(isStrictOpenAIJsonSchemaCompatible(schema)).toBe(false); + expect( + resolveOpenAIStrictToolFlagForInventory([{ name: "write", parameters: schema }], true), + ).toBe(false); + }); +}); diff --git a/src/agents/openai-tool-schema.ts b/src/agents/openai-tool-schema.ts index 660d2f8b059..beddb3b5515 100644 --- a/src/agents/openai-tool-schema.ts +++ b/src/agents/openai-tool-schema.ts @@ -10,14 +10,14 @@ type ToolWithParameters = { }; export function normalizeStrictOpenAIJsonSchema(schema: unknown): unknown { - return normalizeStrictOpenAIJsonSchemaRecursive(normalizeToolParameterSchema(schema ?? {})); + return normalizeStrictOpenAIJsonSchemaRecursive(normalizeToolParameterSchema(schema ?? {}), 0); } -function normalizeStrictOpenAIJsonSchemaRecursive(schema: unknown): unknown { +function normalizeStrictOpenAIJsonSchemaRecursive(schema: unknown, depth: number): unknown { if (Array.isArray(schema)) { let changed = false; const normalized = schema.map((entry) => { - const next = normalizeStrictOpenAIJsonSchemaRecursive(entry); + const next = normalizeStrictOpenAIJsonSchemaRecursive(entry, depth); changed ||= next !== entry; return next; }); @@ -31,7 +31,10 @@ function normalizeStrictOpenAIJsonSchemaRecursive(schema: unknown): unknown { let changed = false; const normalized: Record = {}; for (const [key, value] of Object.entries(record)) { - const next = normalizeStrictOpenAIJsonSchemaRecursive(value); + const next = normalizeStrictOpenAIJsonSchemaRecursive( + value, + key === "properties" ? depth : depth + 1, + ); normalized[key] = next; changed ||= next !== value; } @@ -47,6 +50,10 @@ function normalizeStrictOpenAIJsonSchemaRecursive(schema: unknown): unknown { normalized.required = []; changed = true; } + if (depth === 0 && !("additionalProperties" in normalized)) { + normalized.additionalProperties = false; + changed = true; + } } return changed ? normalized : schema; diff --git a/src/agents/openai-transport-stream.test.ts b/src/agents/openai-transport-stream.test.ts index 4e9471cc300..9cc6341a5f8 100644 --- a/src/agents/openai-transport-stream.test.ts +++ b/src/agents/openai-transport-stream.test.ts @@ -1718,7 +1718,12 @@ describe("openai transport stream", () => { { name: "read", description: "Read file", - parameters: { type: "object", properties: {} }, + parameters: { + type: "object", + additionalProperties: false, + properties: { path: { type: "string" } }, + required: [], + }, }, ], } as never, diff --git a/src/agents/openclaw-owned-tool-runtime-contract.test.ts b/src/agents/openclaw-owned-tool-runtime-contract.test.ts new file mode 100644 index 00000000000..9c54586fbab --- /dev/null +++ b/src/agents/openclaw-owned-tool-runtime-contract.test.ts @@ -0,0 +1,390 @@ +import type { AgentTool } from "@mariozechner/pi-agent-core"; +import type { ExtensionContext } from "@mariozechner/pi-coding-agent"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + installOpenClawOwnedToolHooks, + resetOpenClawOwnedToolHooks, + textToolResult, +} from "../../test/helpers/agents/openclaw-owned-tool-runtime-contract.js"; +import type { MessagingToolSend } from "./pi-embedded-messaging.types.js"; +import { + handleToolExecutionEnd, + handleToolExecutionStart, +} from "./pi-embedded-subscribe.handlers.tools.js"; +import type { + ToolCallSummary, + ToolHandlerContext, +} from "./pi-embedded-subscribe.handlers.types.js"; +import { toToolDefinitions } from "./pi-tool-definition-adapter.js"; +import { createBaseToolHandlerState } from "./pi-tool-handler-state.test-helpers.js"; +import { wrapToolWithBeforeToolCallHook } from "./pi-tools.before-tool-call.js"; + +function createContractTool(name: string, execute: AgentTool["execute"]): AgentTool { + return { + name, + label: name, + description: `contract tool: ${name}`, + parameters: { type: "object", properties: {} }, + execute, + } as AgentTool; +} + +type ToolExecutionStartEvent = Parameters[1]; +type ToolExecutionEndEvent = Parameters[1]; + +function createToolHandlerCtx(): ToolHandlerContext { + return { + params: { + runId: "run-contract", + agentId: "agent-1", + sessionId: "session-1", + sessionKey: "agent:agent-1:session-1", + }, + state: { + ...createBaseToolHandlerState(), + toolMetaById: new Map(), + pendingMessagingTargets: new Map(), + messagingToolSentTargets: [] as MessagingToolSend[], + successfulCronAdds: 0, + }, + log: { debug: vi.fn(), warn: vi.fn() }, + flushBlockReplyBuffer: vi.fn(), + shouldEmitToolResult: () => false, + shouldEmitToolOutput: () => false, + emitToolSummary: vi.fn(), + emitToolOutput: vi.fn(), + trimMessagingToolSent: vi.fn(), + }; +} + +function toolExecutionStartEvent(params: { + toolName: string; + toolCallId: string; + args: unknown; +}): ToolExecutionStartEvent { + return { + type: "tool_execution_start", + toolName: params.toolName, + toolCallId: params.toolCallId, + args: params.args, + } as ToolExecutionStartEvent; +} + +function toolExecutionEndEvent(params: { + toolName: string; + toolCallId: string; + isError: boolean; + result: unknown; +}): ToolExecutionEndEvent { + return { + type: "tool_execution_end", + toolName: params.toolName, + toolCallId: params.toolCallId, + isError: params.isError, + result: params.result, + } as ToolExecutionEndEvent; +} + +function createToolExtensionContext(): ExtensionContext { + return {} as ExtensionContext; +} + +describe("OpenClaw-owned tool runtime contract — Pi adapter", () => { + afterEach(() => { + resetOpenClawOwnedToolHooks(); + }); + + it("preserves partially adjusted before_tool_call params through execution and after_tool_call", async () => { + const adjustedParams = { mode: "safe" }; + const mergedParams = { command: "pwd", mode: "safe" }; + const hooks = installOpenClawOwnedToolHooks({ adjustedParams }); + const execute = vi.fn(async () => textToolResult("done", { ok: true })); + const tool = wrapToolWithBeforeToolCallHook(createContractTool("exec", execute), { + agentId: "agent-1", + sessionId: "session-1", + sessionKey: "agent:agent-1:session-1", + runId: "run-contract", + }); + const definition = toToolDefinitions([tool])[0]; + if (!definition) { + throw new Error("missing Pi tool definition"); + } + const ctx = createToolHandlerCtx(); + const toolCallId = "call-contract"; + const originalParams = { command: "pwd" }; + + await handleToolExecutionStart( + ctx, + toolExecutionStartEvent({ + toolName: "exec", + toolCallId, + args: originalParams, + }), + ); + const result = await definition.execute( + toolCallId, + originalParams, + undefined, + undefined, + createToolExtensionContext(), + ); + await handleToolExecutionEnd( + ctx, + toolExecutionEndEvent({ + toolName: "exec", + toolCallId, + isError: false, + result, + }), + ); + + expect(hooks.beforeToolCall).toHaveBeenCalledTimes(1); + expect(execute).toHaveBeenCalledWith(toolCallId, mergedParams, undefined, undefined); + await vi.waitFor(() => { + expect(hooks.afterToolCall).toHaveBeenCalledWith( + expect.objectContaining({ + toolName: "exec", + toolCallId, + params: mergedParams, + result: expect.objectContaining({ + content: [{ type: "text", text: "done" }], + details: { ok: true }, + }), + }), + expect.objectContaining({ + agentId: "agent-1", + sessionId: "session-1", + sessionKey: "agent:agent-1:session-1", + runId: "run-contract", + toolCallId, + }), + ); + }); + }); + + it("reports Pi dynamic tool execution errors through after_tool_call", async () => { + const adjustedParams = { timeoutSec: 1 }; + const mergedParams = { command: "false", timeoutSec: 1 }; + const hooks = installOpenClawOwnedToolHooks({ adjustedParams }); + const execute = vi.fn(async () => { + throw new Error("tool failed"); + }); + const tool = wrapToolWithBeforeToolCallHook(createContractTool("exec", execute), { + agentId: "agent-1", + sessionId: "session-1", + sessionKey: "agent:agent-1:session-1", + runId: "run-error", + }); + const definition = toToolDefinitions([tool])[0]; + if (!definition) { + throw new Error("missing Pi tool definition"); + } + const ctx = createToolHandlerCtx(); + ctx.params.runId = "run-error"; + const toolCallId = "call-error"; + const originalParams = { command: "false" }; + + await handleToolExecutionStart( + ctx, + toolExecutionStartEvent({ + toolName: "exec", + toolCallId, + args: originalParams, + }), + ); + const result = await definition.execute( + toolCallId, + originalParams, + undefined, + undefined, + createToolExtensionContext(), + ); + expect(result).toEqual( + expect.objectContaining({ + details: expect.objectContaining({ + status: "error", + error: "tool failed", + }), + }), + ); + await handleToolExecutionEnd( + ctx, + toolExecutionEndEvent({ + toolName: "exec", + toolCallId, + isError: true, + result, + }), + ); + + expect(hooks.beforeToolCall).toHaveBeenCalledTimes(1); + expect(execute).toHaveBeenCalledWith(toolCallId, mergedParams, undefined, undefined); + await vi.waitFor(() => { + expect(hooks.afterToolCall).toHaveBeenCalledWith( + expect.objectContaining({ + toolName: "exec", + toolCallId, + params: mergedParams, + error: "tool failed", + }), + expect.objectContaining({ + runId: "run-error", + toolCallId, + }), + ); + }); + }); + + it("commits successful Pi messaging text, media, and target telemetry", async () => { + const hooks = installOpenClawOwnedToolHooks(); + const execute = vi.fn(async () => textToolResult("sent")); + const tool = wrapToolWithBeforeToolCallHook(createContractTool("message", execute), { + agentId: "agent-1", + sessionId: "session-1", + sessionKey: "agent:agent-1:session-1", + runId: "run-message", + }); + const definition = toToolDefinitions([tool])[0]; + if (!definition) { + throw new Error("missing Pi tool definition"); + } + const ctx = createToolHandlerCtx(); + ctx.params.runId = "run-message"; + const toolCallId = "call-message"; + const originalParams = { + action: "send", + content: "hello from Pi", + mediaUrl: "/tmp/pi-reply.png", + provider: "telegram", + to: "chat-1", + }; + + await handleToolExecutionStart( + ctx, + toolExecutionStartEvent({ + toolName: "message", + toolCallId, + args: originalParams, + }), + ); + const result = await definition.execute( + toolCallId, + originalParams, + undefined, + undefined, + createToolExtensionContext(), + ); + await handleToolExecutionEnd( + ctx, + toolExecutionEndEvent({ + toolName: "message", + toolCallId, + isError: false, + result, + }), + ); + + expect(ctx.state.messagingToolSentTexts).toEqual(["hello from Pi"]); + expect(ctx.state.messagingToolSentMediaUrls).toEqual(["/tmp/pi-reply.png"]); + expect(ctx.state.messagingToolSentTargets).toEqual([ + expect.objectContaining({ + tool: "message", + provider: "telegram", + to: "chat-1", + }), + ]); + await vi.waitFor(() => { + expect(hooks.afterToolCall).toHaveBeenCalledWith( + expect.objectContaining({ + toolName: "message", + toolCallId, + params: originalParams, + result: expect.objectContaining({ + content: [{ type: "text", text: "sent" }], + }), + }), + expect.objectContaining({ + runId: "run-message", + toolCallId, + }), + ); + }); + }); + + it("fails closed when before_tool_call blocks a Pi dynamic tool", async () => { + const hooks = installOpenClawOwnedToolHooks({ blockReason: "blocked by policy" }); + const execute = vi.fn(async () => textToolResult("should not run")); + const tool = wrapToolWithBeforeToolCallHook(createContractTool("message", execute), { + agentId: "agent-1", + sessionId: "session-1", + sessionKey: "agent:agent-1:session-1", + runId: "run-blocked", + }); + const definition = toToolDefinitions([tool])[0]; + if (!definition) { + throw new Error("missing Pi tool definition"); + } + const ctx = createToolHandlerCtx(); + ctx.params.runId = "run-blocked"; + const toolCallId = "call-blocked"; + const originalParams = { + action: "send", + text: "blocked", + provider: "telegram", + to: "chat-1", + }; + + await handleToolExecutionStart( + ctx, + toolExecutionStartEvent({ + toolName: "message", + toolCallId, + args: originalParams, + }), + ); + const result = await definition.execute( + toolCallId, + originalParams, + undefined, + undefined, + createToolExtensionContext(), + ); + expect(result).toEqual( + expect.objectContaining({ + details: expect.objectContaining({ + status: "error", + error: "blocked by policy", + }), + }), + ); + await handleToolExecutionEnd( + ctx, + toolExecutionEndEvent({ + toolName: "message", + toolCallId, + isError: true, + result, + }), + ); + + expect(hooks.beforeToolCall).toHaveBeenCalledTimes(1); + expect(execute).not.toHaveBeenCalled(); + await vi.waitFor(() => { + expect(hooks.afterToolCall).toHaveBeenCalledWith( + expect.objectContaining({ + toolName: "message", + toolCallId, + params: originalParams, + error: "blocked by policy", + }), + expect.objectContaining({ + agentId: "agent-1", + sessionId: "session-1", + sessionKey: "agent:agent-1:session-1", + runId: "run-blocked", + toolCallId, + }), + ); + }); + }); +}); diff --git a/src/agents/outcome-fallback-runtime-contract.test.ts b/src/agents/outcome-fallback-runtime-contract.test.ts new file mode 100644 index 00000000000..69d10884e48 --- /dev/null +++ b/src/agents/outcome-fallback-runtime-contract.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it, vi } from "vitest"; +import { + createContractFallbackConfig, + createContractRunResult, + OUTCOME_FALLBACK_RUNTIME_CONTRACT, +} from "../../test/helpers/agents/outcome-fallback-runtime-contract.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { runWithModelFallback } from "./model-fallback.js"; +import { classifyEmbeddedPiRunResultForModelFallback } from "./pi-embedded-runner/result-fallback-classifier.js"; + +vi.mock("./auth-profiles/source-check.js", () => ({ + hasAnyAuthProfileStoreSource: () => false, +})); + +describe("Outcome/fallback runtime contract - Pi fallback classifier", () => { + it.each([ + ["empty", "empty_result"], + ["reasoning-only", "reasoning_only_result"], + ["planning-only", "planning_only_result"], + ] as const)( + "maps harness classification %s to a format fallback code", + (classification, code) => { + expect( + classifyEmbeddedPiRunResultForModelFallback({ + provider: OUTCOME_FALLBACK_RUNTIME_CONTRACT.primaryProvider, + model: OUTCOME_FALLBACK_RUNTIME_CONTRACT.primaryModel, + result: createContractRunResult({ + meta: { + durationMs: 1, + agentHarnessResultClassification: classification, + }, + }), + }), + ).toMatchObject({ + reason: "format", + code, + }); + }, + ); + + it.each([ + ["empty", "empty_result"], + ["reasoning-only", "reasoning_only_result"], + ["planning-only", "planning_only_result"], + ] as const)( + "advances to the configured fallback after a classified GPT-5 %s terminal result", + async (classification, code) => { + const primary = createContractRunResult({ + meta: { + durationMs: 1, + agentHarnessResultClassification: classification, + }, + }); + const fallback = createContractRunResult({ + payloads: [{ text: "fallback ok" }], + meta: { durationMs: 1, finalAssistantVisibleText: "fallback ok" }, + }); + const run = vi.fn().mockResolvedValueOnce(primary).mockResolvedValueOnce(fallback); + + const result = await runWithModelFallback({ + cfg: createContractFallbackConfig() as unknown as OpenClawConfig, + provider: OUTCOME_FALLBACK_RUNTIME_CONTRACT.primaryProvider, + model: OUTCOME_FALLBACK_RUNTIME_CONTRACT.primaryModel, + run, + classifyResult: ({ provider, model, result }) => + classifyEmbeddedPiRunResultForModelFallback({ + provider, + model, + result, + }), + }); + + expect(result.result).toBe(fallback); + expect(run).toHaveBeenCalledTimes(2); + expect(run.mock.calls[1]).toEqual([ + OUTCOME_FALLBACK_RUNTIME_CONTRACT.fallbackProvider, + OUTCOME_FALLBACK_RUNTIME_CONTRACT.fallbackModel, + ]); + expect(result.attempts[0]).toMatchObject({ + provider: OUTCOME_FALLBACK_RUNTIME_CONTRACT.primaryProvider, + model: OUTCOME_FALLBACK_RUNTIME_CONTRACT.primaryModel, + reason: "format", + code, + }); + }, + ); + + it.each([ + { + name: "intentional NO_REPLY", + result: createContractRunResult({ + meta: { durationMs: 1, finalAssistantRawText: "NO_REPLY" }, + }), + }, + { + name: "visible reply", + result: createContractRunResult({ + payloads: [{ text: "visible answer" }], + meta: { durationMs: 1 }, + }), + }, + { + name: "abort", + result: createContractRunResult({ + meta: { durationMs: 1, aborted: true, agentHarnessResultClassification: "empty" }, + }), + }, + { + name: "tool summary side effect", + result: createContractRunResult({ + meta: { durationMs: 1, toolSummary: { calls: 1, tools: ["message"] } }, + }), + }, + { + name: "messaging text side effect", + result: createContractRunResult({ + messagingToolSentTexts: ["sent out of band"], + meta: { durationMs: 1, agentHarnessResultClassification: "empty" }, + }), + }, + { + name: "messaging media side effect", + result: createContractRunResult({ + messagingToolSentMediaUrls: ["https://example.test/image.png"], + meta: { durationMs: 1, agentHarnessResultClassification: "empty" }, + }), + }, + { + name: "messaging target side effect", + result: createContractRunResult({ + messagingToolSentTargets: [{ tool: "message", provider: "slack", to: "channel-1" }], + meta: { durationMs: 1, agentHarnessResultClassification: "empty" }, + }), + }, + { + name: "cron side effect", + result: createContractRunResult({ + successfulCronAdds: 1, + meta: { durationMs: 1, agentHarnessResultClassification: "empty" }, + }), + }, + { + name: "direct block reply", + result: createContractRunResult({ + meta: { durationMs: 1, agentHarnessResultClassification: "empty" }, + }), + hasDirectlySentBlockReply: true, + }, + { + name: "block reply pipeline output", + result: createContractRunResult({ + meta: { durationMs: 1, agentHarnessResultClassification: "empty" }, + }), + hasBlockReplyPipelineOutput: true, + }, + ])("does not fallback for $name", async (contractCase) => { + expect( + classifyEmbeddedPiRunResultForModelFallback({ + provider: OUTCOME_FALLBACK_RUNTIME_CONTRACT.primaryProvider, + model: OUTCOME_FALLBACK_RUNTIME_CONTRACT.primaryModel, + result: contractCase.result, + hasDirectlySentBlockReply: contractCase.hasDirectlySentBlockReply, + hasBlockReplyPipelineOutput: contractCase.hasBlockReplyPipelineOutput, + }), + ).toBeNull(); + + const run = vi.fn().mockResolvedValue(contractCase.result); + const result = await runWithModelFallback({ + cfg: createContractFallbackConfig() as unknown as OpenClawConfig, + provider: OUTCOME_FALLBACK_RUNTIME_CONTRACT.primaryProvider, + model: OUTCOME_FALLBACK_RUNTIME_CONTRACT.primaryModel, + run, + classifyResult: ({ provider, model, result }) => + classifyEmbeddedPiRunResultForModelFallback({ + provider, + model, + result, + hasDirectlySentBlockReply: contractCase.hasDirectlySentBlockReply, + hasBlockReplyPipelineOutput: contractCase.hasBlockReplyPipelineOutput, + }), + }); + + expect(result.result).toBe(contractCase.result); + expect(result.attempts).toEqual([]); + expect(run).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/agents/pi-embedded-runner/compact.hooks.harness.ts b/src/agents/pi-embedded-runner/compact.hooks.harness.ts index 8bae7f434aa..986784225ab 100644 --- a/src/agents/pi-embedded-runner/compact.hooks.harness.ts +++ b/src/agents/pi-embedded-runner/compact.hooks.harness.ts @@ -390,6 +390,7 @@ export async function loadCompactHooksHarness(): Promise<{ vi.doMock("./extra-params.js", () => ({ applyExtraParamsToAgent: applyExtraParamsToAgentMock, resolveAgentTransportOverride: resolveAgentTransportOverrideMock, + resolvePreparedExtraParams: vi.fn(() => ({})), })); vi.doMock("./tool-split.js", () => ({ diff --git a/src/agents/pi-embedded-runner/compact.hooks.test.ts b/src/agents/pi-embedded-runner/compact.hooks.test.ts index 0d8e5e5b2bd..fa4e0fcd57a 100644 --- a/src/agents/pi-embedded-runner/compact.hooks.test.ts +++ b/src/agents/pi-embedded-runner/compact.hooks.test.ts @@ -282,6 +282,8 @@ describe("compactEmbeddedPiSessionDirect hooks", () => { api: "responses", }), "/tmp/workspace", + undefined, + undefined, ); }); diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index fa7f1212899..09fa3233b39 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -26,7 +26,6 @@ import { extractModelCompat } from "../../plugins/provider-model-compat.js"; import type { ProviderRuntimeModel } from "../../plugins/provider-runtime-model.types.js"; import { prepareProviderRuntimeAuth, - resolveProviderSystemPromptContribution, resolveProviderTextTransforms, transformProviderSystemPrompt, } from "../../plugins/provider-runtime.js"; @@ -76,6 +75,8 @@ import { applyPiCompactionSettingsFromConfig } from "../pi-settings.js"; import { createOpenClawCodingTools } from "../pi-tools.js"; import { wrapStreamFnTextTransforms } from "../plugin-text-transforms.js"; import { registerProviderStreamForModel } from "../provider-stream.js"; +import { buildAgentRuntimePlan } from "../runtime-plan/build.js"; +import type { AgentRuntimePlan } from "../runtime-plan/types.js"; import { ensureRuntimePluginsLoaded } from "../runtime-plugins.js"; import { resolveSandboxContext } from "../sandbox.js"; import { repairSessionFileIfNeeded } from "../session-file-repair.js"; @@ -92,7 +93,6 @@ import { resolveSkillsPromptForRun, } from "../skills.js"; import { resolveSystemPromptOverride } from "../system-prompt-override.js"; -import { resolveTranscriptPolicy } from "../transcript-policy.js"; import { classifyCompactionReason, resolveCompactionFailureReason } from "./compact-reasons.js"; import type { CompactEmbeddedPiSessionParams, CompactionMessageMetrics } from "./compact.types.js"; import { @@ -138,10 +138,6 @@ import { collectRegisteredToolNames, toSessionToolAllowlist, } from "./tool-name-allowlist.js"; -import { - logProviderToolSchemaDiagnostics, - normalizeProviderToolSchemas, -} from "./tool-schema-runtime.js"; import { splitSdkTools } from "./tool-split.js"; import type { EmbeddedPiCompactResult } from "./types.js"; import { mapThinkingLevel } from "./utils.js"; @@ -177,6 +173,7 @@ function prepareCompactionSessionAgent(params: { sessionAgentId: string; effectiveWorkspace: string; agentDir: string; + runtimePlan?: AgentRuntimePlan; }) { params.session.agent.streamFn = resolveEmbeddedAgentStreamFn({ currentStreamFn: resolveEmbeddedAgentBaseStreamFn({ session: params.session as never }), @@ -202,6 +199,12 @@ function prepareCompactionSessionAgent(params: { transformSystemPrompt: false, }) as never; } + const preparedRuntimeExtraParams = params.runtimePlan?.transport.resolveExtraParams({ + thinkingLevel: params.thinkLevel, + agentId: params.sessionAgentId, + workspaceDir: params.effectiveWorkspace, + model: params.effectiveModel, + }); return applyExtraParamsToAgent( params.session.agent as never, params.config, @@ -213,6 +216,8 @@ function prepareCompactionSessionAgent(params: { params.effectiveWorkspace, params.effectiveModel, params.agentDir, + undefined, + preparedRuntimeExtraParams ? { preparedExtraParams: preparedRuntimeExtraParams } : undefined, ); } @@ -501,6 +506,23 @@ export async function compactEmbeddedPiSessionDirect( hasRuntimeAuthExchange ? null : apiKeyInfo, params.config, ); + const runtimePlan = + params.runtimePlan ?? + buildAgentRuntimePlan({ + provider, + modelId, + model: effectiveModel, + modelApi: effectiveModel.api, + harnessId: params.agentHarnessId, + harnessRuntime: params.agentHarnessId, + authProfileProvider: authProfileId?.split(":", 1)[0], + sessionAuthProfileId: authProfileId, + config: params.config, + workspaceDir: effectiveWorkspace, + agentDir, + agentId: effectiveSkillAgentId, + thinkingLevel: thinkLevel, + }); const runAbortController = new AbortController(); const toolsRaw = createOpenClawCodingTools({ @@ -535,16 +557,15 @@ export async function compactEmbeddedPiSessionDirect( modelAuthMode: resolveModelAuthMode(model.provider, params.config), }); const toolsEnabled = supportsModelTools(runtimeModel); - const tools = normalizeProviderToolSchemas({ - tools: toolsEnabled ? toolsRaw : [], - provider, - config: params.config, + const runtimePlanModelContext = { workspaceDir: effectiveWorkspace, - env: process.env, - modelId, modelApi: model.api, model, - }); + }; + const tools = runtimePlan.tools.normalize( + toolsEnabled ? toolsRaw : [], + runtimePlanModelContext, + ); const bundleMcpRuntime = toolsEnabled ? await createBundleMcpToolRuntime({ workspaceDir: effectiveWorkspace, @@ -590,16 +611,7 @@ export async function compactEmbeddedPiSessionDirect( }); const effectiveTools = [...tools, ...filteredBundledTools]; const allowedToolNames = collectAllowedToolNames({ tools: effectiveTools }); - logProviderToolSchemaDiagnostics({ - tools: effectiveTools, - provider, - config: params.config, - workspaceDir: effectiveWorkspace, - env: process.env, - modelId, - modelApi: model.api, - model, - }); + runtimePlan.tools.logDiagnostics(effectiveTools, runtimePlanModelContext); const machineName = await getMachineDisplayName(); const runtimeChannel = normalizeMessageChannel(params.messageChannel ?? params.messageProvider); let runtimeCapabilities = runtimeChannel @@ -704,22 +716,21 @@ export async function compactEmbeddedPiSessionDirect( }); const ttsHint = params.config ? buildTtsSystemPromptHint(params.config) : undefined; const ownerDisplay = resolveOwnerDisplaySetting(params.config); - const promptContribution = resolveProviderSystemPromptContribution({ - provider, + const promptContributionContext: Parameters< + AgentRuntimePlan["prompt"]["resolveSystemPromptContribution"] + >[0] = { config: params.config, + agentDir, workspaceDir: effectiveWorkspace, - context: { - config: params.config, - agentDir, - workspaceDir: effectiveWorkspace, - provider, - modelId, - promptMode, - runtimeChannel, - runtimeCapabilities, - agentId: sessionAgentId, - }, - }); + provider, + modelId, + promptMode, + runtimeChannel, + runtimeCapabilities, + agentId: sessionAgentId, + }; + const promptContribution = + runtimePlan.prompt.resolveSystemPromptContribution(promptContributionContext); const buildSystemPromptOverride = (defaultThinkLevel: ThinkLevel) => { const builtSystemPrompt = resolveSystemPromptOverride({ @@ -792,15 +803,7 @@ export async function compactEmbeddedPiSessionDirect( warn: (message) => log.warn(message), }); await prewarmSessionFile(params.sessionFile); - const transcriptPolicy = resolveTranscriptPolicy({ - modelApi: model.api, - provider, - modelId, - config: params.config, - workspaceDir: effectiveWorkspace, - env: process.env, - model, - }); + const transcriptPolicy = runtimePlan.transcript.resolvePolicy(runtimePlanModelContext); const sessionManager = guardSessionManager(SessionManager.open(params.sessionFile), { agentId: sessionAgentId, sessionKey: params.sessionKey, @@ -917,6 +920,7 @@ export async function compactEmbeddedPiSessionDirect( sessionAgentId, effectiveWorkspace, agentDir, + runtimePlan, }); const prior = await sanitizeSessionHistory({ diff --git a/src/agents/pi-embedded-runner/compact.types.ts b/src/agents/pi-embedded-runner/compact.types.ts index 5ff35b6cd24..3503be3eb54 100644 --- a/src/agents/pi-embedded-runner/compact.types.ts +++ b/src/agents/pi-embedded-runner/compact.types.ts @@ -3,6 +3,7 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { ContextEngine, ContextEngineRuntimeContext } from "../../context-engine/types.js"; import type { CommandQueueEnqueueFn } from "../../process/command-queue.types.js"; import type { ExecElevatedDefaults } from "../bash-tools.exec-types.js"; +import type { AgentRuntimePlan } from "../runtime-plan/types.js"; import type { SkillSnapshot } from "../skills.js"; export type CompactEmbeddedPiSessionParams = { @@ -50,6 +51,8 @@ export type CompactEmbeddedPiSessionParams = { contextEngineRuntimeContext?: ContextEngineRuntimeContext; /** Session-pinned embedded harness id. Prevents compaction hot-switching. */ agentHarnessId?: string; + /** OpenClaw-owned runtime policy prepared for this compaction path. */ + runtimePlan?: AgentRuntimePlan; thinkLevel?: ThinkLevel; reasoningLevel?: ReasoningLevel; bashElevated?: ExecElevatedDefaults; diff --git a/src/agents/pi-embedded-runner/extra-params.ts b/src/agents/pi-embedded-runner/extra-params.ts index 7b1f0d35dd1..cd2e5694772 100644 --- a/src/agents/pi-embedded-runner/extra-params.ts +++ b/src/agents/pi-embedded-runner/extra-params.ts @@ -494,6 +494,7 @@ export function applyExtraParamsToAgent( model?: ProviderRuntimeModel, agentDir?: string, resolvedTransport?: SupportedTransport, + options?: { preparedExtraParams?: Record }, ): { effectiveExtraParams: Record } { const resolvedExtraParams = resolveExtraParams({ cfg, @@ -507,19 +508,21 @@ export function applyExtraParamsToAgent( Object.entries(extraParamsOverride).filter(([, value]) => value !== undefined), ) : undefined; - const effectiveExtraParams = resolvePreparedExtraParams({ - cfg, - provider, - modelId, - extraParamsOverride, - thinkingLevel, - agentId, - agentDir, - workspaceDir, - resolvedExtraParams, - model, - resolvedTransport, - }); + const effectiveExtraParams = + options?.preparedExtraParams ?? + resolvePreparedExtraParams({ + cfg, + provider, + modelId, + extraParamsOverride, + thinkingLevel, + agentId, + agentDir, + workspaceDir, + resolvedExtraParams, + model, + resolvedTransport, + }); const wrapperContext: ApplyExtraParamsContext = { agent, cfg, diff --git a/src/agents/pi-embedded-runner/run.attempt-param-forwarding.test.ts b/src/agents/pi-embedded-runner/run.attempt-param-forwarding.test.ts index dcfcb1fa26f..515656c5f14 100644 --- a/src/agents/pi-embedded-runner/run.attempt-param-forwarding.test.ts +++ b/src/agents/pi-embedded-runner/run.attempt-param-forwarding.test.ts @@ -97,4 +97,89 @@ describe("runEmbeddedPiAgent forwards optional params to runEmbeddedAttempt", () expect(mockedGetApiKeyForModel).not.toHaveBeenCalled(); expect(pluginRunAttempt).toHaveBeenCalledWith(expect.objectContaining({ provider: "codex" })); }); + + it("forwards explicit OpenAI Codex auth profiles to codex plugin harnesses", async () => { + const { clearAgentHarnesses, registerAgentHarness } = await import("../harness/registry.js"); + const pluginRunAttempt = vi.fn(async () => makeAttemptResult({ assistantTexts: ["ok"] })); + clearAgentHarnesses(); + registerAgentHarness({ + id: "codex", + label: "Codex", + supports: (ctx) => + ctx.provider === "codex" ? { supported: true, priority: 100 } : { supported: false }, + runAttempt: pluginRunAttempt, + }); + mockedGetApiKeyForModel.mockRejectedValueOnce(new Error("generic auth should be skipped")); + + try { + await runEmbeddedPiAgent({ + ...overflowBaseRunParams, + provider: "codex", + model: "gpt-5.4", + config: { + agents: { + defaults: { + embeddedHarness: { runtime: "codex", fallback: "none" }, + }, + }, + }, + authProfileId: "openai-codex:work", + authProfileIdSource: "user", + runId: "plugin-harness-forwards-openai-codex-auth", + }); + } finally { + clearAgentHarnesses(); + } + + expect(mockedGetApiKeyForModel).not.toHaveBeenCalled(); + expect(pluginRunAttempt).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "codex", + authProfileId: "openai-codex:work", + authProfileIdSource: "user", + }), + ); + }); + + it("forwards OpenAI Codex auth profiles when openai/* is forced through codex", async () => { + const { clearAgentHarnesses, registerAgentHarness } = await import("../harness/registry.js"); + const pluginRunAttempt = vi.fn(async () => makeAttemptResult({ assistantTexts: ["ok"] })); + clearAgentHarnesses(); + registerAgentHarness({ + id: "codex", + label: "Codex", + supports: () => ({ supported: false }), + runAttempt: pluginRunAttempt, + }); + mockedGetApiKeyForModel.mockRejectedValueOnce(new Error("generic auth should be skipped")); + + try { + await runEmbeddedPiAgent({ + ...overflowBaseRunParams, + provider: "openai", + model: "gpt-5.4", + config: { + agents: { + defaults: { + embeddedHarness: { runtime: "codex", fallback: "none" }, + }, + }, + }, + authProfileId: "openai-codex:work", + authProfileIdSource: "user", + runId: "forced-codex-harness-forwards-openai-codex-auth", + }); + } finally { + clearAgentHarnesses(); + } + + expect(mockedGetApiKeyForModel).not.toHaveBeenCalled(); + expect(pluginRunAttempt).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "openai", + authProfileId: "openai-codex:work", + authProfileIdSource: "user", + }), + ); + }); }); diff --git a/src/agents/pi-embedded-runner/run.incomplete-turn.test.ts b/src/agents/pi-embedded-runner/run.incomplete-turn.test.ts index d2611ae8587..0ddd2c0d0c1 100644 --- a/src/agents/pi-embedded-runner/run.incomplete-turn.test.ts +++ b/src/agents/pi-embedded-runner/run.incomplete-turn.test.ts @@ -62,9 +62,12 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => { const result = await runEmbeddedPiAgent({ ...overflowBaseRunParams, + provider: "openai", + model: "gpt-4.1", runId: "run-incomplete-turn-messaging-warning", }); + expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(1); expect(mockedClassifyFailoverReason).toHaveBeenCalledTimes(1); expect(result.payloads?.[0]?.isError).toBe(true); expect(result.payloads?.[0]?.text).toContain("verify before retrying"); diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index b2e71796121..8809d08093c 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -76,6 +76,8 @@ import { pickFallbackThinkingLevel, } from "../pi-embedded-helpers.js"; import { resolveProviderIdForAuth } from "../provider-auth-aliases.js"; +import { buildAgentRuntimeAuthPlan } from "../runtime-plan/auth.js"; +import { buildAgentRuntimePlan } from "../runtime-plan/build.js"; import { ensureRuntimePluginsLoaded } from "../runtime-plugins.js"; import { derivePromptTokens, normalizeUsage, type UsageLike } from "../usage.js"; import { redactRunIdentifier, resolveRunWorkspaceDir } from "../workspace-run.js"; @@ -415,22 +417,36 @@ export async function runEmbeddedPiAgent( const preferredProfileId = params.authProfileId?.trim(); let lockedProfileId = params.authProfileIdSource === "user" ? preferredProfileId : undefined; if (lockedProfileId) { - const lockedProfile = authStore.profiles[lockedProfileId]; - const lockedProfileProvider = lockedProfile - ? resolveProviderIdForAuth(lockedProfile.provider, { - config: params.config, - workspaceDir: resolvedWorkspace, - }) - : undefined; - const runProvider = resolveProviderIdForAuth(provider, { - config: params.config, - workspaceDir: resolvedWorkspace, - }); - if (!lockedProfile || !lockedProfileProvider || lockedProfileProvider !== runProvider) { - lockedProfileId = undefined; + if (pluginHarnessOwnsTransport) { + const runtimeAuthPlan = buildAgentRuntimeAuthPlan({ + provider, + authProfileProvider: lockedProfileId.split(":", 1)[0], + sessionAuthProfileId: lockedProfileId, + config: params.config, + workspaceDir: resolvedWorkspace, + harnessId: agentHarness.id, + }); + if (!runtimeAuthPlan.forwardedAuthProfileId) { + lockedProfileId = undefined; + } + } else { + const lockedProfile = authStore.profiles[lockedProfileId]; + const lockedProfileProvider = lockedProfile + ? resolveProviderIdForAuth(lockedProfile.provider, { + config: params.config, + workspaceDir: resolvedWorkspace, + }) + : undefined; + const runProvider = resolveProviderIdForAuth(provider, { + config: params.config, + workspaceDir: resolvedWorkspace, + }); + if (!lockedProfile || !lockedProfileProvider || lockedProfileProvider !== runProvider) { + lockedProfileId = undefined; + } } } - if (lockedProfileId) { + if (lockedProfileId && !pluginHarnessOwnsTransport) { const eligibility = resolveAuthProfileEligibility({ cfg: params.config, store: authStore, @@ -547,6 +563,8 @@ export async function runEmbeddedPiAgent( // vendor-token refresh attempts before the plugin gets control. if (!pluginHarnessOwnsTransport) { await initializeAuthProfile(); + } else if (lockedProfileId) { + lastProfileId = lockedProfileId; } const { sessionAgentId } = resolveSessionAgentIds({ sessionKey: params.sessionKey, @@ -791,6 +809,26 @@ export async function runEmbeddedPiAgent( if (!runtimeAuthState && apiKeyInfo) { resolvedStreamApiKey = (apiKeyInfo as ApiKeyInfo).apiKey; } + const runtimePlan = buildAgentRuntimePlan({ + provider, + modelId, + model: effectiveModel, + modelApi: effectiveModel.api, + harnessId: agentHarness.id, + harnessRuntime: agentHarness.id, + allowHarnessAuthProfileForwarding: pluginHarnessOwnsTransport, + authProfileProvider: lastProfileId?.split(":", 1)[0], + sessionAuthProfileId: lastProfileId, + config: params.config, + workspaceDir: resolvedWorkspace, + agentDir, + agentId: workspaceResolution.agentId, + thinkingLevel: thinkLevel, + extraParamsOverride: { + ...params.streamParams, + fastMode: params.fastMode, + }, + }); const attempt = await runEmbeddedAttemptWithBackend({ sessionId: params.sessionId, @@ -838,6 +876,7 @@ export async function runEmbeddedPiAgent( // attempt too. Otherwise plugin-owned transports can skip PI auth // bootstrap but drift back to PI when the attempt is created. agentHarnessId: agentHarness.id, + runtimePlan, model: applyAuthHeaderOverride( applyLocalNoAuthHeaderOverride(effectiveModel, apiKeyInfo), // When runtime auth exchange produced a different credential diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index aa6b16e4329..25fff63fadf 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -729,16 +729,23 @@ export async function runEmbeddedAttempt( let abortSessionForYield: (() => void) | null = null; let queueYieldInterruptForSession: (() => void) | null = null; let yieldAbortSettled: Promise | null = null; - const tools = normalizeProviderToolSchemas({ - tools: toolsEnabled ? toolsRaw : [], - provider: params.provider, - config: params.config, + const runtimePlanModelContext = { workspaceDir: effectiveWorkspace, - env: process.env, - modelId: params.modelId, modelApi: params.model.api, model: params.model, - }); + }; + const tools = + params.runtimePlan?.tools.normalize(toolsEnabled ? toolsRaw : [], runtimePlanModelContext) ?? + normalizeProviderToolSchemas({ + tools: toolsEnabled ? toolsRaw : [], + provider: params.provider, + config: params.config, + workspaceDir: effectiveWorkspace, + env: process.env, + modelId: params.modelId, + modelApi: params.model.api, + model: params.model, + }); const clientTools = toolsEnabled ? params.clientTools : undefined; const bundleMcpSessionRuntime = toolsEnabled ? await getOrCreateSessionMcpRuntime({ @@ -794,16 +801,20 @@ export async function runEmbeddedAttempt( tools: effectiveTools, clientTools, }); - logProviderToolSchemaDiagnostics({ - tools: effectiveTools, - provider: params.provider, - config: params.config, - workspaceDir: effectiveWorkspace, - env: process.env, - modelId: params.modelId, - modelApi: params.model.api, - model: params.model, - }); + if (params.runtimePlan) { + params.runtimePlan.tools.logDiagnostics(effectiveTools, runtimePlanModelContext); + } else { + logProviderToolSchemaDiagnostics({ + tools: effectiveTools, + provider: params.provider, + config: params.config, + workspaceDir: effectiveWorkspace, + env: process.env, + modelId: params.modelId, + modelApi: params.model.api, + model: params.model, + }); + } const machineName = await getMachineDisplayName(); const runtimeChannel = normalizeMessageChannel(params.messageChannel ?? params.messageProvider); @@ -929,22 +940,25 @@ export async function runEmbeddedAttempt( defaultAgentId, }) : undefined; - const promptContribution = resolveProviderSystemPromptContribution({ - provider: params.provider, + const promptContributionContext = { config: params.config, + agentDir: params.agentDir, workspaceDir: effectiveWorkspace, - context: { - config: params.config, - agentDir: params.agentDir, - workspaceDir: effectiveWorkspace, + provider: params.provider, + modelId: params.modelId, + promptMode: effectivePromptMode, + runtimeChannel, + runtimeCapabilities, + agentId: sessionAgentId, + }; + const promptContribution = + params.runtimePlan?.prompt.resolveSystemPromptContribution(promptContributionContext) ?? + resolveProviderSystemPromptContribution({ provider: params.provider, - modelId: params.modelId, - promptMode: effectivePromptMode, - runtimeChannel, - runtimeCapabilities, - agentId: sessionAgentId, - }, - }); + config: params.config, + workspaceDir: effectiveWorkspace, + context: promptContributionContext, + }); const builtAppendPrompt = resolveSystemPromptOverride({ @@ -1045,15 +1059,17 @@ export async function runEmbeddedAttempt( .then(() => true) .catch(() => false); - const transcriptPolicy = resolveTranscriptPolicy({ - modelApi: params.model?.api, - provider: params.provider, - modelId: params.modelId, - config: params.config, - workspaceDir: effectiveWorkspace, - env: process.env, - model: params.model, - }); + const transcriptPolicy = + params.runtimePlan?.transcript.resolvePolicy(runtimePlanModelContext) ?? + resolveTranscriptPolicy({ + modelApi: params.model?.api, + provider: params.provider, + modelId: params.modelId, + config: params.config, + workspaceDir: effectiveWorkspace, + env: process.env, + model: params.model, + }); await prewarmSessionFile(params.sessionFile); sessionManager = guardSessionManager(SessionManager.open(params.sessionFile), { @@ -1414,24 +1430,37 @@ export async function runEmbeddedAttempt( }); } + const resolvedTransport = resolveExplicitSettingsTransport({ + settingsManager, + sessionTransport: activeSession.agent.transport, + }); + const streamExtraParamsOverride = { + ...params.streamParams, + fastMode: params.fastMode, + }; + const preparedRuntimeExtraParams = params.runtimePlan?.transport.resolveExtraParams({ + extraParamsOverride: streamExtraParamsOverride, + thinkingLevel: params.thinkLevel, + agentId: sessionAgentId, + workspaceDir: effectiveWorkspace, + model: params.model, + resolvedTransport, + }); const { effectiveExtraParams } = applyExtraParamsToAgent( activeSession.agent, params.config, params.provider, params.modelId, - { - ...params.streamParams, - fastMode: params.fastMode, - }, + streamExtraParamsOverride, params.thinkLevel, sessionAgentId, effectiveWorkspace, params.model, agentDir, - resolveExplicitSettingsTransport({ - settingsManager, - sessionTransport: activeSession.agent.transport, - }), + resolvedTransport, + preparedRuntimeExtraParams + ? { preparedExtraParams: preparedRuntimeExtraParams } + : undefined, ); const effectivePromptCacheRetention = resolveCacheRetention( effectiveExtraParams, @@ -2762,7 +2791,12 @@ export async function runEmbeddedAttempt( sessionId: params.sessionId, provider: params.provider, model: params.modelId, - resolvedRef: `${params.provider}/${params.modelId}`, + resolvedRef: + params.runtimePlan?.observability.resolvedRef ?? + `${params.provider}/${params.modelId}`, + ...(params.runtimePlan?.observability.harnessId + ? { harnessId: params.runtimePlan.observability.harnessId } + : {}), assistantTexts, lastAssistant, usage: attemptUsage, diff --git a/src/agents/pi-embedded-runner/run/transcript-repair-runtime-contract.test.ts b/src/agents/pi-embedded-runner/run/transcript-repair-runtime-contract.test.ts new file mode 100644 index 00000000000..c7181da2b85 --- /dev/null +++ b/src/agents/pi-embedded-runner/run/transcript-repair-runtime-contract.test.ts @@ -0,0 +1,131 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + inlineDataUriOrphanLeaf, + QUEUED_USER_MESSAGE_MARKER, + structuredOrphanLeaf, + textOrphanLeaf, +} from "../../../../test/helpers/agents/transcript-repair-runtime-contract.js"; +import { mergeOrphanedTrailingUserPrompt } from "./attempt.prompt-helpers.js"; +import { + DEFAULT_MESSAGE_MERGE_STRATEGY_ID, + registerMessageMergeStrategyForTest, + resolveMessageMergeStrategy, +} from "./message-merge-strategy.js"; + +let restoreStrategy: (() => void) | undefined; + +afterEach(() => { + restoreStrategy?.(); + restoreStrategy = undefined; +}); + +describe("Pi transcript repair runtime contract", () => { + it("merges text orphan leaves into the next prompt with the queued marker", () => { + const result = mergeOrphanedTrailingUserPrompt({ + prompt: "newest inbound message", + trigger: "user", + leafMessage: textOrphanLeaf(), + }); + + expect(result).toEqual({ + merged: true, + removeLeaf: true, + prompt: `${QUEUED_USER_MESSAGE_MARKER}\nolder active-turn message\n\nnewest inbound message`, + }); + }); + + it("does not duplicate an orphan leaf that is already present in the next prompt", () => { + const result = mergeOrphanedTrailingUserPrompt({ + prompt: "summary\nolder active-turn message\nnewest inbound message", + trigger: "user", + leafMessage: textOrphanLeaf(), + }); + + expect(result).toEqual({ + merged: false, + removeLeaf: true, + prompt: "summary\nolder active-turn message\nnewest inbound message", + }); + }); + + it("preserves structured text and media references before removing the leaf", () => { + const result = mergeOrphanedTrailingUserPrompt({ + prompt: "newest inbound message", + trigger: "user", + leafMessage: structuredOrphanLeaf(), + }); + + expect(result).toEqual({ + merged: true, + removeLeaf: true, + prompt: + `${QUEUED_USER_MESSAGE_MARKER}\n` + + "please inspect this\n" + + "[image_url] https://example.test/cat.png\n" + + "[input_audio] https://example.test/cat.wav\n\n" + + "newest inbound message", + }); + }); + + it("summarizes inline data URI media instead of embedding payload bytes", () => { + const result = mergeOrphanedTrailingUserPrompt({ + prompt: "newest inbound message", + trigger: "user", + leafMessage: inlineDataUriOrphanLeaf(), + }); + + expect(result.merged).toBe(true); + expect(result.removeLeaf).toBe(true); + expect(result.prompt).toContain("please inspect this inline image"); + expect(result.prompt).toContain("[image_url] inline data URI (image/png, 4118 chars)"); + expect(result.prompt).not.toContain("data:"); + expect(result.prompt).not.toContain("data:image/png;base64,"); + expect(result.prompt).not.toContain("aaaa"); + }); + + it("exposes transcript repair through the active message merge strategy", () => { + const strategy = resolveMessageMergeStrategy(); + const result = strategy.mergeOrphanedTrailingUserPrompt({ + prompt: "newest inbound message", + trigger: "manual", + leafMessage: textOrphanLeaf("queued via strategy"), + }); + + expect(strategy.id).toBe("orphan-trailing-user-prompt"); + expect(result).toEqual({ + merged: true, + removeLeaf: true, + prompt: `${QUEUED_USER_MESSAGE_MARKER}\nqueued via strategy\n\nnewest inbound message`, + }); + }); + + it("allows the active transcript repair strategy to be replaced for adapter contracts", () => { + const mergeOrphanedTrailingUserPromptSpy = vi.fn((params: { prompt: string }) => ({ + prompt: `custom strategy: ${params.prompt}`, + merged: false, + removeLeaf: false, + })); + + restoreStrategy = registerMessageMergeStrategyForTest({ + id: DEFAULT_MESSAGE_MERGE_STRATEGY_ID, + mergeOrphanedTrailingUserPrompt: mergeOrphanedTrailingUserPromptSpy, + }); + + const result = resolveMessageMergeStrategy().mergeOrphanedTrailingUserPrompt({ + prompt: "newest inbound message", + trigger: "manual", + leafMessage: textOrphanLeaf("queued via custom strategy"), + }); + + expect(mergeOrphanedTrailingUserPromptSpy).toHaveBeenCalledWith({ + prompt: "newest inbound message", + trigger: "manual", + leafMessage: textOrphanLeaf("queued via custom strategy"), + }); + expect(result).toEqual({ + merged: false, + removeLeaf: false, + prompt: "custom strategy: newest inbound message", + }); + }); +}); diff --git a/src/agents/pi-embedded-runner/run/types.ts b/src/agents/pi-embedded-runner/run/types.ts index 43cd28eca78..29700bf7c73 100644 --- a/src/agents/pi-embedded-runner/run/types.ts +++ b/src/agents/pi-embedded-runner/run/types.ts @@ -7,6 +7,7 @@ import type { ContextEngine, ContextEnginePromptCacheInfo } from "../../../conte import type { DiagnosticTraceContext } from "../../../infra/diagnostic-trace-context.js"; import type { PluginHookBeforeAgentStartResult } from "../../../plugins/hook-before-agent-start.types.js"; import type { MessagingToolSend } from "../../pi-embedded-messaging.types.js"; +import type { AgentRuntimePlan } from "../../runtime-plan/types.js"; import type { ToolErrorSummary } from "../../tool-error-summary.js"; import type { NormalizedUsage } from "../../usage.js"; import type { EmbeddedRunReplayMetadata, EmbeddedRunReplayState } from "../replay-state.js"; @@ -35,6 +36,8 @@ export type EmbeddedRunAttemptParams = EmbeddedRunAttemptBase & { modelId: string; /** Session-pinned embedded harness id. Prevents runtime hot-switching. */ agentHarnessId?: string; + /** OpenClaw-owned runtime policy prepared by the orchestrator for this attempt. */ + runtimePlan?: AgentRuntimePlan; model: Model; authStorage: AuthStorage; modelRegistry: ModelRegistry; diff --git a/src/agents/prompt-overlay-runtime-contract.test.ts b/src/agents/prompt-overlay-runtime-contract.test.ts new file mode 100644 index 00000000000..28cbd597aca --- /dev/null +++ b/src/agents/prompt-overlay-runtime-contract.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { + GPT5_CONTRACT_MODEL_ID, + GPT5_PREFIXED_CONTRACT_MODEL_ID, + NON_GPT5_CONTRACT_MODEL_ID, + NON_OPENAI_CONTRACT_PROVIDER_ID, + CODEX_CONTRACT_PROVIDER_ID, + OPENAI_CODEX_CONTRACT_PROVIDER_ID, + OPENAI_CONTRACT_PROVIDER_ID, + openAiPluginPersonalityConfig, + sharedGpt5PersonalityConfig, +} from "../../test/helpers/agents/prompt-overlay-runtime-contract.js"; +import { resolveGpt5SystemPromptContribution } from "./gpt5-prompt-overlay.js"; + +describe("GPT-5 prompt overlay runtime contract", () => { + it("adds the behavior contract and friendly style to OpenAI-family GPT-5 models by default", () => { + const contribution = resolveGpt5SystemPromptContribution({ + providerId: OPENAI_CONTRACT_PROVIDER_ID, + modelId: GPT5_CONTRACT_MODEL_ID, + }); + + expect(contribution?.stablePrefix).toContain(""); + expect(contribution?.sectionOverrides?.interaction_style).toContain( + "This is a live chat, not a memo.", + ); + }); + + it("lets the shared GPT-5 overlay config disable friendly style without removing the behavior contract", () => { + const contribution = resolveGpt5SystemPromptContribution({ + providerId: NON_OPENAI_CONTRACT_PROVIDER_ID, + modelId: GPT5_PREFIXED_CONTRACT_MODEL_ID, + config: sharedGpt5PersonalityConfig("off"), + }); + + expect(contribution?.stablePrefix).toContain(""); + expect(contribution?.sectionOverrides).toEqual({}); + }); + + it("scopes OpenAI plugin personality fallback to OpenAI-family GPT-5 providers", () => { + const openAiContribution = resolveGpt5SystemPromptContribution({ + providerId: OPENAI_CODEX_CONTRACT_PROVIDER_ID, + modelId: GPT5_CONTRACT_MODEL_ID, + config: openAiPluginPersonalityConfig("off"), + }); + const nonOpenAiContribution = resolveGpt5SystemPromptContribution({ + providerId: NON_OPENAI_CONTRACT_PROVIDER_ID, + modelId: GPT5_PREFIXED_CONTRACT_MODEL_ID, + config: openAiPluginPersonalityConfig("off"), + }); + + expect(openAiContribution?.stablePrefix).toContain(""); + expect(openAiContribution?.sectionOverrides).toEqual({}); + expect(nonOpenAiContribution?.stablePrefix).toContain(""); + expect(nonOpenAiContribution?.sectionOverrides?.interaction_style).toContain( + "This is a live chat, not a memo.", + ); + }); + + it("keeps Codex virtual providers in the OpenAI-family personality fallback scope", () => { + const contribution = resolveGpt5SystemPromptContribution({ + providerId: CODEX_CONTRACT_PROVIDER_ID, + modelId: GPT5_CONTRACT_MODEL_ID, + config: openAiPluginPersonalityConfig("off"), + }); + + expect(contribution?.stablePrefix).toContain(""); + expect(contribution?.sectionOverrides).toEqual({}); + }); + + it("does not apply GPT-5 overlays to non-GPT-5 models", () => { + expect( + resolveGpt5SystemPromptContribution({ + providerId: OPENAI_CONTRACT_PROVIDER_ID, + modelId: NON_GPT5_CONTRACT_MODEL_ID, + }), + ).toBeUndefined(); + }); +}); diff --git a/src/agents/runtime-plan/auth.ts b/src/agents/runtime-plan/auth.ts new file mode 100644 index 00000000000..b34607086a0 --- /dev/null +++ b/src/agents/runtime-plan/auth.ts @@ -0,0 +1,53 @@ +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { normalizeEmbeddedAgentRuntime } from "../pi-embedded-runner/runtime.js"; +import { resolveProviderIdForAuth } from "../provider-auth-aliases.js"; +import type { AgentRuntimeAuthPlan } from "./types.js"; + +const CODEX_HARNESS_AUTH_PROVIDER = "openai-codex"; + +function resolveHarnessAuthProvider(params: { + harnessId?: string; + harnessRuntime?: string; +}): string | undefined { + const harnessId = normalizeEmbeddedAgentRuntime(params.harnessId); + const runtime = normalizeEmbeddedAgentRuntime(params.harnessRuntime); + return harnessId === "codex" || runtime === "codex" ? CODEX_HARNESS_AUTH_PROVIDER : undefined; +} + +export function buildAgentRuntimeAuthPlan(params: { + provider: string; + authProfileProvider?: string; + sessionAuthProfileId?: string; + config?: OpenClawConfig; + workspaceDir?: string; + harnessId?: string; + harnessRuntime?: string; + allowHarnessAuthProfileForwarding?: boolean; +}): AgentRuntimeAuthPlan { + const aliasLookupParams = { + config: params.config, + workspaceDir: params.workspaceDir, + }; + const providerForAuth = resolveProviderIdForAuth(params.provider, aliasLookupParams); + const authProfileProviderForAuth = resolveProviderIdForAuth( + params.authProfileProvider ?? params.provider, + aliasLookupParams, + ); + const harnessAuthProvider = resolveHarnessAuthProvider(params); + const harnessProviderForAuth = harnessAuthProvider + ? resolveProviderIdForAuth(harnessAuthProvider, aliasLookupParams) + : undefined; + const harnessCanForwardProfile = + params.allowHarnessAuthProfileForwarding !== false && + harnessProviderForAuth && + harnessProviderForAuth === authProfileProviderForAuth; + const canForwardProfile = + providerForAuth === authProfileProviderForAuth || harnessCanForwardProfile; + + return { + providerForAuth, + authProfileProviderForAuth, + ...(harnessProviderForAuth ? { harnessAuthProvider: harnessProviderForAuth } : {}), + ...(canForwardProfile ? { forwardedAuthProfileId: params.sessionAuthProfileId } : {}), + }; +} diff --git a/src/agents/runtime-plan/build.test.ts b/src/agents/runtime-plan/build.test.ts new file mode 100644 index 00000000000..77ec3a18450 --- /dev/null +++ b/src/agents/runtime-plan/build.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "vitest"; +import { createParameterFreeTool } from "../../../test/helpers/agents/schema-normalization-runtime-contract.js"; +import { buildAgentRuntimePlan } from "./build.js"; + +describe("AgentRuntimePlan", () => { + it("records resolved model, auth, transport, tool, delivery, and observability policy", () => { + const plan = buildAgentRuntimePlan({ + provider: "openai", + modelId: "gpt-5.4", + modelApi: "openai-responses", + harnessId: "codex", + harnessRuntime: "codex", + authProfileProvider: "openai-codex", + sessionAuthProfileId: "openai-codex:work", + config: {}, + workspaceDir: "/tmp/openclaw-runtime-plan", + model: { + id: "gpt-5.4", + name: "GPT-5.4", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200_000, + maxTokens: 8_192, + }, + }); + + expect(plan.auth).toMatchObject({ + providerForAuth: "openai", + authProfileProviderForAuth: "openai-codex", + harnessAuthProvider: "openai-codex", + forwardedAuthProfileId: "openai-codex:work", + }); + expect(plan.delivery.isSilentPayload({ text: '{"action":"NO_REPLY"}' })).toBe(true); + expect( + plan.delivery.isSilentPayload({ + text: '{"action":"NO_REPLY"}', + mediaUrl: "file:///tmp/image.png", + }), + ).toBe(false); + expect(plan.transport.extraParams).toMatchObject({ + parallel_tool_calls: true, + text_verbosity: "low", + openaiWsWarmup: false, + }); + expect( + plan.transport.resolveExtraParams({ + extraParamsOverride: { parallel_tool_calls: false }, + resolvedTransport: "websocket", + }), + ).toMatchObject({ + parallel_tool_calls: false, + text_verbosity: "low", + openaiWsWarmup: false, + }); + expect( + plan.prompt.resolveSystemPromptContribution({ + provider: "openai", + modelId: "gpt-5.4", + promptMode: "full", + })?.stablePrefix, + ).toContain(""); + expect(plan.transcript.resolvePolicy()).toEqual(plan.transcript.policy); + expect( + plan.outcome.classifyRunResult({ + provider: "openai", + model: "gpt-4.1", + result: {}, + }), + ).toBeNull(); + expect(plan.observability.resolvedRef).toBe("openai/gpt-5.4"); + expect(plan.observability.harnessId).toBe("codex"); + }); + + it("keeps OpenClaw-owned tool-schema normalization reachable from the plan", () => { + const plan = buildAgentRuntimePlan({ + provider: "openai", + modelId: "gpt-5.4", + modelApi: "openai-responses", + config: {}, + workspaceDir: "/tmp/openclaw-runtime-plan", + model: { + id: "gpt-5.4", + name: "GPT-5.4", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200_000, + maxTokens: 8_192, + }, + }); + + const normalized = plan.tools.normalize([createParameterFreeTool()] as never); + + expect(normalized).toHaveLength(1); + expect(normalized[0]?.name).toBe("ping"); + expect(normalized[0]?.parameters).toBeTypeOf("object"); + }); +}); diff --git a/src/agents/runtime-plan/build.ts b/src/agents/runtime-plan/build.ts new file mode 100644 index 00000000000..9c1acc04114 --- /dev/null +++ b/src/agents/runtime-plan/build.ts @@ -0,0 +1,203 @@ +import type { AgentTool } from "@mariozechner/pi-agent-core"; +import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; +import type { TSchema } from "typebox"; +import { isSilentReplyPayloadText, SILENT_REPLY_TOKEN } from "../../auto-reply/tokens.js"; +import { + resolveProviderFollowupFallbackRoute, + resolveProviderSystemPromptContribution, +} from "../../plugins/provider-runtime.js"; +import { resolvePreparedExtraParams } from "../pi-embedded-runner/extra-params.js"; +import { classifyEmbeddedPiRunResultForModelFallback } from "../pi-embedded-runner/result-fallback-classifier.js"; +import { + logProviderToolSchemaDiagnostics, + normalizeProviderToolSchemas, +} from "../pi-embedded-runner/tool-schema-runtime.js"; +import { resolveTranscriptPolicy } from "../transcript-policy.js"; +import { buildAgentRuntimeAuthPlan } from "./auth.js"; +import type { + AgentRuntimeDeliveryPlan, + AgentRuntimeOutcomePlan, + AgentRuntimePlan, + BuildAgentRuntimeDeliveryPlanParams, + BuildAgentRuntimePlanParams, +} from "./types.js"; + +function formatResolvedRef(params: { provider: string; modelId: string }): string { + return `${params.provider}/${params.modelId}`; +} + +function hasMedia(payload: { mediaUrl?: string; mediaUrls?: string[] }): boolean { + return resolveSendableOutboundReplyParts(payload).hasMedia; +} + +export function buildAgentRuntimeDeliveryPlan( + params: BuildAgentRuntimeDeliveryPlanParams, +): AgentRuntimeDeliveryPlan { + return { + isSilentPayload(payload): boolean { + return isSilentReplyPayloadText(payload.text, SILENT_REPLY_TOKEN) && !hasMedia(payload); + }, + resolveFollowupRoute(routeParams) { + return resolveProviderFollowupFallbackRoute({ + provider: params.provider, + config: params.config, + workspaceDir: params.workspaceDir, + context: { + config: params.config, + agentDir: params.agentDir, + workspaceDir: params.workspaceDir, + provider: params.provider, + modelId: params.modelId, + payload: routeParams.payload, + originatingChannel: routeParams.originatingChannel, + originatingTo: routeParams.originatingTo, + originRoutable: routeParams.originRoutable, + dispatcherAvailable: routeParams.dispatcherAvailable, + }, + }); + }, + }; +} + +export function buildAgentRuntimeOutcomePlan(): AgentRuntimeOutcomePlan { + return { + classifyRunResult: classifyEmbeddedPiRunResultForModelFallback, + }; +} + +export function buildAgentRuntimePlan(params: BuildAgentRuntimePlanParams): AgentRuntimePlan { + const modelApi = params.modelApi ?? params.model?.api ?? undefined; + const transport = params.resolvedTransport; + const auth = buildAgentRuntimeAuthPlan({ + provider: params.provider, + authProfileProvider: params.authProfileProvider, + sessionAuthProfileId: params.sessionAuthProfileId, + config: params.config, + workspaceDir: params.workspaceDir, + harnessId: params.harnessId, + harnessRuntime: params.harnessRuntime, + allowHarnessAuthProfileForwarding: params.allowHarnessAuthProfileForwarding, + }); + const resolvedRef = { + provider: params.provider, + modelId: params.modelId, + ...(modelApi ? { modelApi } : {}), + ...(params.harnessId ? { harnessId: params.harnessId } : {}), + ...(transport ? { transport } : {}), + }; + const toolContext = { + provider: params.provider, + config: params.config, + workspaceDir: params.workspaceDir, + env: process.env, + modelId: params.modelId, + modelApi, + model: params.model, + }; + const resolveToolContext = (overrides?: { + workspaceDir?: string; + modelApi?: string; + model?: BuildAgentRuntimePlanParams["model"]; + }) => ({ + ...toolContext, + ...(overrides?.workspaceDir !== undefined ? { workspaceDir: overrides.workspaceDir } : {}), + ...(overrides?.modelApi !== undefined ? { modelApi: overrides.modelApi } : {}), + ...(overrides?.model !== undefined ? { model: overrides.model } : {}), + }); + const resolveTranscriptRuntimePolicy = (overrides?: { + workspaceDir?: string; + modelApi?: string; + model?: BuildAgentRuntimePlanParams["model"]; + }) => + resolveTranscriptPolicy({ + provider: params.provider, + modelId: params.modelId, + config: params.config, + workspaceDir: overrides?.workspaceDir ?? params.workspaceDir, + env: process.env, + modelApi: overrides?.modelApi ?? modelApi, + model: overrides?.model ?? params.model, + }); + const resolveTransportExtraParams = ( + overrides: Parameters[0] = {}, + ) => + resolvePreparedExtraParams({ + cfg: params.config, + provider: params.provider, + modelId: params.modelId, + agentDir: params.agentDir, + workspaceDir: overrides.workspaceDir ?? params.workspaceDir, + extraParamsOverride: overrides.extraParamsOverride ?? params.extraParamsOverride, + thinkingLevel: overrides.thinkingLevel ?? params.thinkingLevel, + agentId: overrides.agentId ?? params.agentId, + model: overrides.model ?? params.model, + resolvedTransport: overrides.resolvedTransport ?? transport, + }); + + return { + resolvedRef, + auth, + prompt: { + provider: params.provider, + modelId: params.modelId, + resolveSystemPromptContribution(context) { + return resolveProviderSystemPromptContribution({ + provider: params.provider, + config: params.config, + workspaceDir: context.workspaceDir ?? params.workspaceDir, + context, + }); + }, + }, + tools: { + normalize( + tools: AgentTool[], + overrides?: { + workspaceDir?: string; + modelApi?: string; + model?: BuildAgentRuntimePlanParams["model"]; + }, + ): AgentTool[] { + return normalizeProviderToolSchemas({ + ...resolveToolContext(overrides), + tools, + }); + }, + logDiagnostics( + tools: AgentTool[], + overrides?: { + workspaceDir?: string; + modelApi?: string; + model?: BuildAgentRuntimePlanParams["model"]; + }, + ): void { + logProviderToolSchemaDiagnostics({ + ...resolveToolContext(overrides), + tools, + }); + }, + }, + transcript: { + policy: resolveTranscriptRuntimePolicy(), + resolvePolicy: resolveTranscriptRuntimePolicy, + }, + delivery: buildAgentRuntimeDeliveryPlan(params), + outcome: buildAgentRuntimeOutcomePlan(), + transport: { + extraParams: resolveTransportExtraParams(), + resolveExtraParams: resolveTransportExtraParams, + }, + observability: { + resolvedRef: formatResolvedRef({ + provider: params.provider, + modelId: params.modelId, + }), + provider: params.provider, + modelId: params.modelId, + ...(modelApi ? { modelApi } : {}), + ...(params.harnessId ? { harnessId: params.harnessId } : {}), + ...(auth.forwardedAuthProfileId ? { authProfileId: auth.forwardedAuthProfileId } : {}), + ...(transport ? { transport } : {}), + }, + }; +} diff --git a/src/agents/runtime-plan/index.ts b/src/agents/runtime-plan/index.ts new file mode 100644 index 00000000000..915b122f33d --- /dev/null +++ b/src/agents/runtime-plan/index.ts @@ -0,0 +1,18 @@ +export { buildAgentRuntimeAuthPlan } from "./auth.js"; +export { + buildAgentRuntimeDeliveryPlan, + buildAgentRuntimeOutcomePlan, + buildAgentRuntimePlan, +} from "./build.js"; +export type { + AgentRuntimeAuthPlan, + AgentRuntimeDeliveryPlan, + AgentRuntimeOutcomePlan, + AgentRuntimePlan, + AgentRuntimePromptPlan, + AgentRuntimeResolvedRef, + AgentRuntimeToolPlan, + AgentRuntimeTransportPlan, + BuildAgentRuntimeDeliveryPlanParams, + BuildAgentRuntimePlanParams, +} from "./types.js"; diff --git a/src/agents/runtime-plan/types.ts b/src/agents/runtime-plan/types.ts new file mode 100644 index 00000000000..aad8e8cf7a8 --- /dev/null +++ b/src/agents/runtime-plan/types.ts @@ -0,0 +1,204 @@ +import type { AgentTool } from "@mariozechner/pi-agent-core"; +import type { TSchema } from "typebox"; +import type { ThinkLevel } from "../../auto-reply/thinking.js"; +import type { ReplyPayload } from "../../auto-reply/types.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import type { ProviderRuntimeModel } from "../../plugins/provider-runtime-model.types.js"; +import type { FailoverReason } from "../pi-embedded-helpers/types.js"; +import type { PromptMode } from "../system-prompt.types.js"; + +export type AgentRuntimeTransport = "sse" | "websocket" | "auto"; + +export type AgentRuntimeSystemPromptSectionId = + | "interaction_style" + | "tool_call_style" + | "execution_bias"; + +export type AgentRuntimeSystemPromptContribution = { + stablePrefix?: string; + dynamicSuffix?: string; + sectionOverrides?: Partial>; +}; + +export type AgentRuntimeSystemPromptContributionContext = { + config?: OpenClawConfig; + agentDir?: string; + workspaceDir?: string; + provider: string; + modelId: string; + promptMode: PromptMode; + runtimeChannel?: string; + runtimeCapabilities?: string[]; + agentId?: string; +}; + +export type AgentRuntimeFollowupFallbackRouteResult = { + route?: "origin" | "dispatcher" | "drop"; + reason?: string; +}; + +export type AgentRuntimeToolCallIdMode = "strict" | "strict9"; + +export type AgentRuntimeTranscriptPolicy = { + sanitizeMode: "full" | "images-only"; + sanitizeToolCallIds: boolean; + toolCallIdMode?: AgentRuntimeToolCallIdMode; + preserveNativeAnthropicToolUseIds: boolean; + repairToolUseResultPairing: boolean; + preserveSignatures: boolean; + sanitizeThoughtSignatures?: { + allowBase64Only?: boolean; + includeCamelCase?: boolean; + }; + sanitizeThinkingSignatures: boolean; + dropThinkingBlocks: boolean; + applyGoogleTurnOrdering: boolean; + validateGeminiTurns: boolean; + validateAnthropicTurns: boolean; + allowSyntheticToolResults: boolean; +}; + +export type AgentRuntimeOutcomeClassification = + | { + message: string; + reason?: FailoverReason; + status?: number; + code?: string; + rawError?: string; + } + | { + error: unknown; + } + | null + | undefined; + +export type AgentRuntimeOutcomeClassifier = (params: { + provider: string; + model: string; + result: unknown; + hasDirectlySentBlockReply?: boolean; + hasBlockReplyPipelineOutput?: boolean; +}) => AgentRuntimeOutcomeClassification; + +export type AgentRuntimeResolvedRef = { + provider: string; + modelId: string; + modelApi?: string; + harnessId?: string; + transport?: AgentRuntimeTransport; +}; + +export type AgentRuntimeAuthPlan = { + providerForAuth: string; + authProfileProviderForAuth: string; + harnessAuthProvider?: string; + forwardedAuthProfileId?: string; +}; + +export type AgentRuntimePromptPlan = { + provider: string; + modelId: string; + resolveSystemPromptContribution( + context: AgentRuntimeSystemPromptContributionContext, + ): AgentRuntimeSystemPromptContribution | undefined; +}; + +export type AgentRuntimeToolPlan = { + normalize( + tools: AgentTool[], + params?: { + workspaceDir?: string; + modelApi?: string; + model?: ProviderRuntimeModel; + }, + ): AgentTool[]; + logDiagnostics( + tools: AgentTool[], + params?: { + workspaceDir?: string; + modelApi?: string; + model?: ProviderRuntimeModel; + }, + ): void; +}; + +export type AgentRuntimeDeliveryPlan = { + isSilentPayload(payload: Pick): boolean; + resolveFollowupRoute(params: { + payload: ReplyPayload; + originatingChannel?: string; + originatingTo?: string; + originRoutable: boolean; + dispatcherAvailable: boolean; + }): AgentRuntimeFollowupFallbackRouteResult | undefined; +}; + +export type AgentRuntimeOutcomePlan = { + classifyRunResult: AgentRuntimeOutcomeClassifier; +}; + +export type AgentRuntimeTransportPlan = { + extraParams: Record; + resolveExtraParams(params?: { + extraParamsOverride?: Record; + thinkingLevel?: ThinkLevel; + agentId?: string; + workspaceDir?: string; + model?: ProviderRuntimeModel; + resolvedTransport?: AgentRuntimeTransport; + }): Record; +}; + +export type AgentRuntimePlan = { + resolvedRef: AgentRuntimeResolvedRef; + auth: AgentRuntimeAuthPlan; + prompt: AgentRuntimePromptPlan; + tools: AgentRuntimeToolPlan; + transcript: { + policy: AgentRuntimeTranscriptPolicy; + resolvePolicy(params?: { + workspaceDir?: string; + modelApi?: string; + model?: ProviderRuntimeModel; + }): AgentRuntimeTranscriptPolicy; + }; + delivery: AgentRuntimeDeliveryPlan; + outcome: AgentRuntimeOutcomePlan; + transport: AgentRuntimeTransportPlan; + observability: { + resolvedRef: string; + provider: string; + modelId: string; + modelApi?: string; + harnessId?: string; + authProfileId?: string; + transport?: AgentRuntimeTransport; + }; +}; + +export type BuildAgentRuntimeDeliveryPlanParams = { + config?: OpenClawConfig; + workspaceDir?: string; + agentDir?: string; + provider: string; + modelId: string; +}; + +export type BuildAgentRuntimePlanParams = { + config?: OpenClawConfig; + workspaceDir?: string; + agentDir?: string; + provider: string; + modelId: string; + model?: ProviderRuntimeModel; + modelApi?: string | null; + harnessId?: string; + harnessRuntime?: string; + allowHarnessAuthProfileForwarding?: boolean; + authProfileProvider?: string; + sessionAuthProfileId?: string; + agentId?: string; + thinkingLevel?: ThinkLevel; + extraParamsOverride?: Record; + resolvedTransport?: AgentRuntimeTransport; +}; diff --git a/src/agents/schema-normalization-runtime-contract.test.ts b/src/agents/schema-normalization-runtime-contract.test.ts new file mode 100644 index 00000000000..343c952b287 --- /dev/null +++ b/src/agents/schema-normalization-runtime-contract.test.ts @@ -0,0 +1,107 @@ +import type { StreamFn } from "@mariozechner/pi-agent-core"; +import { describe, expect, it } from "vitest"; +import { + createNativeOpenAIResponsesModel, + createParameterFreeTool, + createPermissiveTool, + createStrictCompatibleTool, + normalizedParameterFreeSchema, +} from "../../test/helpers/agents/schema-normalization-runtime-contract.js"; +import { buildProviderToolCompatFamilyHooks } from "../plugin-sdk/provider-tools.js"; +import { buildOpenAIResponsesParams } from "./openai-transport-stream.js"; +import { convertTools as convertWebSocketTools } from "./openai-ws-message-conversion.js"; +import { createOpenAIResponsesContextManagementWrapper } from "./pi-embedded-runner/openai-stream-wrappers.js"; + +describe("OpenAI transport schema normalization runtime contract", () => { + it("keeps HTTP Responses and WebSocket strict decisions aligned for the same tool set", () => { + const tools = [createStrictCompatibleTool(), createPermissiveTool()] as never; + const httpParams = buildOpenAIResponsesParams( + createNativeOpenAIResponsesModel() as never, + { systemPrompt: "system", messages: [], tools } as never, + undefined, + ) as { tools?: Array<{ strict?: boolean; parameters?: unknown }> }; + const wsTools = convertWebSocketTools(tools, { strict: true }); + + expect(httpParams.tools?.map((tool) => tool.strict)).toEqual([false, false]); + expect(wsTools.map((tool) => tool.strict)).toEqual([false, false]); + }); + + it("normalizes parameter-free tool schemas to the same strict-compatible object shape for HTTP Responses and WebSocket", () => { + const tools = [createParameterFreeTool()] as never; + const httpParams = buildOpenAIResponsesParams( + createNativeOpenAIResponsesModel() as never, + { systemPrompt: "system", messages: [], tools } as never, + undefined, + ) as { tools?: Array<{ strict?: boolean; parameters?: unknown }> }; + const wsTools = convertWebSocketTools(tools, { strict: true }); + const normalizedSchema = normalizedParameterFreeSchema(); + + expect(httpParams.tools?.[0]?.strict).toBe(true); + expect(wsTools[0]?.strict).toBe(true); + expect(httpParams.tools?.[0]?.parameters).toEqual(normalizedSchema); + expect(wsTools[0]?.parameters).toEqual(normalizedSchema); + }); + + it("keeps provider-prepared parameter-free schemas strict-compatible across HTTP Responses and WebSocket", () => { + const hooks = buildProviderToolCompatFamilyHooks("openai"); + const tools = hooks.normalizeToolSchemas({ + provider: "openai", + modelId: "gpt-5.4", + modelApi: "openai-responses", + tools: [createParameterFreeTool()] as never, + }) as never; + const httpParams = buildOpenAIResponsesParams( + createNativeOpenAIResponsesModel() as never, + { systemPrompt: "system", messages: [], tools } as never, + undefined, + ) as { tools?: Array<{ strict?: boolean; parameters?: unknown }> }; + const wsTools = convertWebSocketTools(tools, { strict: true }); + const normalizedSchema = normalizedParameterFreeSchema(); + + expect(httpParams.tools?.[0]?.strict).toBe(true); + expect(wsTools[0]?.strict).toBe(true); + expect(httpParams.tools?.[0]?.parameters).toEqual(normalizedSchema); + expect(wsTools[0]?.parameters).toEqual(normalizedSchema); + }); + + it("passes prepared executable schemas through compaction-triggered Responses requests", () => { + const hooks = buildProviderToolCompatFamilyHooks("openai"); + const tools = hooks.normalizeToolSchemas({ + provider: "openai", + modelId: "gpt-5.4", + modelApi: "openai-responses", + tools: [createParameterFreeTool()] as never, + }) as never; + const model = createNativeOpenAIResponsesModel() as never; + let payload: + | { context_management?: unknown; tools?: Array<{ parameters?: unknown }> } + | undefined; + const baseStreamFn: StreamFn = (modelArg, contextArg, optionsArg) => { + payload = buildOpenAIResponsesParams( + modelArg, + { + ...(contextArg as unknown as Record), + systemPrompt: "system", + messages: [], + tools, + } as never, + optionsArg as never, + ) as typeof payload; + optionsArg?.onPayload?.(payload, modelArg); + return {} as ReturnType; + }; + const streamFn = createOpenAIResponsesContextManagementWrapper(baseStreamFn, { + responsesServerCompaction: true, + }); + + void streamFn(model, { systemPrompt: "system", messages: [], tools } as never, {}); + + expect(payload?.context_management).toEqual([ + { + type: "compaction", + compact_threshold: 140_000, + }, + ]); + expect(payload?.tools?.[0]?.parameters).toEqual(normalizedParameterFreeSchema()); + }); +}); diff --git a/src/agents/transport-params-runtime-contract.test.ts b/src/agents/transport-params-runtime-contract.test.ts new file mode 100644 index 00000000000..5ad50ca9638 --- /dev/null +++ b/src/agents/transport-params-runtime-contract.test.ts @@ -0,0 +1,239 @@ +import type { StreamFn } from "@mariozechner/pi-agent-core"; +import type { Context, Model, SimpleStreamOptions } from "@mariozechner/pi-ai"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + GPT_PARALLEL_TOOL_CALLS_PAYLOAD_APIS, + NON_OPENAI_GPT5_TRANSPORT_CASE, + OPENAI_GPT5_TRANSPORT_DEFAULT_CASES, + OPENAI_GPT5_TRANSPORT_DEFAULTS, + UNRELATED_TOOL_CALLS_PAYLOAD_APIS, +} from "../../test/helpers/agents/transport-params-runtime-contract.js"; +import { + __testing as extraParamsTesting, + applyExtraParamsToAgent, + resolveExtraParams, + resolvePreparedExtraParams, +} from "./pi-embedded-runner/extra-params.js"; +import { createOpenAIThinkingLevelWrapper } from "./pi-embedded-runner/openai-stream-wrappers.js"; +import { supportsGptParallelToolCallsPayload } from "./provider-api-families.js"; + +beforeEach(() => { + installNoopProviderRuntimeDeps(); +}); + +afterEach(() => { + extraParamsTesting.resetProviderRuntimeDepsForTest(); +}); + +describe("transport params runtime contract (Pi/OpenAI path)", () => { + it.each(OPENAI_GPT5_TRANSPORT_DEFAULT_CASES)( + "applies OpenAI GPT-5 transport defaults for $provider/$modelId", + ({ provider, modelId }) => { + expect(resolveExtraParams({ cfg: undefined, provider, modelId })).toEqual( + OPENAI_GPT5_TRANSPORT_DEFAULTS, + ); + }, + ); + + it("does not leak OpenAI GPT-5 defaults to non-OpenAI providers", () => { + expect( + resolveExtraParams({ + cfg: undefined, + provider: NON_OPENAI_GPT5_TRANSPORT_CASE.provider, + modelId: NON_OPENAI_GPT5_TRANSPORT_CASE.modelId, + }), + ).toBeUndefined(); + }); + + it("normalizes aliased caller params without losing explicit overrides", () => { + const cfg = { + agents: { + defaults: { + models: { + "openai/gpt-5.4": { + params: { + parallelToolCalls: false, + textVerbosity: "medium", + cached_content: "conversation-cache", + openaiWsWarmup: true, + }, + }, + }, + }, + }, + }; + + expect(resolveExtraParams({ cfg, provider: "openai", modelId: "gpt-5.4" })).toEqual({ + parallel_tool_calls: false, + text_verbosity: "medium", + cachedContent: "conversation-cache", + openaiWsWarmup: true, + }); + }); + + it.each(GPT_PARALLEL_TOOL_CALLS_PAYLOAD_APIS)( + "advertises %s as accepting the GPT parallel_tool_calls payload patch", + (api) => { + expect(supportsGptParallelToolCallsPayload(api)).toBe(true); + }, + ); + + it.each(UNRELATED_TOOL_CALLS_PAYLOAD_APIS)( + "does not advertise %s as accepting the GPT parallel_tool_calls payload patch", + (api) => { + expect(supportsGptParallelToolCallsPayload(api)).toBe(false); + }, + ); + + it("injects parallel_tool_calls into openai-codex Responses payloads", () => { + const payload = runPayloadMutation({ + applyProvider: "openai-codex", + applyModelId: "gpt-5.4", + model: { + api: "openai-codex-responses", + provider: "openai-codex", + id: "gpt-5.4", + } as Model<"openai-codex-responses">, + }); + + expect(payload.parallel_tool_calls).toBe(true); + }); + + it("propagates OpenAI GPT-5 warmup default through stream options", () => { + const { agent, calls } = createOptionsCaptureAgent(); + applyExtraParamsToAgent(agent, undefined, "openai", "gpt-5.4"); + + void agent.streamFn?.( + { + api: "openai-responses", + provider: "openai", + id: "gpt-5.4", + } as Model<"openai-responses">, + { messages: [] }, + {}, + ); + + expect(calls).toEqual([ + expect.objectContaining({ + openaiWsWarmup: false, + }), + ]); + }); + + it("maps OpenAI GPT-5 thinking level into Responses reasoning effort payloads", () => { + extraParamsTesting.setProviderRuntimeDepsForTest({ + prepareProviderExtraParams: () => undefined, + resolveProviderExtraParamsForTransport: () => undefined, + wrapProviderStreamFn: (params) => + createOpenAIThinkingLevelWrapper(params.context.streamFn, params.context.thinkingLevel), + }); + + const payload = runPayloadMutation({ + applyProvider: "openai-codex", + applyModelId: "gpt-5.4", + thinkingLevel: "high", + model: { + api: "openai-codex-responses", + provider: "openai-codex", + id: "gpt-5.4", + baseUrl: "https://chatgpt.com/backend-api", + } as Model<"openai-codex-responses">, + payload: { reasoning: { effort: "none", summary: "auto" } }, + }); + + expect(payload.reasoning).toEqual({ effort: "high", summary: "auto" }); + }); + + it("composes provider preparation before transport patch resolution", () => { + const resolveProviderExtraParamsForTransport = vi.fn(() => ({ + patch: { + parallel_tool_calls: false, + transportHookApplied: true, + }, + })); + extraParamsTesting.setProviderRuntimeDepsForTest({ + prepareProviderExtraParams: (params) => ({ + ...params.context.extraParams, + transport: "websocket", + preparedByProvider: true, + }), + resolveProviderExtraParamsForTransport, + wrapProviderStreamFn: (params) => params.context.streamFn, + }); + + const prepared = resolvePreparedExtraParams({ + cfg: undefined, + provider: "openai", + modelId: "gpt-5.4", + thinkingLevel: "high", + model: { + api: "openai-responses", + provider: "openai", + id: "gpt-5.4", + } as Model<"openai-responses">, + }); + + expect(prepared).toMatchObject({ + transport: "websocket", + preparedByProvider: true, + parallel_tool_calls: false, + transportHookApplied: true, + }); + expect(resolveProviderExtraParamsForTransport).toHaveBeenCalledWith( + expect.objectContaining({ + context: expect.objectContaining({ + extraParams: expect.objectContaining({ + preparedByProvider: true, + }), + transport: "websocket", + }), + }), + ); + }); +}); + +function runPayloadMutation(params: { + applyProvider: string; + applyModelId: string; + model: Model<"openai-codex-responses"> | Model<"openai-responses">; + thinkingLevel?: Parameters[5]; + payload?: Record; +}): Record { + const payload: Record = params.payload ?? {}; + const baseStreamFn: StreamFn = (model, _context, options) => { + options?.onPayload?.(payload, model); + return {} as ReturnType; + }; + const agent = { streamFn: baseStreamFn }; + applyExtraParamsToAgent( + agent, + undefined, + params.applyProvider, + params.applyModelId, + undefined, + params.thinkingLevel, + ); + const context: Context = { messages: [] }; + void agent.streamFn?.(params.model, context, {} as SimpleStreamOptions); + return payload; +} + +function installNoopProviderRuntimeDeps() { + extraParamsTesting.setProviderRuntimeDepsForTest({ + prepareProviderExtraParams: () => undefined, + resolveProviderExtraParamsForTransport: () => undefined, + wrapProviderStreamFn: (params) => params.context.streamFn, + }); +} + +function createOptionsCaptureAgent() { + const calls: Array<(SimpleStreamOptions & { openaiWsWarmup?: boolean }) | undefined> = []; + const baseStreamFn: StreamFn = (_model, _context, options) => { + calls.push(options as (SimpleStreamOptions & { openaiWsWarmup?: boolean }) | undefined); + return {} as ReturnType; + }; + return { + calls, + agent: { streamFn: baseStreamFn }, + }; +} diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index fc71f783e05..705648226ff 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -26,9 +26,9 @@ import { isTransientHttpError, } from "../../agents/pi-embedded-helpers.js"; import { sanitizeUserFacingText } from "../../agents/pi-embedded-helpers/sanitize-user-facing-text.js"; -import { classifyEmbeddedPiRunResultForModelFallback } from "../../agents/pi-embedded-runner/result-fallback-classifier.js"; import { isLikelyExecutionAckPrompt } from "../../agents/pi-embedded-runner/run/incomplete-turn.js"; import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js"; +import { buildAgentRuntimeOutcomePlan } from "../../agents/runtime-plan/build.js"; import { resolveGroupSessionKey, resolveSessionTranscriptPath, @@ -885,11 +885,12 @@ export async function runAgentTurnWithFallback(params: { }) : undefined; const onToolResult = params.opts?.onToolResult; + const outcomePlan = buildAgentRuntimeOutcomePlan(); const fallbackResult = await runWithModelFallback({ ...resolveModelFallbackOptions(params.followupRun.run), runId, classifyResult: async ({ result, provider, model }) => { - const classification = classifyEmbeddedPiRunResultForModelFallback({ + const classification = outcomePlan.classifyRunResult({ result, provider, model, diff --git a/src/auto-reply/reply/followup-runner.test.ts b/src/auto-reply/reply/followup-runner.test.ts index ad62c65df89..f726dfffd12 100644 --- a/src/auto-reply/reply/followup-runner.test.ts +++ b/src/auto-reply/reply/followup-runner.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { DELIVERY_NO_REPLY_RUNTIME_CONTRACT } from "../../../test/helpers/agents/delivery-no-reply-runtime-contract.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { SessionEntry } from "../../config/sessions/types.js"; import type { FollowupRun, QueueSettings } from "./queue.js"; @@ -1068,7 +1069,7 @@ describe("createFollowupRunner bootstrap warning dedupe", () => { }); }); -describe("createFollowupRunner messaging tool dedupe", () => { +describe("createFollowupRunner messaging delivery and dedupe", () => { function createMessagingDedupeRunner( onBlockReply: (payload: unknown) => Promise, overrides: Partial<{ @@ -1410,6 +1411,88 @@ describe("createFollowupRunner messaging tool dedupe", () => { expect(onBlockReply).not.toHaveBeenCalled(); }); + it("suppresses exact NO_REPLY followups without origin or dispatcher delivery", async () => { + const typing = createMockTypingController(); + runEmbeddedPiAgentMock.mockResolvedValueOnce({ + payloads: [{ text: ` ${DELIVERY_NO_REPLY_RUNTIME_CONTRACT.silentText} ` }], + meta: {}, + }); + const runner = createFollowupRunner({ + typing, + typingMode: "instant", + defaultModel: "anthropic/claude-opus-4-6", + }); + + await runner(createQueuedRun({ originatingChannel: undefined, originatingTo: undefined })); + + expect(routeReplyMock).not.toHaveBeenCalled(); + expect(typing.markRunComplete).toHaveBeenCalled(); + expect(typing.markDispatchIdle).toHaveBeenCalled(); + }); + + it("suppresses JSON NO_REPLY followups without origin or dispatcher delivery", async () => { + const typing = createMockTypingController(); + runEmbeddedPiAgentMock.mockResolvedValueOnce({ + payloads: [{ text: DELIVERY_NO_REPLY_RUNTIME_CONTRACT.jsonSilentText }], + meta: {}, + }); + const runner = createFollowupRunner({ + typing, + typingMode: "instant", + defaultModel: "anthropic/claude-opus-4-6", + }); + + await runner(createQueuedRun({ originatingChannel: undefined, originatingTo: undefined })); + + expect(routeReplyMock).not.toHaveBeenCalled(); + expect(typing.markRunComplete).toHaveBeenCalled(); + expect(typing.markDispatchIdle).toHaveBeenCalled(); + }); + + it("keeps NO_REPLY followups with media deliverable", async () => { + const { onBlockReply } = await runMessagingCase({ + agentResult: { + payloads: [ + { + text: DELIVERY_NO_REPLY_RUNTIME_CONTRACT.silentText, + mediaUrl: "file:///tmp/followup.png", + }, + ], + }, + queued: { + ...baseQueuedRun("webchat"), + originatingChannel: undefined, + originatingTo: undefined, + } as FollowupRun, + }); + + expect(routeReplyMock).not.toHaveBeenCalled(); + expect(onBlockReply).toHaveBeenCalledTimes(1); + expect(onBlockReply).toHaveBeenCalledWith( + expect.objectContaining({ + text: DELIVERY_NO_REPLY_RUNTIME_CONTRACT.silentText, + mediaUrl: "file:///tmp/followup.png", + }), + ); + }); + + it("falls back to dispatcher when successful output has no complete origin route", async () => { + const { onBlockReply } = await runMessagingCase({ + agentResult: { payloads: [{ text: DELIVERY_NO_REPLY_RUNTIME_CONTRACT.dispatcherText }] }, + queued: { + ...baseQueuedRun("webchat"), + originatingChannel: DELIVERY_NO_REPLY_RUNTIME_CONTRACT.originChannel, + originatingTo: undefined, + } as FollowupRun, + }); + + expect(routeReplyMock).not.toHaveBeenCalled(); + expect(onBlockReply).toHaveBeenCalledTimes(1); + expect(onBlockReply).toHaveBeenCalledWith( + expect.objectContaining({ text: DELIVERY_NO_REPLY_RUNTIME_CONTRACT.dispatcherText }), + ); + }); + it("falls back to dispatcher when same-channel origin routing fails", async () => { routeReplyMock.mockResolvedValueOnce({ ok: false, diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index f84f64af383..0a527810c97 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -9,18 +9,19 @@ import { resolveContextTokensForModel } from "../../agents/context.js"; import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js"; import { runWithModelFallback } from "../../agents/model-fallback.js"; import { isCliProvider } from "../../agents/model-selection.js"; -import { classifyEmbeddedPiRunResultForModelFallback } from "../../agents/pi-embedded-runner/result-fallback-classifier.js"; import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js"; +import { + buildAgentRuntimeDeliveryPlan, + buildAgentRuntimeOutcomePlan, +} from "../../agents/runtime-plan/build.js"; import type { SessionEntry } from "../../config/sessions.js"; import type { TypingMode } from "../../config/types.js"; import { logVerbose } from "../../globals.js"; import { emitAgentEvent, registerAgentRunContext } from "../../infra/agent-events.js"; import { formatErrorMessage } from "../../infra/errors.js"; -import { resolveProviderFollowupFallbackRoute } from "../../plugins/provider-runtime.js"; import { defaultRuntime } from "../../runtime.js"; import { isInternalMessageChannel } from "../../utils/message-channel.js"; import { stripHeartbeatToken } from "../heartbeat.js"; -import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../tokens.js"; import type { GetReplyOptions, ReplyPayload } from "../types.js"; import { runPreflightCompactionIfNeeded } from "./agent-runner-memory.js"; import { @@ -84,6 +85,22 @@ export function createFollowupRunner(params: { const { originatingChannel, originatingTo } = queued; const runtimeConfig = resolveQueuedReplyRuntimeConfig(queued.run.config); const shouldRouteToOriginating = isRoutableChannel(originatingChannel) && originatingTo; + const deliveryPlan = buildAgentRuntimeDeliveryPlan({ + provider: resolvedRun.provider, + modelId: resolvedRun.modelId, + config: runtimeConfig, + workspaceDir: queued.run.workspaceDir, + agentDir: queued.run.agentDir, + }); + + const sendablePayloads = payloads.filter( + (payload): payload is ReplyPayload => + hasOutboundReplyContent(payload) && !deliveryPlan.isSilentPayload(payload), + ); + + if (sendablePayloads.length === 0) { + return; + } if (!shouldRouteToOriginating && !opts?.onBlockReply) { defaultRuntime.error?.( @@ -94,32 +111,13 @@ export function createFollowupRunner(params: { let crossChannelRouteFailureNeedsNotice = false; let routedAnyCrossChannelPayloadToOrigin = false; - for (const payload of payloads) { - if (!payload || !hasOutboundReplyContent(payload)) { - continue; - } - if ( - isSilentReplyText(payload.text, SILENT_REPLY_TOKEN) && - !resolveSendableOutboundReplyParts(payload).hasMedia - ) { - continue; - } - const providerRoute = resolveProviderFollowupFallbackRoute({ - provider: resolvedRun.provider, - config: runtimeConfig, - workspaceDir: queued.run.workspaceDir, - context: { - config: runtimeConfig, - agentDir: queued.run.agentDir, - workspaceDir: queued.run.workspaceDir, - provider: resolvedRun.provider, - modelId: resolvedRun.modelId, - payload, - originatingChannel, - originatingTo, - originRoutable: Boolean(shouldRouteToOriginating), - dispatcherAvailable: Boolean(opts?.onBlockReply), - }, + for (const payload of sendablePayloads) { + const providerRoute = deliveryPlan.resolveFollowupRoute({ + payload, + originatingChannel, + originatingTo, + originRoutable: Boolean(shouldRouteToOriginating), + dispatcherAvailable: Boolean(opts?.onBlockReply), }); if (providerRoute?.route === "drop") { logVerbose( @@ -263,6 +261,7 @@ export function createFollowupRunner(params: { ); replyOperation.setPhase("running"); try { + const outcomePlan = buildAgentRuntimeOutcomePlan(); const fallbackResult = await runWithModelFallback({ cfg: runtimeConfig, provider: run.provider, @@ -275,7 +274,7 @@ export function createFollowupRunner(params: { sessionKey: run.sessionKey, }), classifyResult: ({ result, provider, model }) => - classifyEmbeddedPiRunResultForModelFallback({ result, provider, model }), + outcomePlan.classifyRunResult({ result, provider, model }), run: async (provider, model, runOptions) => { const authProfile = resolveRunAuthProfile(run, provider, { config: runtimeConfig }); let attemptCompactionCount = 0; diff --git a/src/auto-reply/reply/get-reply-run.media-only.test.ts b/src/auto-reply/reply/get-reply-run.media-only.test.ts index d5896b102c4..358c3e7fa5e 100644 --- a/src/auto-reply/reply/get-reply-run.media-only.test.ts +++ b/src/auto-reply/reply/get-reply-run.media-only.test.ts @@ -74,9 +74,9 @@ vi.mock("./body.js", () => ({ })); vi.mock("./groups.js", () => ({ + buildDirectChatContext: vi.fn().mockReturnValue(""), buildGroupIntro: vi.fn().mockReturnValue(""), buildGroupChatContext: vi.fn().mockReturnValue(""), - buildDirectChatContext: vi.fn().mockReturnValue(""), })); vi.mock("./inbound-meta.js", () => ({ diff --git a/src/commands/doctor/shared/legacy-config-core-normalizers.ts b/src/commands/doctor/shared/legacy-config-core-normalizers.ts index 56fa744bbb9..a8c703d09d0 100644 --- a/src/commands/doctor/shared/legacy-config-core-normalizers.ts +++ b/src/commands/doctor/shared/legacy-config-core-normalizers.ts @@ -225,7 +225,7 @@ export function normalizeLegacyOpenAICodexModelsAddMetadata( ) { providerChanged = true; const safeProviderId = sanitizeForLog(providerId); - const safeModelId = sanitizeForLog(String(model.id)); + const safeModelId = sanitizeForLog(model.id); changes.push( `Marked models.providers.${safeProviderId}.models.${safeModelId} as /models add metadata so official OpenAI Codex metadata can override it.`, ); diff --git a/src/plugin-sdk/agent-runtime.ts b/src/plugin-sdk/agent-runtime.ts index b60343da1d2..13ed82bb22b 100644 --- a/src/plugin-sdk/agent-runtime.ts +++ b/src/plugin-sdk/agent-runtime.ts @@ -13,6 +13,7 @@ export * from "../agents/model-selection.js"; export * from "../agents/simple-completion-runtime.js"; export * from "../agents/pi-embedded-block-chunker.js"; export * from "../agents/pi-embedded-utils.js"; +export * from "../agents/provider-auth-aliases.js"; export * from "../agents/provider-id.js"; export * from "../agents/sandbox-paths.js"; export * from "../agents/schema/typebox.js"; diff --git a/src/plugin-sdk/schema-normalization-runtime-contract.test.ts b/src/plugin-sdk/schema-normalization-runtime-contract.test.ts new file mode 100644 index 00000000000..53dce1fbffe --- /dev/null +++ b/src/plugin-sdk/schema-normalization-runtime-contract.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import { + createNativeOpenAICodexResponsesModel, + createNativeOpenAIResponsesModel, + createParameterFreeTool, + createPermissiveTool, + createProxyOpenAIResponsesModel, + normalizedParameterFreeSchema, +} from "../../test/helpers/agents/schema-normalization-runtime-contract.js"; +import { buildProviderToolCompatFamilyHooks } from "./provider-tools.js"; + +describe("OpenAI-family schema normalization runtime contract", () => { + const hooks = buildProviderToolCompatFamilyHooks("openai"); + + it("normalizes parameter-free schemas for native OpenAI Responses tools", () => { + const normalized = hooks.normalizeToolSchemas({ + provider: "openai", + modelId: "gpt-5.4", + modelApi: "openai-responses", + model: createNativeOpenAIResponsesModel() as never, + tools: [createParameterFreeTool()] as never, + }); + + expect(normalized[0]?.parameters).toEqual(normalizedParameterFreeSchema()); + }); + + it("normalizes parameter-free schemas for native OpenAI Codex Responses tools", () => { + const normalized = hooks.normalizeToolSchemas({ + provider: "openai-codex", + modelId: "gpt-5.4", + modelApi: "openai-codex-responses", + model: createNativeOpenAICodexResponsesModel() as never, + tools: [createParameterFreeTool()] as never, + }); + + expect(normalized[0]?.parameters).toEqual(normalizedParameterFreeSchema()); + }); + + it("does not apply native strict normalization to proxy-like OpenAI routes", () => { + const tools = [createParameterFreeTool()] as never; + const normalized = hooks.normalizeToolSchemas({ + provider: "openai", + modelId: "custom-gpt", + modelApi: "openai-responses", + model: createProxyOpenAIResponsesModel() as never, + tools, + }); + + expect(normalized).toBe(tools); + }); + + it("keeps permissive schemas observable for transport strict:false downgrade", () => { + const tool = createPermissiveTool(); + const normalized = hooks.normalizeToolSchemas({ + provider: "openai-codex", + modelId: "gpt-5.4", + modelApi: "openai-codex-responses", + model: createNativeOpenAICodexResponsesModel() as never, + tools: [tool] as never, + }); + + expect(normalized[0]?.parameters).toEqual(tool.parameters); + expect( + hooks.inspectToolSchemas({ + provider: "openai-codex", + modelId: "gpt-5.4", + modelApi: "openai-codex-responses", + model: createNativeOpenAICodexResponsesModel() as never, + tools: [tool] as never, + }), + ).toEqual([]); + }); +}); diff --git a/src/plugins/hook-types.ts b/src/plugins/hook-types.ts index 38ee412cac8..b52d608ab34 100644 --- a/src/plugins/hook-types.ts +++ b/src/plugins/hook-types.ts @@ -200,6 +200,11 @@ export type PluginHookLlmOutputEvent = { * names collapse to just the model id. */ resolvedRef?: string; + /** + * Harness/backend responsible for the model loop. Kept separate from + * `resolvedRef` so provider/model consumers keep a stable parse contract. + */ + harnessId?: string; assistantTexts: string[]; lastAssistant?: unknown; usage?: { diff --git a/test/helpers/agents/auth-profile-runtime-contract.ts b/test/helpers/agents/auth-profile-runtime-contract.ts new file mode 100644 index 00000000000..c51a2457393 --- /dev/null +++ b/test/helpers/agents/auth-profile-runtime-contract.ts @@ -0,0 +1,60 @@ +import { + resolveProviderIdForAuth, + type ProviderAuthAliasLookupParams, +} from "../../../src/agents/provider-auth-aliases.js"; +import type { PluginManifestRegistry } from "../../../src/plugins/manifest-registry.js"; + +export const AUTH_PROFILE_RUNTIME_CONTRACT = { + sessionId: "session-auth-contract", + sessionKey: "agent:main:auth-contract", + runId: "run-auth-contract", + workspacePrompt: "continue with the bound Codex profile", + openAiProvider: "openai", + openAiCodexProvider: "openai-codex", + codexCliProvider: "codex-cli", + codexHarnessProvider: "codex", + claudeCliProvider: "claude-cli", + openAiProfileId: "openai:work", + openAiCodexProfileId: "openai-codex:work", + anthropicProfileId: "anthropic:work", +} as const; + +export function createAuthAliasManifestRegistry(): PluginManifestRegistry { + return { + plugins: [ + { + id: "openai", + origin: "bundled", + channels: [], + providers: [], + cliBackends: [], + skills: [], + hooks: [], + rootDir: "/tmp/openclaw-auth-contract-plugin", + source: "test", + manifestPath: "/tmp/openclaw-auth-contract-plugin/plugin.json", + providerAuthChoices: [ + { + provider: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProvider, + method: "oauth", + choiceId: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProvider, + deprecatedChoiceIds: [AUTH_PROFILE_RUNTIME_CONTRACT.codexCliProvider], + }, + ], + }, + ], + diagnostics: [], + }; +} + +export function expectedForwardedAuthProfile(params: { + provider: string; + authProfileProvider: string; + aliasLookupParams: ProviderAuthAliasLookupParams; + sessionAuthProfileId: string | undefined; +}): string | undefined { + return resolveProviderIdForAuth(params.provider, params.aliasLookupParams) === + resolveProviderIdForAuth(params.authProfileProvider, params.aliasLookupParams) + ? params.sessionAuthProfileId + : undefined; +} diff --git a/test/helpers/agents/delivery-no-reply-runtime-contract.ts b/test/helpers/agents/delivery-no-reply-runtime-contract.ts new file mode 100644 index 00000000000..b8aa7f9cd0f --- /dev/null +++ b/test/helpers/agents/delivery-no-reply-runtime-contract.ts @@ -0,0 +1,12 @@ +export const DELIVERY_NO_REPLY_RUNTIME_CONTRACT = { + sessionId: "session-delivery-contract", + sessionKey: "agent:main:delivery-contract", + runId: "run-delivery-contract", + prompt: "deliver the follow-up contract turn", + originChannel: "discord", + originTo: "channel:C1", + dispatcherText: "visible dispatcher fallback", + visibleText: "visible follow-up", + silentText: "NO_REPLY", + jsonSilentText: '{"action":"NO_REPLY"}', +} as const; diff --git a/test/helpers/agents/openclaw-owned-tool-runtime-contract.ts b/test/helpers/agents/openclaw-owned-tool-runtime-contract.ts new file mode 100644 index 00000000000..a3aee98491a --- /dev/null +++ b/test/helpers/agents/openclaw-owned-tool-runtime-contract.ts @@ -0,0 +1,94 @@ +import type { AgentToolResult } from "@mariozechner/pi-agent-core"; +import { vi } from "vitest"; +import { __testing as beforeToolCallTesting } from "../../../src/agents/pi-tools.before-tool-call.js"; +import type { + CodexAppServerExtensionFactory, + CodexAppServerToolResultEvent, +} from "../../../src/plugins/codex-app-server-extension-types.js"; +import { + initializeGlobalHookRunner, + resetGlobalHookRunner, +} from "../../../src/plugins/hook-runner-global.js"; +import { createMockPluginRegistry } from "../../../src/plugins/hooks.test-helpers.js"; +import { createEmptyPluginRegistry } from "../../../src/plugins/registry-empty.js"; +import { + resetPluginRuntimeStateForTest, + setActivePluginRegistry, +} from "../../../src/plugins/runtime.js"; + +export function textToolResult( + text: string, + details: Record = {}, +): AgentToolResult { + return { + content: [{ type: "text", text }], + details, + }; +} + +export function mediaToolResult( + text: string, + mediaUrl: string, + audioAsVoice = false, +): AgentToolResult { + return textToolResult(text, { + media: { + mediaUrl, + ...(audioAsVoice ? { audioAsVoice } : {}), + }, + }); +} + +export function installOpenClawOwnedToolHooks(params?: { + adjustedParams?: Record; + blockReason?: string; +}) { + const beforeToolCall = vi.fn(async () => { + if (params?.blockReason) { + return { + block: true, + blockReason: params.blockReason, + }; + } + return params?.adjustedParams ? { params: params.adjustedParams } : {}; + }); + const afterToolCall = vi.fn(async () => {}); + initializeGlobalHookRunner( + createMockPluginRegistry([ + { hookName: "before_tool_call", handler: beforeToolCall }, + { hookName: "after_tool_call", handler: afterToolCall }, + ]), + ); + return { beforeToolCall, afterToolCall }; +} + +/** + * Installs only the Codex app-server `tool_result` middleware fixture. + * Pair with `installOpenClawOwnedToolHooks()` when a test asserts before/after hook behavior. + */ +export function installCodexToolResultMiddleware( + handler: (event: CodexAppServerToolResultEvent) => AgentToolResult, +) { + const middleware = vi.fn(async (event: CodexAppServerToolResultEvent) => ({ + result: handler(event), + })); + const registry = createEmptyPluginRegistry(); + const factory: CodexAppServerExtensionFactory = async (codex) => { + codex.on("tool_result", middleware); + }; + registry.codexAppServerExtensionFactories.push({ + pluginId: "runtime-contract", + pluginName: "Runtime Contract", + rawFactory: factory, + factory, + source: "test", + }); + setActivePluginRegistry(registry); + return { middleware }; +} + +export function resetOpenClawOwnedToolHooks(): void { + resetGlobalHookRunner(); + resetPluginRuntimeStateForTest(); + beforeToolCallTesting.adjustedParamsByToolCallId.clear(); +} diff --git a/test/helpers/agents/outcome-fallback-runtime-contract.ts b/test/helpers/agents/outcome-fallback-runtime-contract.ts new file mode 100644 index 00000000000..81ad1f8c303 --- /dev/null +++ b/test/helpers/agents/outcome-fallback-runtime-contract.ts @@ -0,0 +1,48 @@ +import type { EmbeddedPiRunResult } from "../../../src/agents/pi-embedded-runner/types.js"; + +export const OUTCOME_FALLBACK_RUNTIME_CONTRACT = { + primaryProvider: "openai-codex", + primaryModel: "gpt-5.4", + fallbackProvider: "anthropic", + fallbackModel: "claude-haiku-3-5", + sessionId: "session-outcome-contract", + sessionKey: "agent:main:outcome-contract", + runId: "run-outcome-contract", + prompt: "finish the contract turn", + reasoningOnlyText: "I need to reason about this before answering.", + planningOnlyText: "Inspect state, then decide the next step.", +} as const; + +export function createContractRunResult( + overrides: Partial = {}, +): EmbeddedPiRunResult { + const { meta, ...rest } = overrides; + return { + payloads: [], + didSendViaMessagingTool: false, + messagingToolSentTexts: [], + messagingToolSentMediaUrls: [], + messagingToolSentTargets: [], + successfulCronAdds: 0, + ...rest, + meta: { + durationMs: 1, + ...meta, + }, + }; +} + +export function createContractFallbackConfig() { + return { + agents: { + defaults: { + model: { + primary: `${OUTCOME_FALLBACK_RUNTIME_CONTRACT.primaryProvider}/${OUTCOME_FALLBACK_RUNTIME_CONTRACT.primaryModel}`, + fallbacks: [ + `${OUTCOME_FALLBACK_RUNTIME_CONTRACT.fallbackProvider}/${OUTCOME_FALLBACK_RUNTIME_CONTRACT.fallbackModel}`, + ], + }, + }, + }, + } as const; +} diff --git a/test/helpers/agents/prompt-overlay-runtime-contract.ts b/test/helpers/agents/prompt-overlay-runtime-contract.ts new file mode 100644 index 00000000000..5ff5fc610a5 --- /dev/null +++ b/test/helpers/agents/prompt-overlay-runtime-contract.ts @@ -0,0 +1,48 @@ +import type { OpenClawConfig } from "../../../src/config/types.openclaw.js"; +import type { ProviderSystemPromptContributionContext } from "../../../src/plugins/types.js"; + +export const GPT5_CONTRACT_MODEL_ID = "gpt-5.4"; +export const GPT5_PREFIXED_CONTRACT_MODEL_ID = "openai/gpt-5.4"; +export const NON_GPT5_CONTRACT_MODEL_ID = "gpt-4.1"; +export const OPENAI_CONTRACT_PROVIDER_ID = "openai"; +export const OPENAI_CODEX_CONTRACT_PROVIDER_ID = "openai-codex"; +export const CODEX_CONTRACT_PROVIDER_ID = "codex"; +export const NON_OPENAI_CONTRACT_PROVIDER_ID = "openrouter"; + +export function openAiPluginPersonalityConfig(personality: "friendly" | "off"): OpenClawConfig { + return { + plugins: { + entries: { + openai: { + config: { personality }, + }, + }, + }, + } satisfies OpenClawConfig; +} + +export function sharedGpt5PersonalityConfig(personality: "friendly" | "off"): OpenClawConfig { + return { + agents: { + defaults: { + promptOverlays: { + gpt5: { personality }, + }, + }, + }, + } satisfies OpenClawConfig; +} + +export function codexPromptOverlayContext(params?: { + modelId?: string; + config?: OpenClawConfig; +}): ProviderSystemPromptContributionContext { + return { + provider: CODEX_CONTRACT_PROVIDER_ID, + modelId: params?.modelId ?? GPT5_CONTRACT_MODEL_ID, + promptMode: "full", + agentDir: "/tmp/openclaw-codex-prompt-contract-agent", + workspaceDir: "/tmp/openclaw-codex-prompt-contract-workspace", + ...(params?.config ? { config: params.config } : {}), + }; +} diff --git a/test/helpers/agents/schema-normalization-runtime-contract.ts b/test/helpers/agents/schema-normalization-runtime-contract.ts new file mode 100644 index 00000000000..ed3385aea59 --- /dev/null +++ b/test/helpers/agents/schema-normalization-runtime-contract.ts @@ -0,0 +1,92 @@ +export function createParameterFreeTool(name = "ping") { + return { + name, + description: "Parameter-free test tool", + parameters: {}, + }; +} + +export function createStrictCompatibleTool(name = "lookup") { + return { + name, + description: "Strict-compatible test tool", + parameters: { + type: "object", + properties: { + path: { type: "string" }, + }, + required: ["path"], + additionalProperties: false, + }, + }; +} + +export function createPermissiveTool(name = "schedule") { + return { + name, + description: "Permissive test tool", + parameters: { + type: "object", + properties: { + action: { type: "string" }, + cron: { type: "string" }, + }, + required: ["action"], + additionalProperties: true, + }, + }; +} + +export function createNativeOpenAIResponsesModel() { + return { + id: "gpt-5.4", + name: "GPT-5.4", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200_000, + maxTokens: 8_192, + }; +} + +export function createNativeOpenAICodexResponsesModel() { + return { + id: "gpt-5.4", + name: "GPT-5.4", + api: "openai-codex-responses", + provider: "openai-codex", + baseUrl: "https://chatgpt.com/backend-api", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200_000, + maxTokens: 8_192, + }; +} + +export function createProxyOpenAIResponsesModel() { + return { + id: "custom-gpt", + name: "Custom GPT", + api: "openai-responses", + provider: "openai", + baseUrl: "https://proxy.example.com/v1", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200_000, + maxTokens: 8_192, + }; +} + +export function normalizedParameterFreeSchema() { + return { + type: "object", + properties: {}, + required: [], + additionalProperties: false, + }; +} diff --git a/test/helpers/agents/transcript-repair-runtime-contract.ts b/test/helpers/agents/transcript-repair-runtime-contract.ts new file mode 100644 index 00000000000..30da2592e29 --- /dev/null +++ b/test/helpers/agents/transcript-repair-runtime-contract.ts @@ -0,0 +1,62 @@ +import type { AgentMessage } from "@mariozechner/pi-agent-core"; + +export const QUEUED_USER_MESSAGE_MARKER = + "[Queued user message that arrived while the previous turn was still active]"; + +export function textOrphanLeaf(text = "older active-turn message"): { content: string } { + return { content: text }; +} + +export function structuredOrphanLeaf(): { content: unknown[] } { + return { + content: [ + { type: "text", text: "please inspect this" }, + { type: "image_url", image_url: { url: "https://example.test/cat.png" } }, + { type: "input_audio", audio_url: "https://example.test/cat.wav" }, + ], + }; +} + +export function inlineDataUriOrphanLeaf(): { content: unknown[] } { + return { + content: [ + { type: "text", text: "please inspect this inline image" }, + { type: "image_url", image_url: { url: `data:image/png;base64,${"a".repeat(4096)}` } }, + ], + }; +} + +export function mediaOnlyHistoryMessage(): AgentMessage { + return { + role: "user", + content: [{ type: "image", data: "b".repeat(2048), mimeType: "image/png" }], + timestamp: 1, + } as AgentMessage; +} + +export function structuredHistoryMessage(): AgentMessage { + return { + role: "user", + content: [ + { type: "text", text: "older structured context" }, + { type: "image", data: "c".repeat(64), mimeType: "image/png" }, + ], + timestamp: 1, + } as AgentMessage; +} + +export function currentPromptHistoryMessage(prompt: string): AgentMessage { + return { + role: "user", + content: [{ type: "text", text: prompt }], + timestamp: 2, + } as AgentMessage; +} + +export function assistantHistoryMessage(text = "ack"): AgentMessage { + return { + role: "assistant", + content: [{ type: "text", text }], + timestamp: 2, + } as AgentMessage; +} diff --git a/test/helpers/agents/transport-params-runtime-contract.ts b/test/helpers/agents/transport-params-runtime-contract.ts new file mode 100644 index 00000000000..05b200fbb43 --- /dev/null +++ b/test/helpers/agents/transport-params-runtime-contract.ts @@ -0,0 +1,33 @@ +export const OPENAI_GPT5_TRANSPORT_DEFAULTS = { + parallel_tool_calls: true, + text_verbosity: "low", + openaiWsWarmup: false, +} as const; + +export const OPENAI_GPT5_TRANSPORT_DEFAULT_CASES = [ + { + provider: "openai", + modelId: "gpt-5.4", + }, + { + provider: "openai-codex", + modelId: "gpt-5.4", + }, +] as const; + +export const NON_OPENAI_GPT5_TRANSPORT_CASE = { + provider: "openrouter", + modelId: "gpt-5.4", +} as const; + +export const GPT_PARALLEL_TOOL_CALLS_PAYLOAD_APIS = [ + "openai-completions", + "openai-responses", + "openai-codex-responses", + "azure-openai-responses", +] as const; + +export const UNRELATED_TOOL_CALLS_PAYLOAD_APIS = [ + "anthropic-messages", + "google-generative-ai", +] as const;