From ec4ae78b752e27423497b7c69ead26bf35954698 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Wed, 12 Aug 2026 09:42:16 +0530 Subject: [PATCH] fix(agents): generate session titles with runtime-owned auth (#122163) Use the selected harness for isolated title generation, including native Codex auth. Retry failed dashboard titles from the first user message without overriding manual names. Co-authored-by: Ayaan Zaidi --- .../agent-harness-runtime.json | 2 +- .../agent-harness.json | 2 +- .../plugin-sdk-api-baseline/channel-core.json | 2 +- .../channel-entry-contract.json | 2 +- .../channel-message.json | 2 +- .../channel-outbound.json | 2 +- .../channel-plugin-common.json | 2 +- .../plugin-sdk-api-baseline/core.json | 2 +- .../plugin-sdk-api-baseline/discord.json | 2 +- .../inbound-reply-dispatch.json | 2 +- .../meeting-runtime.json | 2 +- .../plugin-sdk-api-baseline/plugin-entry.json | 2 +- .../plugin-runtime.json | 2 +- .../provider-catalog-runtime.json | 2 +- .../reply-dispatch-runtime.json | 2 +- .../reply-runtime.json | 2 +- .../plugin-sdk-api-baseline/tool-plugin.json | 2 +- .../webhook-ingress.json | 2 +- docs/plugins/sdk-agent-harness.md | 29 +- extensions/codex/harness.test.ts | 89 +++ extensions/codex/harness.ts | 75 +- .../codex/src/app-server/bounded-turn.test.ts | 54 ++ .../codex/src/app-server/bounded-turn.ts | 26 +- .../event-projector-assistant-message.ts | 20 +- .../app-server/isolated-completion.test.ts | 174 +++++ .../src/app-server/isolated-completion.ts | 97 +++ .../src/app-server/settled-turn-finalizer.ts | 1 + .../codex/src/web-search-provider.runtime.ts | 1 + extensions/copilot/harness.test.ts | 109 +-- extensions/copilot/harness.ts | 6 +- extensions/copilot/src/isolated-completion.ts | 57 +- .../src/monitor/thread-title.generate.test.ts | 273 ++------ .../discord/src/monitor/thread-title.ts | 72 +- src/agents/harness/builtin-openclaw.test.ts | 44 +- src/agents/harness/builtin-openclaw.ts | 9 +- src/agents/harness/types.ts | 33 +- src/agents/isolated-completion.test.ts | 392 ++++++++++- src/agents/isolated-completion.ts | 285 +++++++- .../conversation-label-generator.test.ts | 662 ++++-------------- .../reply/conversation-label-generator.ts | 334 +++------ src/gateway/dashboard-session-title.test.ts | 92 ++- src/gateway/dashboard-session-title.ts | 59 +- .../server-methods/chat-send-background.ts | 1 + .../server-methods/session-discussion.test.ts | 22 +- .../server-methods/session-discussion.ts | 33 +- src/plugin-sdk/agent-harness-runtime.test.ts | 9 + 46 files changed, 1837 insertions(+), 1257 deletions(-) create mode 100644 extensions/codex/src/app-server/isolated-completion.test.ts create mode 100644 extensions/codex/src/app-server/isolated-completion.ts diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json index 2a530c0f36a0..57dac44fd419 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json @@ -1 +1 @@ -{"contentHash":"5e739e6ec9fd1a63b1fad71781d063c47b0e2169d8e39cc8cbf21352ed1333f4","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"} +{"contentHash":"606504bb06b321f5a4fef507f322c297a9295dccdc2e5996ac64603049113408","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json index 6a77527735c6..2a8b60ee0264 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json @@ -1 +1 @@ -{"contentHash":"7a620697c8689b8ddd08f9d9ec31e54240455158e43277946178caa8b81c3872","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} +{"contentHash":"40d137a31b2ac9f1da776aafcc77b54422b1610c91fe2d6594068e1f7285e91b","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-core.json b/docs/.generated/plugin-sdk-api-baseline/channel-core.json index 00d2a0a258c3..156c909f3056 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-core.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-core.json @@ -1 +1 @@ -{"contentHash":"c462572277db06da0e31193b91fef1ff87682602665148d89fbbf848929f11a9","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} +{"contentHash":"6257c43a60153049dac1af0d828435081f10f6bcd2554e2a67d1c8adb6df973c","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json index f402b1376699..876806d33a11 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json @@ -1 +1 @@ -{"contentHash":"c202b9e8bbbcc1d35d29e9ce92e0d9fd5a08a5a9c2c4c6a4eb6f621a52839da9","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"} +{"contentHash":"88113b5ae8119ca780a3d93b0e033c87d0c387b21a29b72e72b375f5c77f08b3","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-message.json b/docs/.generated/plugin-sdk-api-baseline/channel-message.json index 4d2e61f084b9..12d12f0322ff 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-message.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-message.json @@ -1 +1 @@ -{"contentHash":"22a0413c4e79e1c1cd51e122681bf7ad3e7c867668e61dd2f510ea9e14968891","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} +{"contentHash":"a85b4bbcd416a76bde8d58af34fab58bdb416a1b2c8476b46a3ffd8540754e04","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json index 1ded94d41b1d..051fa3974425 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json @@ -1 +1 @@ -{"contentHash":"810a5da4a06925554ab89f9462a17f1fb9fa3196277227eaa383aa82b3b81e58","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} +{"contentHash":"12e07b9ba7b5b35c1f4e0f4510a073adac00671d292a72700025c86376db22b0","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json index 0ebc205b6c16..41cf20d4e926 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json @@ -1 +1 @@ -{"contentHash":"604289da3812346c5a080a9f867a32f4fcc0e06a9837f61395c961d8363d8b9d","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"} +{"contentHash":"f36ace33d1e16649ed888032751d5db67e826cdc59f06732a5c13b5185f971ed","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"} diff --git a/docs/.generated/plugin-sdk-api-baseline/core.json b/docs/.generated/plugin-sdk-api-baseline/core.json index 581e18aa177d..775825f24dd0 100644 --- a/docs/.generated/plugin-sdk-api-baseline/core.json +++ b/docs/.generated/plugin-sdk-api-baseline/core.json @@ -1 +1 @@ -{"contentHash":"1ecacce44a31327293a1ab1b2e24e3f085e526764f90fbb367687c42e029cce4","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} +{"contentHash":"7451e2a5ffc615f6e8fedebbf4327caf3a64a30973828ca776ca8e298052eebf","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/discord.json b/docs/.generated/plugin-sdk-api-baseline/discord.json index 7c1501dcd9a8..4be0b091d72d 100644 --- a/docs/.generated/plugin-sdk-api-baseline/discord.json +++ b/docs/.generated/plugin-sdk-api-baseline/discord.json @@ -1 +1 @@ -{"contentHash":"b9936060e6ca111906bdca8409097589f8548304b947a7762dc068992f0d2d95","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} +{"contentHash":"726e1cc6c7c0463333587b25908a0b4274c9ca2e1c7d696ab7af5cb392aeca07","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} diff --git a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json index ae7c44a68622..d84bd6799495 100644 --- a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json +++ b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json @@ -1 +1 @@ -{"contentHash":"c7fbb54e9ad926e520df85c396531eadbab78e042ffe315dd3311a3508eb6e55","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"} +{"contentHash":"da9273b6213137fdfc3c2f0f22de681c7bd664dfb4fd0fd0f7636b70cad9835b","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"} diff --git a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json index e13f7ffbc91c..d024143ffc6d 100644 --- a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json @@ -1 +1 @@ -{"contentHash":"4ea730b41856a414960f55940197485c5b9a80782f7584425eedd8fca585a177","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} +{"contentHash":"2328445ee010703050ecce3ea4b823881172b1da8ffd061cb149a7324ac3b967","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json index af3a0f2510ff..a7e49f33706f 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json @@ -1 +1 @@ -{"contentHash":"6d3f6a64b8ad7459763f4c62a5f731b679ddc64eba4b3463be122659546b2b58","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} +{"contentHash":"cd50a67407a27b36d6f3e22af00940d1031bfe907fc15be5a72187e68f04d822","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json index 9e081752c72e..79ea9f3b1493 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json @@ -1 +1 @@ -{"contentHash":"e7a9ba2f6a48e5c3c2f7cc4a3d6be9d4776deb0c55b4a22084968ad19ad64fbc","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} +{"contentHash":"ba7ea5cfd334e44f5c5c2571a2161961a44f51e64e45074917fab3e69ac7652f","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json index 406cd885b820..8dbf79d536d2 100644 --- a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json @@ -1 +1 @@ -{"contentHash":"a0bbe80276278db6a7f9981d5fb0b2e990438995e76db85d40bf695924e2ad19","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"} +{"contentHash":"6f6b852b66e41c6f2c15b617bc00148efbafc2d737049b19b45fe3b25eebb9fe","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/reply-dispatch-runtime.json b/docs/.generated/plugin-sdk-api-baseline/reply-dispatch-runtime.json index d2cfe93ae0eb..577914c7b783 100644 --- a/docs/.generated/plugin-sdk-api-baseline/reply-dispatch-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/reply-dispatch-runtime.json @@ -1 +1 @@ -{"contentHash":"cec5c1bb21caf43610e93817539237330ba78d3ebbe7e21a4218914129a564c2","entrypoint":"reply-dispatch-runtime","importSpecifier":"openclaw/plugin-sdk/reply-dispatch-runtime"} +{"contentHash":"a41c05428a9a5430b2d81ed310aa5efcbeddcef7573adfd8af5ec415bfbbba97","entrypoint":"reply-dispatch-runtime","importSpecifier":"openclaw/plugin-sdk/reply-dispatch-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/reply-runtime.json b/docs/.generated/plugin-sdk-api-baseline/reply-runtime.json index c7eeb7a97fe7..27b48726206c 100644 --- a/docs/.generated/plugin-sdk-api-baseline/reply-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/reply-runtime.json @@ -1 +1 @@ -{"contentHash":"682fa22e552f9663f901c9928b49546cf73a9e91824923c794660672f8de1288","entrypoint":"reply-runtime","importSpecifier":"openclaw/plugin-sdk/reply-runtime"} +{"contentHash":"74f042e649fe9ad18cee1eb327622b526636b470e7c230c2e280e11e990c7764","entrypoint":"reply-runtime","importSpecifier":"openclaw/plugin-sdk/reply-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json index a52f4f826d4b..e7cf05ffec50 100644 --- a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json +++ b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json @@ -1 +1 @@ -{"contentHash":"1759143daf318471c30e816f8651b52ee01c1edbade4f0a9f87b4260b11a21a6","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} +{"contentHash":"0686a3b02b9bae23e46af30fdddf46487db53ec6ffe0833ce9cf298aded54362","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} diff --git a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json index 1d2b55e775cf..3e29106b8dd2 100644 --- a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json +++ b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json @@ -1 +1 @@ -{"contentHash":"1eff4a94da8119d071af4884b96f77fe2b3a73650b9be79bfb2703af510e0a8e","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} +{"contentHash":"80e15361b1e42280548074fc349fd32a45b55dd622c33bc37a0e2db963852790","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} diff --git a/docs/plugins/sdk-agent-harness.md b/docs/plugins/sdk-agent-harness.md index 61a78b2e18a6..4ee0e941b4e6 100644 --- a/docs/plugins/sdk-agent-harness.md +++ b/docs/plugins/sdk-agent-harness.md @@ -170,12 +170,21 @@ export default definePluginEntry({ ### Isolated completion -The optional `runIsolatedCompletion(params)` capability serves product paths +The optional `runIsolatedCompletionV2(params)` capability serves product paths that require one fresh prompt-only inference call with a literal empty -model-callable tool surface. Core passes the exact prepared `model`, `auth`, -provider, model id, system prompt, user prompt, timeout, abort signal, and stream -parameters. The harness must not re-resolve credentials, switch routes, reuse a -native thread, attach tools, invoke agent lifecycle hooks, or deliver output. +model-callable tool surface. Core passes provider and model ids, prompts, +deadline controls, and one prepared `authorization`: + +- `owner: "host"` contains the exact transport `model` and resolved `auth`. +- `owner: "harness"` contains the prepared runtime auth plan and a credential + snapshot restricted to the single profile selected for that call. Core owns + automatic fallback order and invokes the harness separately for each candidate. + +Host-authorized calls must use the supplied model and credential without +substitution. Harness-authorized calls may resolve only the supplied prepared +route and scoped profiles, or the harness's native account when the plan leaves +auth to the harness. The harness must not switch routes, reuse a native thread, +attach tools, invoke agent lifecycle hooks, or deliver output. Return `{ assistant: AssistantMessage }`. Core accepts only terminal text/thinking content with a `stop` or `length` stop reason; tool calls, failed stops, and empty @@ -187,9 +196,15 @@ Plugin callers select this behavior through the harness callback is the provider-side enforcement SPI, not a second caller API. +The legacy `runIsolatedCompletion(params)` host-auth-only capability is +deprecated and remains available for external plugins through 2026-10-12. +Implement V2 for harness-owned or native authentication; OpenClaw never invents +a host credential when only the legacy capability is present. + Native agent servers often have ambient built-in tools even when OpenClaw sends -an empty tool list. In that case, use a separate provider transport that can -serialize a true zero-tool request, or leave the capability unsupported. +an empty tool list. Disable and attest those native capabilities for the fresh +turn, use a separate transport that can serialize a true zero-tool request, or +leave the capability unsupported. ### Delegated execution diff --git a/extensions/codex/harness.test.ts b/extensions/codex/harness.test.ts index 35e6481eb531..05036ffe2a13 100644 --- a/extensions/codex/harness.test.ts +++ b/extensions/codex/harness.test.ts @@ -6,10 +6,14 @@ import { upsertSessionEntry } from "openclaw/plugin-sdk/session-store-runtime"; import { describe, expect, it, vi } from "vitest"; const completeWithPreparedSimpleCompletionModel = vi.hoisted(() => vi.fn()); +const runCodexIsolatedCompletion = vi.hoisted(() => vi.fn()); vi.mock("openclaw/plugin-sdk/simple-completion-runtime", () => ({ completeWithPreparedSimpleCompletionModel, })); +vi.mock("./src/app-server/isolated-completion.js", () => ({ + runCodexIsolatedCompletion, +})); import { createCodexAppServerAgentHarness } from "./harness.js"; import { @@ -66,6 +70,91 @@ describe("Codex agent harness supports()", () => { ); }); + it("delegates V2 isolated completion to the native bounded adapter", async () => { + const legacyCallCount = completeWithPreparedSimpleCompletionModel.mock.calls.length; + const result = { + assistant: { + role: "assistant", + content: [{ type: "text", text: "done" }], + stopReason: "stop", + }, + }; + runCodexIsolatedCompletion.mockResolvedValueOnce(result); + const params = { + authorization: { + owner: "harness", + plan: { + providerForAuth: "openai", + authProfileProviderForAuth: "openai", + }, + authProfileStore: { version: 1, profiles: {} }, + }, + config: {}, + systemPrompt: "system", + prompt: "user", + timeoutMs: 1_000, + provider: "openai", + modelId: "gpt-test", + agentId: "main", + agentDir: "/tmp/agent", + workspaceDir: "/tmp/workspace", + } as unknown as Parameters>[0]; + + await expect(harness.runIsolatedCompletionV2?.(params)).resolves.toBe(result); + expect(runCodexIsolatedCompletion).toHaveBeenCalledWith(params, { pluginConfig: undefined }); + expect(completeWithPreparedSimpleCompletionModel).toHaveBeenCalledTimes(legacyCallCount); + }); + + it("keeps V2 host authorization on the prepared direct transport", async () => { + const nativeCallCount = runCodexIsolatedCompletion.mock.calls.length; + const assistant = { + role: "assistant", + content: [{ type: "text", text: "done" }], + stopReason: "stop", + }; + completeWithPreparedSimpleCompletionModel.mockResolvedValueOnce(assistant); + const websocketHarness = createCodexAppServerAgentHarness({ + bindingStore: testCodexAppServerBindingStore, + pluginConfig: { + appServer: { transport: "websocket", url: "ws://127.0.0.1:4501" }, + }, + }); + const hostModel = { + provider: "openai", + id: "gpt-test", + api: "openai-responses", + }; + const hostAuth = { apiKey: "secret", source: "profile:test", mode: "api-key" }; + const params = { + authorization: { + owner: "host", + model: hostModel, + auth: hostAuth, + }, + config: {}, + systemPrompt: "system", + prompt: "user", + timeoutMs: 1_000, + provider: "openai", + modelId: "gpt-test", + agentId: "main", + agentDir: "/tmp/agent", + workspaceDir: "/tmp/workspace", + } as unknown as Parameters>[0]; + + await expect(websocketHarness.runIsolatedCompletionV2?.(params)).resolves.toEqual({ + assistant, + }); + expect(completeWithPreparedSimpleCompletionModel).toHaveBeenCalledWith( + expect.objectContaining({ + model: hostModel, + auth: hostAuth, + context: expect.objectContaining({ tools: [] }), + }), + ); + expect(runCodexIsolatedCompletion).toHaveBeenCalledTimes(nativeCallCount); + }); + it("supports the canonical codex virtual provider", () => { expect(harness.supports({ provider: "codex", requestedRuntime: "codex" })).toEqual({ supported: true, diff --git a/extensions/codex/harness.ts b/extensions/codex/harness.ts index 72d2f61ca60e..3d43f5ea05bf 100644 --- a/extensions/codex/harness.ts +++ b/extensions/codex/harness.ts @@ -33,6 +33,36 @@ type CodexAppServerAgentHarness = AgentHarnessV2 & { ): Promise; }; +type CodexHostPreparedIsolatedCompletionParams = Parameters< + NonNullable +>[0]; + +async function runCodexHostPreparedIsolatedCompletion( + params: CodexHostPreparedIsolatedCompletionParams, +) { + const timeoutSignal = AbortSignal.timeout(params.timeoutMs); + const signal = params.abortSignal + ? AbortSignal.any([params.abortSignal, timeoutSignal]) + : timeoutSignal; + const assistant = await completeWithPreparedSimpleCompletionModel({ + model: params.model, + auth: params.auth, + cfg: params.config, + context: { + systemPrompt: params.systemPrompt, + messages: [{ role: "user", content: params.prompt, timestamp: Date.now() }], + tools: [], + }, + options: { + maxTokens: params.streamParams?.maxTokens, + temperature: params.streamParams?.temperature, + reasoning: params.thinkLevel, + signal, + }, + }); + return { assistant }; +} + async function disposeSharedCodexAppServerClients(): Promise { const dispose = ( globalThis as typeof globalThis & { @@ -186,31 +216,28 @@ export function createCodexAppServerAgentHarness(options: { nativeHookRelay: { enabled: true }, }); }, - runIsolatedCompletion: async (params) => { - // Codex app-server always exposes update_plan. Pure inference therefore - // uses the already-prepared OpenAI/ChatGPT transport and credential - // directly, without entering a Codex thread or re-resolving the route. - const timeoutSignal = AbortSignal.timeout(params.timeoutMs); - const signal = params.abortSignal - ? AbortSignal.any([params.abortSignal, timeoutSignal]) - : timeoutSignal; - const assistant = await completeWithPreparedSimpleCompletionModel({ - model: params.model, - auth: params.auth, - cfg: params.config, - context: { - systemPrompt: params.systemPrompt, - messages: [{ role: "user", content: params.prompt, timestamp: Date.now() }], - tools: [], - }, - options: { - maxTokens: params.streamParams?.maxTokens, - temperature: params.streamParams?.temperature, - reasoning: params.thinkLevel, - signal, - }, + runIsolatedCompletionV2: async (params) => { + if (params.authorization.owner === "host") { + const { authorization, ...commonParams } = params; + return runCodexHostPreparedIsolatedCompletion({ + ...commonParams, + model: authorization.model, + auth: authorization.auth, + ...(authorization.sourceAuthFingerprint + ? { sourceAuthFingerprint: authorization.sourceAuthFingerprint } + : {}), + }); + } + const { runCodexIsolatedCompletion } = + await import("./src/app-server/isolated-completion.js"); + return runCodexIsolatedCompletion(params, { + pluginConfig: options?.resolvePluginConfig?.() ?? options?.pluginConfig, }); - return { assistant }; + }, + runIsolatedCompletion: async (params) => { + // Keep the deprecated V1 contract on its exact host-prepared transport. + // V2 owns native Codex auth and zero-tool attestation above. + return runCodexHostPreparedIsolatedCompletion(params); }, finalizeSettledTurn: async (params) => { const { runCodexSettledTurnFinalization } = diff --git a/extensions/codex/src/app-server/bounded-turn.test.ts b/extensions/codex/src/app-server/bounded-turn.test.ts index bafb0e6b4ee0..fe136b62e6a2 100644 --- a/extensions/codex/src/app-server/bounded-turn.test.ts +++ b/extensions/codex/src/app-server/bounded-turn.test.ts @@ -410,6 +410,56 @@ describe("runBoundedCodexAppServerTurn settled finalization isolation", () => { ).rejects.toThrow("turn ended with status interrupted"); }); + it("forwards one prepared authorization selection to the isolated client", async () => { + const fake = createClientFactory(); + const preparedAuth = { kind: "api-key" as const, apiKey: "test-key" }; + + await runBoundedCodexAppServerTurn({ + model: { mode: "required", id: "gpt-5.4" }, + preparedAuth, + authRequirement: "api-key", + timeoutMs: 5_000, + options: { + clientFactory: fake.factory, + pluginConfig: { appServer: { homeScope: "user" } }, + }, + taskLabel: "isolated completion", + developerInstructions: "Answer only.", + input: [{ type: "text", text: "Name this conversation.", text_elements: [] }], + requiredModalities: ["text"], + isolation: "private-stdio", + requireNoExternalCapabilities: true, + }); + + expect(fake.factory).toHaveBeenCalledWith( + expect.objectContaining({ + preparedAuth, + authRequirement: "api-key", + startOptions: expect.objectContaining({ homeScope: "agent" }), + }), + ); + expect(vi.mocked(fake.factory).mock.calls[0]?.[0]).not.toHaveProperty("authProfileId"); + }); + + it("preserves the configured native model provider when no override is supplied", async () => { + const fake = createClientFactory(); + + await runBoundedCodexAppServerTurn({ + model: { mode: "required", id: "gpt-5.4" }, + timeoutMs: 5_000, + options: { clientFactory: fake.factory }, + taskLabel: "isolated completion", + developerInstructions: "Answer only.", + input: [{ type: "text", text: "Name this conversation.", text_elements: [] }], + requiredModalities: ["text"], + isolation: "configured-transport", + requireNoExternalCapabilities: true, + }); + + const startParams = fake.request.mock.calls.find(([method]) => method === "thread/start")?.[1]; + expect(startParams).not.toHaveProperty("modelProvider"); + }); + it("attests ring-zero and injects frozen history before starting the final turn", async () => { const fake = createClientFactory(); const historyItems: JsonValue[] = [ @@ -465,9 +515,13 @@ describe("runBoundedCodexAppServerTurn settled finalization isolation", () => { "features.hooks": false, "features.multi_agent": false, "features.multi_agent_v2": false, + "features.code_mode": false, + "features.code_mode_only": false, "skills.include_instructions": false, include_environment_context: false, mcp_servers: { inherited: { enabled: false } }, + "tools.experimental_request_user_input.enabled": false, + "tools.update_plan.enabled": false, }, }); const turnParams = fake.request.mock.calls.find(([method]) => method === "turn/start")?.[1]; diff --git a/extensions/codex/src/app-server/bounded-turn.ts b/extensions/codex/src/app-server/bounded-turn.ts index 069bc7084c25..75006a39229f 100644 --- a/extensions/codex/src/app-server/bounded-turn.ts +++ b/extensions/codex/src/app-server/bounded-turn.ts @@ -7,6 +7,7 @@ import { readStringField as readString } from "openclaw/plugin-sdk/string-coerce import { resolvePreferredOpenClawTmpDir, withTempWorkspace } from "openclaw/plugin-sdk/temp-path"; import { CODEX_APP_SERVER_INTERRUPT_TIMEOUT_MS, + closeCodexStartupClientBestEffort, interruptCodexTurnAndWaitBestEffort, } from "./attempt-client-cleanup.js"; import { @@ -14,6 +15,7 @@ import { isTerminalTurnStatus, readCodexNotificationItem, } from "./attempt-notifications.js"; +import type { CodexAppServerAuthRequirement, CodexAppServerPreparedAuth } from "./auth-bridge.js"; import type { CodexAppServerClient } from "./client.js"; import { resolveCodexAppServerRuntimeOptions } from "./config.js"; import { normalizeCodexResponseTokenUsage } from "./event-projector-usage.js"; @@ -95,7 +97,10 @@ class CodexBoundedTurnTimeoutError extends Error { type CodexBoundedTurnParams = { config?: OpenClawConfig; model: CodexBoundedTurnModelSelection; + modelProvider?: string; profile?: string; + preparedAuth?: CodexAppServerPreparedAuth; + authRequirement?: CodexAppServerAuthRequirement; timeoutMs: number; signal?: AbortSignal; agentDir?: string; @@ -163,14 +168,24 @@ async function runBoundedCodexAppServerTurnInWorkspace( // Hosted search needs a private Codex home and cwd so inherited native tools // cannot escape the bounded turn. Media calls retain configured transport // compatibility while still using an isolated ephemeral thread. - const startOptions = workspace.codexHome + const isolatedStartOptions = workspace.codexHome ? buildPrivateCodexAppServerStartOptions(appServer.start, workspace.codexHome) : appServer.start; + // A prepared credential is scoped to the fresh private home even when the + // operator's configured app-server normally points at their user home. + const startOptions = + workspace.codexHome && params.preparedAuth + ? { ...isolatedStartOptions, homeScope: "agent" as const } + : isolatedStartOptions; const ownsClient = !params.options.clientFactory; + const authSelection = params.preparedAuth + ? { preparedAuth: params.preparedAuth } + : { authProfileId: params.profile }; const client = params.options.clientFactory ? await params.options.clientFactory({ startOptions, - authProfileId: params.profile, + ...authSelection, + authRequirement: params.authRequirement, agentDir, config: params.config, timeoutMs, @@ -180,7 +195,8 @@ async function runBoundedCodexAppServerTurnInWorkspace( createIsolatedCodexAppServerClient({ startOptions, timeoutMs, - authProfileId: params.profile, + ...authSelection, + authRequirement: params.authRequirement, agentDir, authProfileStore: params.authProfileStore, config: params.config, @@ -244,7 +260,7 @@ async function runBoundedCodexAppServerTurnInWorkspace( "thread/start", { model, - modelProvider: "openai", + ...(params.modelProvider ? { modelProvider: params.modelProvider } : {}), cwd: workspace.cwd, approvalPolicy: "on-request", sandbox: "read-only", @@ -339,7 +355,7 @@ async function runBoundedCodexAppServerTurnInWorkspace( params.signal?.removeEventListener("abort", abortFromCaller); await interruptPromise; if (ownsClient) { - client.close(); + await closeCodexStartupClientBestEffort(client); } } if (retrySelection) { diff --git a/extensions/codex/src/app-server/event-projector-assistant-message.ts b/extensions/codex/src/app-server/event-projector-assistant-message.ts index b14fca630dbe..3dc5e4cf87e5 100644 --- a/extensions/codex/src/app-server/event-projector-assistant-message.ts +++ b/extensions/codex/src/app-server/event-projector-assistant-message.ts @@ -11,6 +11,11 @@ import { type CodexAssistantMessageParams = CodexLocalRuntimeAttributionParams & Pick; +type CodexAssistantAttribution = { + provider: string; + modelId: string; + api?: AssistantMessage["api"]; +}; type CodexAssistantUsage = Usage & { // Codex is a managed runtime; keep reasoning telemetry private to managed consumers. @@ -44,6 +49,19 @@ export function createAssistantMessage( options: AssistantMessageOptions, ): AssistantMessage { const attribution = resolveCodexLocalRuntimeAttribution(params); + return createAttributedCodexAssistantMessage( + { ...attribution, modelId: params.modelId }, + text, + options, + ); +} + +/** Creates a Codex assistant row when a bounded call already owns attribution. */ +export function createAttributedCodexAssistantMessage( + attribution: CodexAssistantAttribution, + text: string, + options: AssistantMessageOptions, +): AssistantMessage { const usage: CodexAssistantUsage = options.tokenUsage ? { input: options.tokenUsage.input ?? 0, @@ -70,7 +88,7 @@ export function createAssistantMessage( content: [{ type: "text", text }], api: attribution.api ?? "openai-chatgpt-responses", provider: attribution.provider, - model: params.modelId, + model: attribution.modelId, usage, stopReason: options.aborted ? "aborted" : options.promptError ? "error" : "stop", errorMessage: options.promptError ? formatErrorMessage(options.promptError) : undefined, diff --git a/extensions/codex/src/app-server/isolated-completion.test.ts b/extensions/codex/src/app-server/isolated-completion.test.ts new file mode 100644 index 000000000000..23532ce09953 --- /dev/null +++ b/extensions/codex/src/app-server/isolated-completion.test.ts @@ -0,0 +1,174 @@ +import type { AgentHarnessV2 } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + resolveAuthHandoff: vi.fn(), + runBoundedTurn: vi.fn(), +})); + +vi.mock("./auth-bridge.js", () => ({ + resolveCodexAppServerPreparedAuthHandoff: mocks.resolveAuthHandoff, +})); +vi.mock("./bounded-turn.js", () => ({ + runBoundedCodexAppServerTurn: mocks.runBoundedTurn, +})); + +import { runCodexIsolatedCompletion } from "./isolated-completion.js"; + +type IsolatedParams = Parameters>[0]; + +const authProfileStore = { + version: 1, + profiles: { + "openai:test": { + type: "oauth", + provider: "openai", + access: "test-access", + refresh: "test-refresh", + expires: Date.now() + 60_000, + }, + }, +}; + +function createParams(): IsolatedParams { + return { + authorization: { + owner: "harness", + plan: { + providerForAuth: "openai", + authProfileProviderForAuth: "openai", + forwardedAuthProfileId: "openai:test", + modelRoute: { + provider: "openai", + modelId: "gpt-5.4", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + authRequirement: "subscription", + requestTransportOverrides: "none", + }, + }, + authProfileStore, + }, + config: {}, + provider: "openai", + modelId: "gpt-5.4", + agentId: "main", + agentDir: "/tmp/agent", + workspaceDir: "/tmp/workspace", + systemPrompt: "Name the conversation.", + prompt: "Help me plan a garden.", + timeoutMs: 5_000, + } as unknown as IsolatedParams; +} + +describe("runCodexIsolatedCompletion", () => { + beforeEach(() => { + mocks.resolveAuthHandoff.mockReset(); + mocks.runBoundedTurn.mockReset(); + mocks.resolveAuthHandoff.mockResolvedValue({ + authProfileId: "openai:test", + nativeAuthProfile: true, + }); + mocks.runBoundedTurn.mockResolvedValue({ + text: "Garden Planning", + model: "gpt-5.4", + usage: { input: 7, output: 3, cacheRead: 2, total: 10 }, + items: [ + { + id: "prompt", + type: "userMessage", + content: [{ type: "text", text: "Help me plan a garden." }], + }, + { id: "reasoning", type: "reasoning" }, + { id: "answer", type: "agentMessage", text: "Garden Planning" }, + ], + }); + }); + + it("uses native authorization on a ring-zero configured-transport turn", async () => { + const params = createParams(); + + await expect(runCodexIsolatedCompletion(params, {})).resolves.toEqual({ + assistant: expect.objectContaining({ + role: "assistant", + api: "openai-chatgpt-responses", + provider: "openai", + model: "gpt-5.4", + content: [{ type: "text", text: "Garden Planning" }], + usage: expect.objectContaining({ + input: 7, + output: 3, + cacheRead: 2, + totalTokens: 10, + }), + }), + }); + expect(mocks.resolveAuthHandoff).toHaveBeenCalledWith( + expect.objectContaining({ + authRequirement: "subscription", + authProfileId: "openai:test", + authProfileStore, + agentDir: "/tmp/agent", + }), + ); + expect(mocks.runBoundedTurn).toHaveBeenCalledWith( + expect.objectContaining({ + model: { mode: "required", id: "gpt-5.4" }, + profile: "openai:test", + authRequirement: "subscription", + isolation: "configured-transport", + requireNoExternalCapabilities: true, + developerInstructions: "Name the conversation.", + input: [{ type: "text", text: "Help me plan a garden.", text_elements: [] }], + }), + ); + expect(mocks.runBoundedTurn.mock.calls[0]?.[0]).not.toHaveProperty("modelProvider"); + }); + + it("forwards prepared profile auth without also selecting a profile", async () => { + const preparedAuth = { + kind: "profile", + profileId: "openai:test", + store: authProfileStore, + snapshot: { + loginParams: { type: "chatgptAuthTokens", accessToken: "test-access" }, + secretFreeCacheKey: "test-account", + }, + }; + mocks.resolveAuthHandoff.mockResolvedValue({ + authProfileId: "openai:test", + nativeAuthProfile: true, + preparedAuth, + }); + + await runCodexIsolatedCompletion(createParams(), {}); + + const boundedParams = mocks.runBoundedTurn.mock.calls[0]?.[0]; + expect(boundedParams).toMatchObject({ preparedAuth }); + expect(boundedParams).not.toHaveProperty("profile"); + }); + + it("rejects any native or tool item outside the passive response surface", async () => { + mocks.runBoundedTurn.mockResolvedValue({ + text: "Garden Planning", + model: "gpt-5.4", + items: [{ id: "tool", type: "commandExecution" }], + }); + + await expect(runCodexIsolatedCompletion(createParams(), {})).rejects.toThrow( + "Codex isolated completion returned unexpected native item: commandExecution", + ); + }); + + it("rejects host authorization at the native-only boundary", async () => { + const params = createParams(); + params.authorization = { + owner: "host", + model: { provider: "openai", id: "gpt-5.4", api: "openai-responses" }, + auth: { mode: "api-key", source: "test" }, + } as IsolatedParams["authorization"]; + + await expect(runCodexIsolatedCompletion(params, {})).rejects.toThrow("harness-owned"); + expect(mocks.runBoundedTurn).not.toHaveBeenCalled(); + }); +}); diff --git a/extensions/codex/src/app-server/isolated-completion.ts b/extensions/codex/src/app-server/isolated-completion.ts new file mode 100644 index 000000000000..b25eea38887b --- /dev/null +++ b/extensions/codex/src/app-server/isolated-completion.ts @@ -0,0 +1,97 @@ +import type { AgentHarnessV2 } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { resolveCodexAppServerPreparedAuthHandoff } from "./auth-bridge.js"; +import { runBoundedCodexAppServerTurn, type CodexBoundedTurnOptions } from "./bounded-turn.js"; +import { readCodexPluginConfig, resolveCodexAppServerHomeScope } from "./config.js"; +import { createAttributedCodexAssistantMessage } from "./event-projector-assistant-message.js"; +import { isJsonObject, type CodexThreadItem } from "./protocol.js"; + +const ISOLATED_PASSIVE_ITEM_TYPES = new Set(["agentMessage", "reasoning"]); + +type CodexIsolatedCompletionParams = Parameters< + NonNullable +>[0]; +type AgentHarnessIsolatedCompletionResult = Awaited< + ReturnType> +>; + +function assertIsolatedCompletionItems(items: CodexThreadItem[], prompt: string): void { + let promptEchoSeen = false; + for (const item of items) { + if (ISOLATED_PASSIVE_ITEM_TYPES.has(item.type)) { + continue; + } + if (item.type === "userMessage" && !promptEchoSeen) { + const content = Array.isArray(item.content) ? item.content : []; + const input = content[0]; + if ( + content.length === 1 && + isJsonObject(input) && + input.type === "text" && + input.text === prompt + ) { + promptEchoSeen = true; + continue; + } + } + throw new Error(`Codex isolated completion returned unexpected native item: ${item.type}`); + } +} + +/** Runs prompt-only Codex inference on an ephemeral, ring-zero native thread. */ +export async function runCodexIsolatedCompletion( + params: CodexIsolatedCompletionParams, + options: CodexBoundedTurnOptions, +): Promise { + const authorization = params.authorization; + if (authorization.owner !== "harness") { + throw new Error("Codex native isolated completion requires harness-owned authorization."); + } + const pluginConfig = readCodexPluginConfig(options.pluginConfig); + const authRequirement = authorization.plan.modelRoute?.authRequirement; + const authHandoff = await resolveCodexAppServerPreparedAuthHandoff({ + authRequirement, + authProfileId: authorization.plan.forwardedAuthProfileId, + authProfileStore: authorization.authProfileStore, + agentDir: params.agentDir, + homeScope: resolveCodexAppServerHomeScope({ appServer: pluginConfig.appServer }), + config: params.config, + subscriptionProfileRequiredError: + "Prepared Codex subscription route requires a scoped native OAuth or token profile.", + subscriptionProfileUnusableError: `Prepared Codex auth profile "${authorization.plan.forwardedAuthProfileId}" is unusable.`, + }); + const authSelection = authHandoff.preparedAuth + ? { preparedAuth: authHandoff.preparedAuth } + : { profile: authHandoff.authProfileId }; + const result = await runBoundedCodexAppServerTurn({ + config: params.config, + model: { + mode: "required", + id: params.modelId, + }, + ...authSelection, + authRequirement, + timeoutMs: params.timeoutMs, + signal: params.abortSignal, + agentDir: params.agentDir, + authProfileStore: authorization.authProfileStore, + options, + taskLabel: "isolated completion", + developerInstructions: params.systemPrompt, + input: [{ type: "text", text: params.prompt, text_elements: [] }], + requiredModalities: ["text"], + isolation: "configured-transport", + requireNoExternalCapabilities: true, + }); + assertIsolatedCompletionItems(result.items, params.prompt); + return { + assistant: createAttributedCodexAssistantMessage( + { + api: "openai-chatgpt-responses", + provider: params.provider, + modelId: result.model, + }, + result.text, + { tokenUsage: result.usage, aborted: false, promptError: null }, + ), + }; +} diff --git a/extensions/codex/src/app-server/settled-turn-finalizer.ts b/extensions/codex/src/app-server/settled-turn-finalizer.ts index 07ec4aeda76c..b0b3ff64324d 100644 --- a/extensions/codex/src/app-server/settled-turn-finalizer.ts +++ b/extensions/codex/src/app-server/settled-turn-finalizer.ts @@ -39,6 +39,7 @@ export async function runCodexSettledTurnFinalization( const bounded = await runBoundedCodexAppServerTurn({ config: attempt.config, model: { mode: "required", id: attempt.modelId }, + modelProvider: "openai", profile: attempt.authProfileId, timeoutMs: attempt.runTimeoutOverrideMs ?? attempt.timeoutMs, signal: attempt.abortSignal, diff --git a/extensions/codex/src/web-search-provider.runtime.ts b/extensions/codex/src/web-search-provider.runtime.ts index 7051c4f7d966..6c2a1d5ff82c 100644 --- a/extensions/codex/src/web-search-provider.runtime.ts +++ b/extensions/codex/src/web-search-provider.runtime.ts @@ -26,6 +26,7 @@ export async function executeCodexWebSearchProviderTool( const result = await runBoundedCodexAppServerTurn({ config: ctx.config, model: { mode: "live-default" }, + modelProvider: "openai", timeoutMs: resolveSearchTimeoutSeconds(ctx.searchConfig as SearchConfigRecord) * 1_000, signal: executionContext?.signal, agentDir: ctx.agentDir, diff --git a/extensions/copilot/harness.test.ts b/extensions/copilot/harness.test.ts index de2015478790..7867d7b1724e 100644 --- a/extensions/copilot/harness.test.ts +++ b/extensions/copilot/harness.test.ts @@ -21,7 +21,7 @@ import { createCopilotTestHostCapabilities } from "./src/host-capability.test-su import type { CopilotClientPool, PoolKey } from "./src/runtime.js"; type AgentHarnessIsolatedCompletionParams = Parameters< - NonNullable + NonNullable >[0]; type CanonicalAttemptResult = Extract; @@ -121,25 +121,28 @@ const TEST_SESSION_CONFIG = { const ISOLATED_COMPLETION_PARAMS = { provider: "github-copilot", modelId: "gpt-4.1", - model: { - id: "gpt-4.1", - name: "GPT-4.1", - api: "openai-responses", - provider: "github-copilot", - baseUrl: "https://api.githubcopilot.com", - reasoning: false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 128_000, - maxTokens: 8_192, + authorization: { + owner: "host", + model: { + id: "gpt-4.1", + name: "GPT-4.1", + api: "openai-responses", + provider: "github-copilot", + baseUrl: "https://api.githubcopilot.com", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 8_192, + }, + auth: { + apiKey: "prepared-github-token", + profileId: "github:work", + source: "profile", + mode: "oauth", + }, + sourceAuthFingerprint: "prepared-owner-fingerprint", }, - auth: { - apiKey: "prepared-github-token", - profileId: "github:work", - source: "profile", - mode: "oauth", - }, - sourceAuthFingerprint: "prepared-owner-fingerprint", config: {}, agentId: "test", agentDir: "/tmp/agent", @@ -546,7 +549,7 @@ describe("createCopilotAgentHarness", () => { const harness = createCopilotAgentHarness({ pool }); await expect( - harness.runIsolatedCompletion?.({ + harness.runIsolatedCompletionV2?.({ ...ISOLATED_COMPLETION_PARAMS, streamParams: { maxTokens: 800, temperature: 0.2 }, }), @@ -621,6 +624,26 @@ describe("createCopilotAgentHarness", () => { expect(pool.release).toHaveBeenCalledWith(expect.objectContaining({ client })); }); + it("rejects harness-owned authorization before acquiring a client", async () => { + const pool = makePoolMock(); + const harness = createCopilotAgentHarness({ pool }); + + await expect( + harness.runIsolatedCompletionV2?.({ + ...ISOLATED_COMPLETION_PARAMS, + authorization: { + owner: "harness", + plan: { + providerForAuth: "github-copilot", + authProfileProviderForAuth: "github-copilot", + }, + authProfileStore: { version: 1, profiles: {} }, + }, + }), + ).rejects.toThrow("requires host-prepared authorization"); + expect(pool.acquire).not.toHaveBeenCalled(); + }); + it("returns tool-shaped output for core to reject with its stable code", async () => { const session = { abort: vi.fn().mockResolvedValue(undefined), @@ -645,7 +668,7 @@ describe("createCopilotAgentHarness", () => { pool.acquire.mockResolvedValue({ client, key: TEST_POOL_KEY }); const harness = createCopilotAgentHarness({ pool }); - await expect(harness.runIsolatedCompletion?.(ISOLATED_COMPLETION_PARAMS)).resolves.toEqual({ + await expect(harness.runIsolatedCompletionV2?.(ISOLATED_COMPLETION_PARAMS)).resolves.toEqual({ assistant: expect.objectContaining({ content: [{ type: "toolCall", id: "call-1", name: "shell", arguments: {} }], stopReason: "toolUse", @@ -662,7 +685,7 @@ describe("createCopilotAgentHarness", () => { const harness = createCopilotAgentHarness({ pool }); await expect( - harness.runIsolatedCompletion?.({ ...ISOLATED_COMPLETION_PARAMS, thinkLevel }), + harness.runIsolatedCompletionV2?.({ ...ISOLATED_COMPLETION_PARAMS, thinkLevel }), ).rejects.toThrow(`does not support thinking level ${thinkLevel}`); expect(pool.acquire).not.toHaveBeenCalled(); }, @@ -686,7 +709,7 @@ describe("createCopilotAgentHarness", () => { const harness = createCopilotAgentHarness({ pool }); await expect( - harness.runIsolatedCompletion?.({ + harness.runIsolatedCompletionV2?.({ ...ISOLATED_COMPLETION_PARAMS, abortSignal: controller.signal, }), @@ -723,7 +746,7 @@ describe("createCopilotAgentHarness", () => { const harness = createCopilotAgentHarness({ pool }); await expect( - harness.runIsolatedCompletion?.({ + harness.runIsolatedCompletionV2?.({ ...ISOLATED_COMPLETION_PARAMS, abortSignal: controller.signal, }), @@ -744,7 +767,7 @@ describe("createCopilotAgentHarness", () => { const harness = createCopilotAgentHarness({ pool }); await expect( - harness.runIsolatedCompletion?.({ ...ISOLATED_COMPLETION_PARAMS, timeoutMs: 5 }), + harness.runIsolatedCompletionV2?.({ ...ISOLATED_COMPLETION_PARAMS, timeoutMs: 5 }), ).rejects.toThrow("timed out after 5ms"); deferred.resolve(lateHandle); await flushAsyncWork(); @@ -767,7 +790,7 @@ describe("createCopilotAgentHarness", () => { const harness = createCopilotAgentHarness({ pool }); await expect( - harness.runIsolatedCompletion?.({ ...ISOLATED_COMPLETION_PARAMS, timeoutMs: 5 }), + harness.runIsolatedCompletionV2?.({ ...ISOLATED_COMPLETION_PARAMS, timeoutMs: 5 }), ).rejects.toThrow("timed out after 5ms"); deferred.resolve(lateSession); await flushAsyncWork(); @@ -797,7 +820,7 @@ describe("createCopilotAgentHarness", () => { pool.acquire.mockResolvedValue({ client, key: TEST_POOL_KEY }); const harness = createCopilotAgentHarness({ pool }); - await expect(harness.runIsolatedCompletion?.(ISOLATED_COMPLETION_PARAMS)).resolves.toEqual({ + await expect(harness.runIsolatedCompletionV2?.(ISOLATED_COMPLETION_PARAMS)).resolves.toEqual({ assistant: expect.objectContaining({ content: [{ type: "text", text: "Done." }] }), }); expect(disconnect).toHaveBeenCalledOnce(); @@ -825,24 +848,28 @@ describe("createCopilotAgentHarness", () => { ...ISOLATED_COMPLETION_PARAMS, provider: "custom-openai", modelId: "prepared-model", - model: { - ...ISOLATED_COMPLETION_PARAMS.model, - id: "prepared-model", - name: "Prepared model", - provider: "custom-openai", - baseUrl: "https://inference.example/v1", - headers: { "x-tenant": "tenant-a" }, - }, - auth: { - apiKey: "prepared-byok-key", - profileId: "custom:work", - source: "profile", - mode: "api-key" as const, + authorization: { + owner: "host", + model: { + ...ISOLATED_COMPLETION_PARAMS.authorization.model, + id: "prepared-model", + name: "Prepared model", + provider: "custom-openai", + baseUrl: "https://inference.example/v1", + headers: { "x-tenant": "tenant-a" }, + }, + auth: { + apiKey: "prepared-byok-key", + profileId: "custom:work", + source: "profile", + mode: "api-key" as const, + }, + sourceAuthFingerprint: "prepared-owner-fingerprint", }, streamParams: { maxTokens: 321 }, } satisfies AgentHarnessIsolatedCompletionParams; - await expect(harness.runIsolatedCompletion?.(params)).resolves.toEqual({ + await expect(harness.runIsolatedCompletionV2?.(params)).resolves.toEqual({ assistant: expect.objectContaining({ content: [{ type: "text", text: "Done." }], model: "prepared-model", diff --git a/extensions/copilot/harness.ts b/extensions/copilot/harness.ts index 3aa628911fa0..1cef2466c7ef 100644 --- a/extensions/copilot/harness.ts +++ b/extensions/copilot/harness.ts @@ -34,7 +34,7 @@ import type { PoolKey, } from "./src/runtime.js"; -type AgentHarnessIsolatedCompletion = NonNullable; +type AgentHarnessIsolatedCompletion = NonNullable; type AgentHarnessIsolatedCompletionParams = Parameters[0]; type AgentHarnessIsolatedCompletionResult = Awaited>; type CopilotSettledTurnFinalizationAttemptParams = Parameters< @@ -896,7 +896,7 @@ export function createCopilotAgentHarness( } } - async function runIsolatedCompletion( + async function runIsolatedCompletionV2( params: AgentHarnessIsolatedCompletionParams, ): Promise { const completionPromise = (async () => { @@ -974,7 +974,7 @@ export function createCopilotAgentHarness( runAttempt: (params) => runHarnessAttempt(params, "attempt"), - runIsolatedCompletion, + runIsolatedCompletionV2, finalizeSettledTurn: async ({ attempt }) => { const result = await runHarnessAttempt(attempt, "settled-tool-finalization"); diff --git a/extensions/copilot/src/isolated-completion.ts b/extensions/copilot/src/isolated-completion.ts index 2f73b14b3ae8..90533a1585f2 100644 --- a/extensions/copilot/src/isolated-completion.ts +++ b/extensions/copilot/src/isolated-completion.ts @@ -9,7 +9,7 @@ import type { CopilotClientPool, PooledClient } from "./runtime.js"; import { createCopilotIsolatedSessionRestrictions } from "./session-restrictions.js"; import { buildCopilotAssistantUsage } from "./usage-bridge.js"; -type AgentHarnessIsolatedCompletion = NonNullable; +type AgentHarnessIsolatedCompletion = NonNullable; type AgentHarnessIsolatedCompletionParams = Parameters[0]; type AgentHarnessIsolatedCompletionResult = Awaited>; @@ -36,14 +36,6 @@ function startBestEffortCleanup(cleanup: () => Promise): void { } } -function requirePreparedCredential(params: AgentHarnessIsolatedCompletionParams): string { - const apiKey = params.auth.apiKey?.trim(); - if (!apiKey) { - throw new Error("[copilot] isolated completion requires the prepared credential"); - } - return apiKey; -} - function resolveReasoningEffort( thinkLevel: AgentHarnessIsolatedCompletionParams["thinkLevel"], ): SessionConfig["reasoningEffort"] { @@ -175,25 +167,33 @@ export async function runCopilotIsolatedCompletion( deadlineMs: Date.now() + params.timeoutMs, timeoutMs: params.timeoutMs, }; - const apiKey = requirePreparedCredential(params); + if (params.authorization.owner !== "host") { + throw new Error("[copilot] isolated completion requires host-prepared authorization"); + } + const authorization = params.authorization; + const { auth, model } = authorization; + const apiKey = auth.apiKey?.trim(); + if (!apiKey) { + throw new Error("[copilot] isolated completion requires the prepared credential"); + } const resolvedProvider = resolveCopilotProvider({ model: { - api: params.model.api, - id: params.model.id, - provider: params.model.provider, - baseUrl: params.model.baseUrl, - headers: params.model.headers, - authHeader: params.model.authHeader, - contextTokens: params.model.contextTokens, - contextWindow: params.model.contextWindow, - maxTokens: params.streamParams?.maxTokens ?? params.model.maxTokens, + api: model.api, + id: model.id, + provider: model.provider, + baseUrl: model.baseUrl, + headers: model.headers, + authHeader: model.authHeader, + contextTokens: model.contextTokens, + contextWindow: model.contextWindow, + maxTokens: params.streamParams?.maxTokens ?? model.maxTokens, azureApiVersion: - typeof params.model.params?.azureApiVersion === "string" - ? params.model.params.azureApiVersion + typeof model.params?.azureApiVersion === "string" + ? model.params.azureApiVersion : undefined, }, resolvedApiKey: apiKey, - authProfileId: params.auth.profileId, + authProfileId: auth.profileId, }); // Sampling controls are best-effort completion hints. Native Copilot does // not expose equivalent SDK fields, while BYOK applies maxTokens above. @@ -209,8 +209,9 @@ export async function runCopilotIsolatedCompletion( const sessionProvider = byokProxy?.provider ?? resolvedProvider; const githubAuth = sessionProvider.mode === "github-copilot"; const copilotHome = resolve(params.agentDir, "copilot"); - const authProfileId = params.auth.profileId?.trim() || "prepared"; - const authProfileVersion = params.sourceAuthFingerprint?.trim() || tokenFingerprint(apiKey); + const authProfileId = auth.profileId?.trim() || "prepared"; + const authProfileVersion = + authorization.sourceAuthFingerprint?.trim() || tokenFingerprint(apiKey); let handle: PooledClient | undefined; let session: IsolatedSession | undefined; try { @@ -238,7 +239,7 @@ export async function runCopilotIsolatedCompletion( handle = acquiredHandle; const sessionConfig: SessionConfig = { ...createCopilotIsolatedSessionRestrictions(), - model: params.model.id, + model: model.id, ...(githubAuth ? { gitHubToken: apiKey } : {}), ...(sessionProvider.provider ? { provider: sessionProvider.provider } : {}), ...(reasoningEffort ? { reasoningEffort } : {}), @@ -287,9 +288,9 @@ export async function runCopilotIsolatedCompletion( assistant: { role: "assistant", content, - api: params.model.api, - provider: params.model.provider, - model: event.data.model ?? params.model.id, + api: model.api, + provider: model.provider, + model: event.data.model ?? model.id, stopReason: event.data.toolRequests?.length ? "toolUse" : "stop", timestamp: Date.now(), usage: buildCopilotAssistantUsage({ fallbackOutputTokens: event.data.outputTokens }), diff --git a/extensions/discord/src/monitor/thread-title.generate.test.ts b/extensions/discord/src/monitor/thread-title.generate.test.ts index 728ede3a982b..2d8c53208813 100644 --- a/extensions/discord/src/monitor/thread-title.generate.test.ts +++ b/extensions/discord/src/monitor/thread-title.generate.test.ts @@ -1,31 +1,13 @@ // Discord tests cover thread title.generate plugin behavior. -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { - completeWithPreparedSimpleCompletionModel, - extractAssistantText, - prepareSimpleCompletionModelForAgent, -} from "openclaw/plugin-sdk/simple-completion-runtime"; +import { generateConversationLabel } from "openclaw/plugin-sdk/reply-dispatch-runtime"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { EMPTY_DISCORD_TEST_CONFIG } from "../test-support/config.js"; -vi.mock("openclaw/plugin-sdk/simple-completion-runtime", { spy: true }); - -const completeWithPreparedSimpleCompletionModelMock = - vi.fn(); -const prepareSimpleCompletionModelForAgentMock = - vi.fn(); -const extractAssistantTextMock = vi.fn(); +vi.mock("openclaw/plugin-sdk/reply-dispatch-runtime", { spy: true }); +const generateConversationLabelMock = vi.fn(); let generateThreadTitle: typeof import("./thread-title.js").generateThreadTitle; -function firstCompletionArgs(): Parameters[0] { - const firstCall = completeWithPreparedSimpleCompletionModelMock.mock.calls.at(0); - if (!firstCall) { - throw new Error("expected completion call"); - } - return firstCall[0]; -} - function hasLoneSurrogate(value: string): boolean { for (let index = 0; index < value.length; index += 1) { const code = value.charCodeAt(index); @@ -35,9 +17,7 @@ function hasLoneSurrogate(value: string): boolean { return true; } index += 1; - continue; - } - if (code >= 0xdc00 && code <= 0xdfff) { + } else if (code >= 0xdc00 && code <= 0xdfff) { return true; } } @@ -50,58 +30,23 @@ beforeAll(async () => { beforeEach(() => { vi.restoreAllMocks(); - completeWithPreparedSimpleCompletionModelMock.mockReset(); - prepareSimpleCompletionModelForAgentMock.mockReset(); - extractAssistantTextMock.mockReset(); - - prepareSimpleCompletionModelForAgentMock.mockResolvedValue({ - selection: { - provider: "anthropic", - modelId: "claude-sonnet-4-6", - agentDir: "/tmp/openclaw-agent", - }, - model: { - provider: "anthropic", - id: "claude-sonnet-4-6", - maxTokens: 64_000, - }, - auth: { - apiKey: "sk-test", - source: "env:TEST_API_KEY", - mode: "api-key", - }, - } as Awaited>); - completeWithPreparedSimpleCompletionModelMock.mockResolvedValue( - {} as Awaited>, - ); - extractAssistantTextMock.mockReturnValue("Generated title"); - vi.mocked(prepareSimpleCompletionModelForAgent).mockImplementation((...args) => - prepareSimpleCompletionModelForAgentMock(...args), - ); - vi.mocked(completeWithPreparedSimpleCompletionModel).mockImplementation((...args) => - completeWithPreparedSimpleCompletionModelMock(...args), - ); - vi.mocked(extractAssistantText).mockImplementation((...args) => - extractAssistantTextMock(...args), + generateConversationLabelMock.mockReset(); + generateConversationLabelMock.mockResolvedValue("Generated title"); + vi.mocked(generateConversationLabel).mockImplementation((...args) => + generateConversationLabelMock(...args), ); }); describe("generateThreadTitle", () => { it.each([ [' "Weekly Release Summary"\nExtra text', "Weekly Release Summary"], - ['\n\n "Weekly Release Summary"\nExtra text', "Weekly Release Summary"], ["```markdown\nWeekly Release Summary\n```", "Weekly Release Summary"], ["**Scaling ArcherScore Development Roadmap**", "Scaling ArcherScore Development Roadmap"], ['"__Weekly Release Summary__"', "Weekly Release Summary"], ["*Plan* for *project*", "*Plan* for *project*"], - ["**Bold** vs **Strong**", "**Bold** vs **Strong**"], - ["_intro_ and _outro_", "_intro_ and _outro_"], - ["**Release *plan***", "Release *plan*"], ["***Release plan***", "Release plan"], - ["__Release _plan___", "Release _plan_"], ])("normalizes generated title %j", async (generated, expected) => { - extractAssistantTextMock.mockReturnValueOnce(generated); - + generateConversationLabelMock.mockResolvedValueOnce(generated); await expect( generateThreadTitle({ cfg: EMPTY_DISCORD_TEST_CONFIG, @@ -111,139 +56,27 @@ describe("generateThreadTitle", () => { ).resolves.toBe(expected); }); - it("calls shared one-shot model prep with aws-sdk allowance", async () => { - prepareSimpleCompletionModelForAgentMock.mockResolvedValueOnce({ - selection: { - provider: "openrouter", - modelId: "anthropic/claude-sonnet-4-5", - profileId: "work", - agentDir: "/tmp/openclaw-agent", - }, - model: { - provider: "openrouter", - id: "anthropic/claude-sonnet-4-5", - maxTokens: 64_000, - }, - auth: { - apiKey: "sk-openrouter", - source: "profile:work", - mode: "api-key", - }, - } as Awaited>); - const cfg = { - agents: { - defaults: { - model: "openrouter/anthropic/claude-sonnet-4-5@work", - }, - }, - } as OpenClawConfig; - + it("routes through the shared isolated label generator", async () => { await generateThreadTitle({ - cfg, - agentId: "main", - messageText: "Need a generated title.", - }); - - expect(prepareSimpleCompletionModelForAgentMock).toHaveBeenCalledWith({ - cfg, - agentId: "main", - useUtilityModel: true, - allowMissingApiKeyModes: ["aws-sdk"], - }); - }); - - it("passes model override refs into shared model prep", async () => { - const cfg = EMPTY_DISCORD_TEST_CONFIG; - await generateThreadTitle({ - cfg, - agentId: "main", - modelRef: "openai/gpt-4.1-mini@local", - messageText: "Need a generated title.", - }); - - expect(prepareSimpleCompletionModelForAgentMock).toHaveBeenCalledWith({ - cfg, - agentId: "main", - modelRef: "openai/gpt-4.1-mini@local", - useUtilityModel: true, - allowMissingApiKeyModes: ["aws-sdk"], - }); - }); - - it("returns null when shared model prep cannot resolve selection", async () => { - prepareSimpleCompletionModelForAgentMock.mockResolvedValueOnce({ - error: "No model configured for agent main.", - } as Awaited>); - - const result = await generateThreadTitle({ cfg: EMPTY_DISCORD_TEST_CONFIG, agentId: "main", - messageText: "Need a thread title.", + modelRef: "openai/gpt-4.1-mini@local", + messageText: "Summarize deployment blockers and owner follow-ups.", + channelName: "release-status", + channelDescription: "Deploy updates and incident notes", }); - expect(result).toBeNull(); - expect(completeWithPreparedSimpleCompletionModelMock).not.toHaveBeenCalled(); - }); - - it("returns null when shared completion prep fails", async () => { - prepareSimpleCompletionModelForAgentMock.mockResolvedValue({ - error: 'No API key resolved for provider "anthropic" (auth mode: api-key).', - selection: { - provider: "anthropic", - modelId: "claude-sonnet-4-6", - agentDir: "/tmp/openclaw-agent", - }, - } as Awaited>); - - const result = await generateThreadTitle({ + expect(generateConversationLabelMock).toHaveBeenCalledWith({ cfg: EMPTY_DISCORD_TEST_CONFIG, agentId: "main", - messageText: "Need a thread title.", - }); - - expect(result).toBeNull(); - expect(completeWithPreparedSimpleCompletionModelMock).not.toHaveBeenCalled(); - }); - - it("builds contextual prompt and forwards completion options", async () => { - const now = 1_700_000_000_000; - const dateNowSpy = vi.spyOn(Date, "now").mockReturnValue(now); - const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); - let result: string | null; - try { - result = await generateThreadTitle({ - cfg: EMPTY_DISCORD_TEST_CONFIG, - agentId: "main", - messageText: "Summarize deployment blockers and owner follow-ups.", - channelName: "release-status", - channelDescription: "Deploy updates and incident notes", - }); - } finally { - dateNowSpy.mockRestore(); - } - - expect(result).toBe("Generated title"); - expect(completeWithPreparedSimpleCompletionModelMock).toHaveBeenCalledTimes(1); - const completionArgs = firstCompletionArgs(); - expect(completionArgs.context).toEqual({ - systemPrompt: + userMessage: + "Channel: release-status\n\nChannel description: Deploy updates and incident notes\n\nMessage:\nSummarize deployment blockers and owner follow-ups.", + prompt: "Generate a concise Discord thread title (3-6 words). Return only the title. Use channel context when provided and avoid redundant channel-name words unless needed for clarity.", - messages: [ - { - role: "user", - content: - "Channel: release-status\n\nChannel description: Deploy updates and incident notes\n\nMessage:\nSummarize deployment blockers and owner follow-ups.", - timestamp: now, - }, - ], + modelRef: "openai/gpt-4.1-mini@local", + timeoutMs: 60_000, + maxLength: 600, }); - expect(completionArgs.options).toEqual({ - maxTokens: 4_096, - signal: completionArgs.options?.signal, - }); - expect(completionArgs.options?.signal).toBeInstanceOf(AbortSignal); - expect(completionArgs.options).not.toHaveProperty("temperature"); - expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 60_000); }); it("keeps truncated prompt fields on UTF-16 boundaries", async () => { @@ -255,54 +88,32 @@ describe("generateThreadTitle", () => { channelDescription: `${"d".repeat(319)}😀tail`, }); - const message = firstCompletionArgs().context.messages.at(0); - const content = typeof message?.content === "string" ? message.content : ""; - + const content = generateConversationLabelMock.mock.calls[0]?.[0]?.userMessage ?? ""; expect(hasLoneSurrogate(content)).toBe(false); expect(content).toContain(`${"m".repeat(599)}...`); expect(content).toContain(`${"n".repeat(119)}...`); expect(content).toContain(`${"d".repeat(319)}...`); }); - it("clamps completion budget to the selected model output cap", async () => { - prepareSimpleCompletionModelForAgentMock.mockResolvedValueOnce({ - selection: { - provider: "anthropic", - modelId: "claude-haiku-4-5", - agentDir: "/tmp/openclaw-agent", - }, - model: { - provider: "anthropic", - id: "claude-haiku-4-5", - maxTokens: 1_024, - }, - auth: { - apiKey: "sk-test", - source: "env:TEST_API_KEY", - mode: "api-key", - }, - } as Awaited>); - - await generateThreadTitle({ - cfg: EMPTY_DISCORD_TEST_CONFIG, - agentId: "main", - messageText: "Need a generated title.", - }); - - expect(firstCompletionArgs().options?.maxTokens).toBe(1_024); - }); - - it("returns null when completion throws", async () => { - completeWithPreparedSimpleCompletionModelMock.mockRejectedValueOnce( - new Error("network timeout"), - ); - - const result = await generateThreadTitle({ - cfg: EMPTY_DISCORD_TEST_CONFIG, - agentId: "main", - messageText: "Generate title.", - }); - - expect(result).toBeNull(); + it("returns null for empty input, empty output, or generation failure", async () => { + await expect( + generateThreadTitle({ cfg: EMPTY_DISCORD_TEST_CONFIG, agentId: "main", messageText: " " }), + ).resolves.toBeNull(); + generateConversationLabelMock.mockResolvedValueOnce(null); + await expect( + generateThreadTitle({ + cfg: EMPTY_DISCORD_TEST_CONFIG, + agentId: "main", + messageText: "Generate title.", + }), + ).resolves.toBeNull(); + generateConversationLabelMock.mockRejectedValueOnce(new Error("network timeout")); + await expect( + generateThreadTitle({ + cfg: EMPTY_DISCORD_TEST_CONFIG, + agentId: "main", + messageText: "Generate title.", + }), + ).resolves.toBeNull(); }); }); diff --git a/extensions/discord/src/monitor/thread-title.ts b/extensions/discord/src/monitor/thread-title.ts index 579993f5e7b8..a3b7146b4544 100644 --- a/extensions/discord/src/monitor/thread-title.ts +++ b/extensions/discord/src/monitor/thread-title.ts @@ -1,24 +1,13 @@ // Discord plugin module implements thread title behavior. import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { generateConversationLabel } from "openclaw/plugin-sdk/reply-dispatch-runtime"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; -import { - completeWithPreparedSimpleCompletionModel, - extractAssistantText, - prepareSimpleCompletionModelForAgent, -} from "openclaw/plugin-sdk/simple-completion-runtime"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; -import { withAbortTimeout } from "./timeouts.js"; const DEFAULT_THREAD_TITLE_TIMEOUT_MS = 60_000; const MAX_THREAD_TITLE_SOURCE_CHARS = 600; const MAX_THREAD_TITLE_CHANNEL_NAME_CHARS = 120; const MAX_THREAD_TITLE_CHANNEL_DESCRIPTION_CHARS = 320; -// Budget generous enough to cover reasoning-model thinking tokens plus the -// short text output. Lower values (e.g. 24) starve reasoning models of output -// capacity: the entire budget is consumed by the thinking block before any -// text is emitted, so extractAssistantText returns empty and the rename is -// silently skipped. -const DISCORD_THREAD_TITLE_MAX_TOKENS = 4_096; const DISCORD_THREAD_TITLE_SYSTEM_PROMPT = "Generate a concise Discord thread title (3-6 words). Return only the title. Use channel context when provided and avoid redundant channel-name words unless needed for clarity."; @@ -36,21 +25,6 @@ export async function generateThreadTitle(params: { return null; } - const prepared = await prepareSimpleCompletionModelForAgent({ - cfg: params.cfg, - agentId: params.agentId, - ...(params.modelRef ? { modelRef: params.modelRef } : {}), - useUtilityModel: true, - allowMissingApiKeyModes: ["aws-sdk"], - }); - if ("error" in prepared) { - const modelLabel = prepared.selection - ? `${prepared.selection.provider}/${prepared.selection.modelId}` - : "unknown"; - logVerbose(`thread-title: ${prepared.error} (agent=${params.agentId}, model=${modelLabel})`); - return null; - } - try { const userMessage = buildThreadTitleCompletionUserMessage({ sourceText, @@ -58,52 +32,22 @@ export async function generateThreadTitle(params: { channelDescription: params.channelDescription, }); const timeoutMs = resolveThreadTitleTimeoutMs(params.timeoutMs); - const response = await completeThreadTitle({ - model: prepared.model, - auth: prepared.auth, + const generated = await generateConversationLabel({ + cfg: params.cfg, + agentId: params.agentId, userMessage, + prompt: DISCORD_THREAD_TITLE_SYSTEM_PROMPT, + ...(params.modelRef ? { modelRef: params.modelRef } : {}), timeoutMs, + maxLength: MAX_THREAD_TITLE_SOURCE_CHARS, }); - const generated = normalizeGeneratedThreadTitle(extractAssistantText(response)); - return generated || null; + return generated ? normalizeGeneratedThreadTitle(generated) : null; } catch (err) { logVerbose(`thread-title: title generation failed for agent ${params.agentId}: ${String(err)}`); return null; } } -async function completeThreadTitle(params: { - model: Parameters[0]["model"]; - auth: Parameters[0]["auth"]; - userMessage: string; - timeoutMs: number; -}) { - const maxTokens = Math.min(DISCORD_THREAD_TITLE_MAX_TOKENS, Math.floor(params.model.maxTokens)); - return await withAbortTimeout({ - timeoutMs: params.timeoutMs, - createTimeoutError: () => new Error(`thread-title timed out after ${params.timeoutMs}ms`), - run: async (signal) => - await completeWithPreparedSimpleCompletionModel({ - model: params.model, - auth: params.auth, - context: { - systemPrompt: DISCORD_THREAD_TITLE_SYSTEM_PROMPT, - messages: [ - { - role: "user", - content: params.userMessage, - timestamp: Date.now(), - }, - ], - }, - options: { - maxTokens, - signal, - }, - }), - }); -} - function buildThreadTitleCompletionUserMessage(params: { sourceText: string; channelName?: string; diff --git a/src/agents/harness/builtin-openclaw.test.ts b/src/agents/harness/builtin-openclaw.test.ts index 9e7214c6389a..a8b80c3cbb2a 100644 --- a/src/agents/harness/builtin-openclaw.test.ts +++ b/src/agents/harness/builtin-openclaw.test.ts @@ -98,8 +98,11 @@ describe("createOpenClawAgentHarness", () => { it("runs isolated completion through the prepared zero-tool transport", async () => { const params = { - model: { provider: "openai", id: "gpt-test", api: "openai-responses" }, - auth: { apiKey: "secret", source: "profile:test", mode: "api-key" }, + authorization: { + owner: "host", + model: { provider: "openai", id: "gpt-test", api: "openai-responses" }, + auth: { apiKey: "secret", source: "profile:test", mode: "api-key" }, + }, config: {}, systemPrompt: "system", prompt: "user", @@ -110,16 +113,16 @@ describe("createOpenClawAgentHarness", () => { agentDir: "/tmp/agent", workspaceDir: "/tmp/workspace", } as unknown as Parameters< - NonNullable["runIsolatedCompletion"]> + NonNullable["runIsolatedCompletionV2"]> >[0]; - await expect(createOpenClawAgentHarness().runIsolatedCompletion?.(params)).resolves.toEqual({ + await expect(createOpenClawAgentHarness().runIsolatedCompletionV2?.(params)).resolves.toEqual({ assistant: expect.objectContaining({ stopReason: "stop" }), }); expect(completeWithPreparedSimpleCompletionModel).toHaveBeenCalledWith( expect.objectContaining({ - model: params.model, - auth: params.auth, + model: expect.objectContaining({ provider: "openai", id: "gpt-test" }), + auth: expect.objectContaining({ apiKey: "secret", mode: "api-key" }), context: { systemPrompt: "system", messages: [expect.objectContaining({ role: "user", content: "user" })], @@ -129,4 +132,33 @@ describe("createOpenClawAgentHarness", () => { ); expect(runEmbeddedAttempt).not.toHaveBeenCalled(); }); + + it("rejects harness-owned isolated authorization", async () => { + const params = { + authorization: { + owner: "harness", + plan: { + providerForAuth: "openai", + authProfileProviderForAuth: "openai", + }, + authProfileStore: { version: 1, profiles: {} }, + }, + config: {}, + systemPrompt: "system", + prompt: "user", + timeoutMs: 1_000, + provider: "openai", + modelId: "gpt-test", + agentId: "main", + agentDir: "/tmp/agent", + workspaceDir: "/tmp/workspace", + } satisfies Parameters< + NonNullable["runIsolatedCompletionV2"]> + >[0]; + + await expect(createOpenClawAgentHarness().runIsolatedCompletionV2?.(params)).rejects.toThrow( + "requires host-prepared authorization", + ); + expect(completeWithPreparedSimpleCompletionModel).not.toHaveBeenCalled(); + }); }); diff --git a/src/agents/harness/builtin-openclaw.ts b/src/agents/harness/builtin-openclaw.ts index e0fc18589b8b..1256307e6739 100644 --- a/src/agents/harness/builtin-openclaw.ts +++ b/src/agents/harness/builtin-openclaw.ts @@ -85,14 +85,17 @@ export function createOpenClawAgentHarness(): AgentHarnessV2 { contextEngineHostCapabilities: OPENCLAW_EMBEDDED_CONTEXT_ENGINE_HOST.capabilities, supports: () => ({ supported: true, priority: 0 }), runAttempt: (params) => runEmbeddedAttempt(params as EmbeddedRunAttemptParams), - runIsolatedCompletion: async (params) => { + runIsolatedCompletionV2: async (params) => { + if (params.authorization.owner !== "host") { + throw new Error("The built-in OpenClaw harness requires host-prepared authorization."); + } const timeoutSignal = AbortSignal.timeout(params.timeoutMs); const signal = params.abortSignal ? AbortSignal.any([params.abortSignal, timeoutSignal]) : timeoutSignal; const assistant = await completeWithPreparedSimpleCompletionModel({ - model: params.model, - auth: params.auth, + model: params.authorization.model, + auth: params.authorization.auth, cfg: params.config, context: { systemPrompt: params.systemPrompt, diff --git a/src/agents/harness/types.ts b/src/agents/harness/types.ts index 832a26a4e115..4f7816796176 100644 --- a/src/agents/harness/types.ts +++ b/src/agents/harness/types.ts @@ -134,6 +134,7 @@ export type AgentHarnessSettledTurnFinalizationResult = { assistantMessageIndex?: number; diagnosticTrace?: import("../../infra/diagnostic-trace-context.js").DiagnosticTraceContext; }; +/** @deprecated Use AgentHarnessIsolatedCompletionParamsV2. Remove after 2026-10-12. */ type AgentHarnessIsolatedCompletionParams = { /** Logical provider selected by the caller before harness dispatch. */ provider: string; @@ -159,7 +160,29 @@ type AgentHarnessIsolatedCompletionParams = { temperature?: number; }; }; -type AgentHarnessIsolatedCompletionResult = { +export type AgentHarnessIsolatedCompletionAuthorization = + | { + /** OpenClaw resolved the exact transport model and credential before handoff. */ + owner: "host"; + model: import("../../llm/types.js").Model; + auth: import("../model-auth-runtime-shared.js").ResolvedProviderAuth; + /** Non-reversible proof of the prepared credential owner when available. */ + sourceAuthFingerprint?: string; + } + | { + /** The selected harness owns credential resolution for this prepared route. */ + owner: "harness"; + plan: import("../runtime-plan/types.js").AgentRuntimeAuthPlan; + /** Credential snapshot restricted to the single profile selected for this call. */ + authProfileStore: import("../auth-profiles/types.js").AuthProfileStore; + }; +export type AgentHarnessIsolatedCompletionParamsV2 = Omit< + AgentHarnessIsolatedCompletionParams, + "model" | "auth" | "sourceAuthFingerprint" +> & { + authorization: AgentHarnessIsolatedCompletionAuthorization; +}; +export type AgentHarnessIsolatedCompletionResult = { /** The single assistant completion. Core rejects tool-shaped or failed results. */ assistant: import("../../llm/types.js").AssistantMessage; }; @@ -335,12 +358,16 @@ type AgentHarnessRunCapability< finalizeSettledTurn?( params: AgentHarnessSettledTurnFinalizationParams, ): Promise; + /** @deprecated Implement runIsolatedCompletionV2. Remove after 2026-10-12. */ + runIsolatedCompletion?( + params: AgentHarnessIsolatedCompletionParams, + ): Promise; /** * Runs one fresh prompt-only completion with a literal zero-tool model surface. * The harness must fail closed when it cannot enforce that native boundary. */ - runIsolatedCompletion?( - params: AgentHarnessIsolatedCompletionParams, + runIsolatedCompletionV2?( + params: AgentHarnessIsolatedCompletionParamsV2, ): Promise; }; diff --git a/src/agents/isolated-completion.test.ts b/src/agents/isolated-completion.test.ts index c2f60eec9a91..c2cffa3ae035 100644 --- a/src/agents/isolated-completion.test.ts +++ b/src/agents/isolated-completion.test.ts @@ -8,8 +8,11 @@ const mocks = vi.hoisted(() => ({ acquireAgentRunPreparedModelRuntime: vi.fn(), ensureSelectedAgentHarnessPlugin: vi.fn(async () => {}), getRegisteredAgentHarness: vi.fn(), + ensureAuthProfileStore: vi.fn(), isCliRuntimeAliasForProvider: vi.fn(() => false), prepareSimpleCompletionModel: vi.fn(), + prepareAgentRuntimeAuth: vi.fn(), + resolveModelWithRegistry: vi.fn(), resolveCliRuntimeCanonicalProvider: vi.fn(() => undefined), resolveCliBackendConfig: vi.fn< () => { config: { command: string; modelAliases?: Record } } | undefined @@ -32,6 +35,9 @@ vi.mock("./cli-backends.js", () => ({ vi.mock("./embedded-agent-runner/cli-backend-dispatch-eligibility.js", () => ({ resolveEmbeddedCliBackendDispatchEligibility: mocks.resolveEmbeddedCliBackendDispatchEligibility, })); +vi.mock("./embedded-agent-runner/model.js", () => ({ + resolveModelWithRegistry: mocks.resolveModelWithRegistry, +})); vi.mock("./harness/registry.js", () => ({ getRegisteredAgentHarness: mocks.getRegisteredAgentHarness, })); @@ -42,12 +48,33 @@ vi.mock("./model-runtime-aliases.js", () => ({ isCliRuntimeAliasForProvider: mocks.isCliRuntimeAliasForProvider, resolveCliRuntimeExecutionProvider: mocks.resolveCliRuntimeExecutionProvider, })); +vi.mock("./model-auth.js", () => ({ ensureAuthProfileStore: mocks.ensureAuthProfileStore })); vi.mock("./prepared-model-runtime.js", () => ({ acquireAgentRunPreparedModelRuntime: mocks.acquireAgentRunPreparedModelRuntime, })); vi.mock("./simple-completion-runtime.js", () => ({ prepareSimpleCompletionModel: mocks.prepareSimpleCompletionModel, })); +vi.mock("./runtime-plan/prepare-auth.js", async () => { + const actual = await vi.importActual( + "./runtime-plan/prepare-auth.js", + ); + return { ...actual, prepareAgentRuntimeAuth: mocks.prepareAgentRuntimeAuth }; +}); +vi.mock("./runtime-plan/resolve-auth.js", () => ({ + scopeAuthProfileStoreToPreparedPlan: ( + store: { version: number; profiles: Record }, + plan: { forwardedAuthProfileCandidateIds?: string[] }, + ) => ({ + ...store, + profiles: Object.fromEntries( + (plan.forwardedAuthProfileCandidateIds ?? []).flatMap((profileId) => { + const profile = store.profiles[profileId]; + return profile ? [[profileId, profile]] : []; + }), + ), + }), +})); vi.mock("./thinking-runtime.js", () => ({ resolveEffectiveAgentRuntime: mocks.resolveEffectiveAgentRuntime, })); @@ -100,7 +127,10 @@ function request() { beforeEach(() => { vi.clearAllMocks(); mocks.acquireAgentRunPreparedModelRuntime.mockResolvedValue({ - snapshot: { pluginRegistry: createEmptyPluginRegistry() }, + snapshot: { + pluginRegistry: createEmptyPluginRegistry(), + createStores: () => ({ modelRegistry: {} }), + }, release: vi.fn(), }); mocks.isCliRuntimeAliasForProvider.mockReturnValue(false); @@ -111,9 +141,369 @@ beforeEach(() => { auth: { apiKey: "secret", source: "profile:openai:test", mode: "oauth" }, sourceAuthFingerprint: "fingerprint", }); + mocks.resolveModelWithRegistry.mockReturnValue({ + provider: "openai", + id: "gpt-test", + api: "openai-chatgpt-responses", + }); + mocks.ensureAuthProfileStore.mockReturnValue({ version: 1, profiles: {} }); + const plan = { + providerForAuth: "openai", + modelId: "gpt-test", + harnessAuthProvider: "openai", + modelRoute: { authRequirement: "subscription" }, + }; + mocks.prepareAgentRuntimeAuth.mockReturnValue({ + plan, + attempts: [{ kind: "implicit", plan }], + }); }); describe("runIsolatedCompletion", () => { + it("hands harness-owned authorization to the V2 owner without resolving a host key", async () => { + const runIsolatedCompletionV2 = vi.fn(async () => ({ + assistant: assistant([{ type: "text", text: "native result" }]), + })); + mocks.getRegisteredAgentHarness.mockReturnValue({ + harness: { + id: "codex", + label: "Codex", + authBootstrap: "harness", + supports: () => ({ supported: true }), + runAttempt: vi.fn(), + runIsolatedCompletionV2, + } satisfies AgentHarness, + }); + + await expect(runIsolatedCompletion(request())).resolves.toMatchObject({ + text: "native result", + owner: { kind: "harness", id: "codex" }, + }); + expect(mocks.prepareSimpleCompletionModel).not.toHaveBeenCalled(); + expect(runIsolatedCompletionV2).toHaveBeenCalledWith( + expect.objectContaining({ + authorization: expect.objectContaining({ owner: "harness" }), + }), + ); + }); + + it("clamps V2 output tokens to the resolved physical model limit", async () => { + mocks.resolveModelWithRegistry.mockReturnValueOnce({ + provider: "openai", + id: "gpt-test", + api: "openai-chatgpt-responses", + maxTokens: 1_024, + }); + const runIsolatedCompletionV2 = vi.fn(async () => ({ + assistant: assistant([{ type: "text", text: "native result" }]), + })); + mocks.getRegisteredAgentHarness.mockReturnValue({ + harness: { + id: "codex", + label: "Codex", + authBootstrap: "harness", + supports: () => ({ supported: true }), + runAttempt: vi.fn(), + runIsolatedCompletionV2, + } satisfies AgentHarness, + }); + + await runIsolatedCompletion({ + ...request(), + streamParams: { maxTokens: 4_096, temperature: 0.2 }, + }); + + expect(runIsolatedCompletionV2).toHaveBeenCalledWith( + expect.objectContaining({ streamParams: { maxTokens: 1_024, temperature: 0.2 } }), + ); + }); + + it("keeps automatic harness fallback core-owned and scopes one profile per call", async () => { + const firstPlan = { + providerForAuth: "openai", + modelId: "gpt-test", + harnessAuthProvider: "openai", + forwardedAuthProfileId: "openai:first", + forwardedAuthProfileSource: "auto" as const, + forwardedAuthProfileCandidateIds: ["openai:first", "openai:backup"], + modelRoute: { authRequirement: "subscription" as const }, + }; + const backupPlan = { + ...firstPlan, + forwardedAuthProfileId: "openai:backup", + forwardedAuthProfileCandidateIds: ["openai:backup"], + }; + mocks.ensureAuthProfileStore.mockReturnValueOnce({ + version: 1, + profiles: { + "openai:first": { type: "token", provider: "openai", token: "first" }, + "openai:backup": { type: "token", provider: "openai", token: "backup" }, + }, + }); + mocks.prepareAgentRuntimeAuth.mockReturnValueOnce({ + plan: firstPlan, + attempts: [ + { kind: "profile", plan: firstPlan, profileId: "openai:first" }, + { kind: "profile", plan: backupPlan, profileId: "openai:backup" }, + ], + }); + const runIsolatedCompletionV2 = vi + .fn() + .mockRejectedValueOnce(new Error("first profile unavailable")) + .mockResolvedValueOnce({ + assistant: assistant([{ type: "text", text: "backup result" }]), + }); + mocks.getRegisteredAgentHarness.mockReturnValue({ + harness: { + id: "codex", + label: "Codex", + authBootstrap: "harness", + supports: () => ({ supported: true }), + runAttempt: vi.fn(), + runIsolatedCompletionV2, + } satisfies AgentHarness, + }); + + await expect(runIsolatedCompletion(request())).resolves.toMatchObject({ + text: "backup result", + }); + expect(runIsolatedCompletionV2).toHaveBeenCalledTimes(2); + expect( + runIsolatedCompletionV2.mock.calls.map(([params]) => ({ + profileId: + params.authorization.owner === "harness" + ? params.authorization.plan.forwardedAuthProfileId + : undefined, + candidateIds: + params.authorization.owner === "harness" + ? params.authorization.plan.forwardedAuthProfileCandidateIds + : undefined, + profiles: + params.authorization.owner === "harness" + ? Object.keys(params.authorization.authProfileStore.profiles) + : [], + })), + ).toEqual([ + { + profileId: "openai:first", + candidateIds: ["openai:first"], + profiles: ["openai:first"], + }, + { + profileId: "openai:backup", + candidateIds: ["openai:backup"], + profiles: ["openai:backup"], + }, + ]); + expect(mocks.prepareSimpleCompletionModel).not.toHaveBeenCalled(); + }); + + it("does not unlock direct auth when a prepared profile becomes cooldown-blocked", async () => { + const profilePlan = { + providerForAuth: "openai", + modelId: "gpt-test", + harnessAuthProvider: "openai", + forwardedAuthProfileId: "openai:first", + forwardedAuthProfileSource: "auto" as const, + forwardedAuthProfileCandidateIds: ["openai:first"], + modelRoute: { authRequirement: "subscription" as const }, + }; + const directPlan = { + providerForAuth: "openai", + modelId: "gpt-test", + harnessAuthProvider: "openai", + modelRoute: { authRequirement: "api-key" as const }, + }; + mocks.ensureAuthProfileStore.mockReturnValueOnce({ + version: 1, + profiles: { + "openai:first": { type: "token", provider: "openai", token: "first" }, + }, + usageStats: { + "openai:first": { cooldownUntil: Date.now() + 60_000 }, + }, + }); + mocks.prepareAgentRuntimeAuth.mockReturnValueOnce({ + plan: profilePlan, + attempts: [ + { kind: "profile", plan: profilePlan, profileId: "openai:first" }, + { + kind: "direct", + plan: directPlan, + allowAuthProfileFallback: false, + requiresPriorProfileAttempt: true, + }, + ], + }); + const runIsolatedCompletionV2 = vi + .fn() + .mockRejectedValueOnce(new Error("profile unavailable")) + .mockResolvedValueOnce({ assistant: assistant([{ type: "text", text: "direct result" }]) }); + mocks.getRegisteredAgentHarness.mockReturnValue({ + harness: { + id: "codex", + label: "Codex", + authBootstrap: "harness", + supports: () => ({ supported: true }), + runAttempt: vi.fn(), + runIsolatedCompletionV2, + } satisfies AgentHarness, + }); + + await expect(runIsolatedCompletion(request())).rejects.toThrow("temporarily unavailable"); + expect(runIsolatedCompletionV2).not.toHaveBeenCalled(); + expect(mocks.prepareSimpleCompletionModel).not.toHaveBeenCalled(); + }); + + it("skips a cooled profile without hiding a prepared healthy backup", async () => { + const firstPlan = { + providerForAuth: "openai", + modelId: "gpt-test", + harnessAuthProvider: "openai", + forwardedAuthProfileId: "openai:first", + forwardedAuthProfileSource: "auto" as const, + forwardedAuthProfileCandidateIds: ["openai:first", "openai:backup"], + modelRoute: { authRequirement: "subscription" as const }, + }; + const backupPlan = { + ...firstPlan, + forwardedAuthProfileId: "openai:backup", + forwardedAuthProfileCandidateIds: ["openai:backup"], + }; + mocks.ensureAuthProfileStore.mockReturnValueOnce({ + version: 1, + profiles: { + "openai:first": { type: "token", provider: "openai", token: "first" }, + "openai:backup": { type: "token", provider: "openai", token: "backup" }, + }, + usageStats: { + "openai:first": { cooldownUntil: Date.now() + 60_000 }, + }, + }); + mocks.prepareAgentRuntimeAuth.mockReturnValueOnce({ + plan: firstPlan, + attempts: [ + { kind: "profile", plan: firstPlan, profileId: "openai:first" }, + { kind: "profile", plan: backupPlan, profileId: "openai:backup" }, + ], + }); + const runIsolatedCompletionV2 = vi.fn(async () => ({ + assistant: assistant([{ type: "text", text: "backup result" }]), + })); + mocks.getRegisteredAgentHarness.mockReturnValue({ + harness: { + id: "codex", + label: "Codex", + authBootstrap: "harness", + supports: () => ({ supported: true }), + runAttempt: vi.fn(), + runIsolatedCompletionV2, + } satisfies AgentHarness, + }); + + await expect(runIsolatedCompletion(request())).resolves.toMatchObject({ + text: "backup result", + }); + expect(runIsolatedCompletionV2).toHaveBeenCalledOnce(); + expect(runIsolatedCompletionV2).toHaveBeenCalledWith( + expect.objectContaining({ + authorization: expect.objectContaining({ + owner: "harness", + plan: expect.objectContaining({ forwardedAuthProfileId: "openai:backup" }), + }), + }), + ); + }); + + it("allows direct auth after a prepared profile was actually dispatched", async () => { + const profilePlan = { + providerForAuth: "openai", + modelId: "gpt-test", + harnessAuthProvider: "openai", + forwardedAuthProfileId: "openai:first", + forwardedAuthProfileSource: "auto" as const, + forwardedAuthProfileCandidateIds: ["openai:first"], + modelRoute: { authRequirement: "subscription" as const }, + }; + const directPlan = { + providerForAuth: "openai", + modelId: "gpt-test", + harnessAuthProvider: "openai", + modelRoute: { authRequirement: "api-key" as const }, + }; + mocks.ensureAuthProfileStore.mockReturnValueOnce({ + version: 1, + profiles: { + "openai:first": { type: "token", provider: "openai", token: "first" }, + }, + }); + mocks.prepareAgentRuntimeAuth.mockReturnValueOnce({ + plan: profilePlan, + attempts: [ + { kind: "profile", plan: profilePlan, profileId: "openai:first" }, + { + kind: "direct", + plan: directPlan, + allowAuthProfileFallback: false, + requiresPriorProfileAttempt: true, + }, + ], + }); + const runIsolatedCompletionV2 = vi + .fn() + .mockRejectedValueOnce(new Error("profile unavailable")) + .mockResolvedValueOnce({ assistant: assistant([{ type: "text", text: "direct result" }]) }); + mocks.getRegisteredAgentHarness.mockReturnValue({ + harness: { + id: "codex", + label: "Codex", + authBootstrap: "harness", + supports: () => ({ supported: true }), + runAttempt: vi.fn(), + runIsolatedCompletionV2, + } satisfies AgentHarness, + }); + + await expect(runIsolatedCompletion(request())).resolves.toMatchObject({ + text: "direct result", + }); + expect(runIsolatedCompletionV2).toHaveBeenCalledTimes(2); + expect(mocks.prepareSimpleCompletionModel).toHaveBeenCalledOnce(); + }); + + it("uses host authorization for V2 API-key routes", async () => { + const plan = { + providerForAuth: "openai", + modelId: "gpt-test", + harnessAuthProvider: "openai", + modelRoute: { authRequirement: "api-key" as const }, + }; + mocks.prepareAgentRuntimeAuth.mockReturnValueOnce({ + plan, + attempts: [{ kind: "implicit", plan }], + }); + const runIsolatedCompletionV2 = vi.fn(async () => ({ + assistant: assistant([{ type: "text", text: "key result" }]), + })); + mocks.getRegisteredAgentHarness.mockReturnValue({ + harness: { + id: "codex", + label: "Codex", + authBootstrap: "harness", + supports: () => ({ supported: true }), + runAttempt: vi.fn(), + runIsolatedCompletionV2, + } satisfies AgentHarness, + }); + + await runIsolatedCompletion(request()); + + expect(mocks.prepareSimpleCompletionModel).toHaveBeenCalledOnce(); + expect(runIsolatedCompletionV2).toHaveBeenCalledWith( + expect.objectContaining({ authorization: expect.objectContaining({ owner: "host" }) }), + ); + }); + it("passes one prepared route to the selected harness and returns text", async () => { const runIsolatedCompletionHarness = vi.fn(async () => ({ assistant: assistant([{ type: "text", text: '{"ok":true}' }]), diff --git a/src/agents/isolated-completion.ts b/src/agents/isolated-completion.ts index 28f5bdece7fd..340f515d9fd5 100644 --- a/src/agents/isolated-completion.ts +++ b/src/agents/isolated-completion.ts @@ -18,9 +18,16 @@ import { resolveAgentDir, resolveAgentWorkspaceDir, resolveDefaultAgentId } from import { resolveCliBackendConfig, resolveCliRuntimeCanonicalProvider } from "./cli-backends.js"; import { normalizeCliModel } from "./cli-runner/helpers.js"; import { resolveEmbeddedCliBackendDispatchEligibility } from "./embedded-agent-runner/cli-backend-dispatch-eligibility.js"; +import { resolveModelWithRegistry } from "./embedded-agent-runner/model.js"; import { getRegisteredAgentHarness } from "./harness/registry.js"; import { ensureSelectedAgentHarnessPlugin } from "./harness/runtime-plugin.js"; -import type { AgentHarness } from "./harness/types.js"; +import type { + AgentHarness, + AgentHarnessIsolatedCompletionAuthorization, + AgentHarnessIsolatedCompletionParamsV2, + AgentHarnessIsolatedCompletionResult, +} from "./harness/types.js"; +import { ensureAuthProfileStore } from "./model-auth.js"; import { isCliRuntimeAliasForProvider, resolveCliRuntimeExecutionProvider, @@ -30,6 +37,13 @@ import { unwrapModelHeaderSentinelsForProviderEgress, unwrapSecretSentinelsForProviderEgress, } from "./provider-secret-egress.js"; +import { + canRunPreparedAgentRuntimeAuthAttempt, + prepareAgentRuntimeAuth, + preparedAgentRuntimeProfileAttemptHasCandidate, + type PreparedAgentRuntimeAuthAttempt, +} from "./runtime-plan/prepare-auth.js"; +import { scopeAuthProfileStoreToPreparedPlan } from "./runtime-plan/resolve-auth.js"; import { prepareSimpleCompletionModel } from "./simple-completion-runtime.js"; import { resolveEffectiveAgentRuntime } from "./thinking-runtime.js"; import type { UsageLike } from "./usage.js"; @@ -41,6 +55,7 @@ type RunIsolatedCompletionParams = { /** Explicit credential owner. CLI and harness paths must not replace it with another profile. */ authProfileId?: string; agentId?: string; + agentDir?: string; workspaceDir?: string; /** Concrete owner already resolved by the caller, when available. */ agentHarnessRuntimeOverride?: string; @@ -84,6 +99,29 @@ type AgentHarnessIsolatedCompletionParams = Parameters< NonNullable >[0]; +function clampIsolatedStreamParams( + streamParams: RunIsolatedCompletionParams["streamParams"], + modelMaxTokens: number | undefined, +): RunIsolatedCompletionParams["streamParams"] { + if (streamParams?.maxTokens === undefined || modelMaxTokens === undefined) { + return streamParams; + } + return { ...streamParams, maxTokens: Math.min(streamParams.maxTokens, modelMaxTokens) }; +} + +function selectIsolatedHarnessAuthPlan(attempt: PreparedAgentRuntimeAuthAttempt) { + if (attempt.kind !== "profile") { + return attempt.plan; + } + return { + ...attempt.plan, + forwardedAuthProfileId: attempt.profileId, + // Core owns candidate order. A harness receives one selected credential + // snapshot per call so it cannot inspect or reorder fallback profiles. + forwardedAuthProfileCandidateIds: [attempt.profileId], + }; +} + function requireIsolatedAssistantText(assistant: AssistantMessage): string { if (assistant.stopReason !== "stop" && assistant.stopReason !== "length") { throw new IsolatedCompletionError( @@ -325,13 +363,71 @@ function prepareIsolatedHarnessParams( }; } +function prepareIsolatedHarnessParamsV2( + harness: AgentHarness, + params: AgentHarnessIsolatedCompletionParamsV2, +): AgentHarnessIsolatedCompletionParamsV2 { + if (harness.id === "openclaw" || params.authorization.owner === "harness") { + return params; + } + const boundary = "plugin harness isolated completion handoff"; + const apiKey = params.authorization.auth.apiKey + ? unwrapSecretSentinelsForProviderEgress(params.authorization.auth.apiKey, boundary) + : params.authorization.auth.apiKey; + const model = unwrapModelHeaderSentinelsForProviderEgress(params.authorization.model, boundary); + if (apiKey === params.authorization.auth.apiKey && model === params.authorization.model) { + return params; + } + return { + ...params, + authorization: { + ...params.authorization, + model, + auth: { ...params.authorization.auth, apiKey }, + }, + }; +} + +async function prepareHostAuthorization(params: { + config: OpenClawConfig; + agentId: string; + agentDir: string; + provider: string; + modelId: string; + authProfileId?: string; +}): Promise> { + const prepared = await prepareSimpleCompletionModel({ + cfg: params.config, + agentId: params.agentId, + provider: params.provider, + modelId: params.modelId, + agentDir: params.agentDir, + profileId: params.authProfileId, + allowMissingApiKeyModes: ["aws-sdk"], + allowBundledStaticCatalogFallback: true, + skipAgentDiscovery: true, + bindAuthOwner: true, + }); + if ("error" in prepared) { + throw new Error(`Isolated completion preparation failed: ${prepared.error}`); + } + return { + owner: "host", + model: prepared.model, + auth: prepared.auth, + ...(prepared.sourceAuthFingerprint + ? { sourceAuthFingerprint: prepared.sourceAuthFingerprint } + : {}), + }; +} + /** Run one fresh completion without any model-callable tool surface or fallback. */ export async function runIsolatedCompletion( request: RunIsolatedCompletionParams, ): Promise { const config = request.config ?? {}; const agentId = request.agentId ?? resolveDefaultAgentId(config); - const agentDir = resolveAgentDir(config, agentId); + const agentDir = request.agentDir ?? resolveAgentDir(config, agentId); const workspaceDir = request.workspaceDir ?? resolveAgentWorkspaceDir(config, agentId); const provider = resolveCliRuntimeCanonicalProvider({ @@ -398,35 +494,15 @@ export async function runIsolatedCompletion( } const harness = await resolveHarness(runtime); - if (!harness.runIsolatedCompletion) { + if (!harness.runIsolatedCompletionV2 && !harness.runIsolatedCompletion) { throw new IsolatedCompletionError( "unsupported", `Agent harness ${harness.id} does not support isolated completion.`, ); } - const prepared = await prepareSimpleCompletionModel({ - cfg: config, - agentId, + const commonParams = { provider, modelId: request.model, - agentDir, - profileId: request.authProfileId, - allowMissingApiKeyModes: ["aws-sdk"], - allowBundledStaticCatalogFallback: true, - skipAgentDiscovery: true, - bindAuthOwner: true, - }); - if ("error" in prepared) { - throw new Error(`Isolated completion preparation failed: ${prepared.error}`); - } - const harnessParams: AgentHarnessIsolatedCompletionParams = { - provider, - modelId: request.model, - model: prepared.model, - auth: prepared.auth, - ...(prepared.sourceAuthFingerprint - ? { sourceAuthFingerprint: prepared.sourceAuthFingerprint } - : {}), config, agentId, agentDir, @@ -436,11 +512,164 @@ export async function runIsolatedCompletion( timeoutMs: request.timeoutMs, abortSignal: request.abortSignal, thinkLevel: request.thinkLevel, - streamParams: request.streamParams, }; - const result = await harness.runIsolatedCompletion( - prepareIsolatedHarnessParams(harness, harnessParams), - ); + let result: AgentHarnessIsolatedCompletionResult | undefined; + if (harness.runIsolatedCompletionV2) { + let modelMaxTokens: number | undefined; + let authProfileStore: ReturnType | undefined; + let authAttempts: readonly PreparedAgentRuntimeAuthAttempt[] | undefined; + if (harness.authBootstrap === "harness") { + const { modelRegistry } = lease.snapshot.createStores(); + const runtimeModel = resolveModelWithRegistry({ + provider, + modelId: request.model, + modelRegistry, + cfg: config, + }); + if (!runtimeModel) { + throw new IsolatedCompletionError( + "runtime-unavailable", + `Unknown isolated completion model ${provider}/${request.model}.`, + ); + } + modelMaxTokens = runtimeModel.maxTokens; + authProfileStore = ensureAuthProfileStore(agentDir, { + readOnly: true, + allowKeychainPrompt: false, + config, + }); + authAttempts = prepareAgentRuntimeAuth({ + provider: runtimeModel.provider, + modelId: runtimeModel.id, + modelApi: runtimeModel.api, + modelBaseUrl: runtimeModel.baseUrl, + config, + env: process.env, + agentDir, + workspaceDir, + authProfileStore, + sessionAuthProfileId: request.authProfileId, + sessionAuthProfileSource: request.authProfileId ? "user" : undefined, + harnessId: harness.id, + harnessRuntime: harness.id, + harnessAuthBootstrap: harness.authBootstrap, + }).attempts; + } + let firstError: unknown; + let priorProfileAttempted = false; + for (const preparedAttempt of authAttempts?.length ? authAttempts : [undefined]) { + const attempt: PreparedAgentRuntimeAuthAttempt | undefined = + preparedAttempt?.kind === "profile" + ? { ...preparedAttempt, plan: selectIsolatedHarnessAuthPlan(preparedAttempt) } + : preparedAttempt; + if ( + attempt && + !canRunPreparedAgentRuntimeAuthAttempt({ attempt, priorProfileAttempted }) + ) { + firstError ??= new Error("Prepared direct auth requires a prior profile attempt."); + continue; + } + if ( + attempt?.kind === "profile" && + authProfileStore && + !preparedAgentRuntimeProfileAttemptHasCandidate({ + attempt, + store: authProfileStore, + modelId: request.model, + }) + ) { + firstError ??= new Error( + "Prepared runtime auth candidates are temporarily unavailable.", + ); + continue; + } + try { + let authorization: AgentHarnessIsolatedCompletionAuthorization; + if ( + attempt?.plan.harnessAuthProvider && + attempt.plan.modelRoute?.authRequirement !== "api-key" && + authProfileStore + ) { + const plan = attempt.plan; + authorization = { + owner: "harness", + plan, + authProfileStore: scopeAuthProfileStoreToPreparedPlan(authProfileStore, plan), + }; + } else { + authorization = await prepareHostAuthorization({ + config, + agentId, + agentDir, + provider, + modelId: request.model, + authProfileId: + attempt?.kind === "profile" ? attempt.profileId : request.authProfileId, + }); + modelMaxTokens = authorization.model.maxTokens; + } + if ( + attempt?.kind === "profile" && + authProfileStore && + !preparedAgentRuntimeProfileAttemptHasCandidate({ + attempt, + store: authProfileStore, + modelId: request.model, + }) + ) { + throw new Error("Prepared runtime auth candidates are temporarily unavailable."); + } + const pending = harness.runIsolatedCompletionV2( + prepareIsolatedHarnessParamsV2(harness, { + ...commonParams, + authorization, + streamParams: clampIsolatedStreamParams(request.streamParams, modelMaxTokens), + }), + ); + priorProfileAttempted ||= attempt?.kind === "profile"; + result = await pending; + break; + } catch (error) { + if (request.abortSignal?.aborted) { + throw error; + } + firstError ??= error; + } + } + if (!result) { + if (firstError instanceof Error) { + throw firstError; + } + throw new Error("No prepared auth attempt succeeded.", { cause: firstError }); + } + } else { + const authorization = await prepareHostAuthorization({ + config, + agentId, + agentDir, + provider, + modelId: request.model, + authProfileId: request.authProfileId, + }); + const harnessParams: AgentHarnessIsolatedCompletionParams = { + ...commonParams, + streamParams: clampIsolatedStreamParams( + request.streamParams, + authorization.model.maxTokens, + ), + model: authorization.model, + auth: authorization.auth, + ...(authorization.sourceAuthFingerprint + ? { sourceAuthFingerprint: authorization.sourceAuthFingerprint } + : {}), + }; + result = await harness.runIsolatedCompletion!( + prepareIsolatedHarnessParams(harness, harnessParams), + ); + } + if (!result) { + throw new IsolatedCompletionError("runtime-unavailable", "Isolated completion failed."); + } return { text: requireIsolatedAssistantText(result.assistant), provider: result.assistant.provider, diff --git a/src/auto-reply/reply/conversation-label-generator.test.ts b/src/auto-reply/reply/conversation-label-generator.test.ts index f23628091396..24f585e77c34 100644 --- a/src/auto-reply/reply/conversation-label-generator.test.ts +++ b/src/auto-reply/reply/conversation-label-generator.test.ts @@ -1,330 +1,140 @@ /** Tests generated conversation labels for reply sessions. */ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; -const completeWithPreparedSimpleCompletionModel = vi.hoisted(() => vi.fn()); -const logVerbose = vi.hoisted(() => vi.fn()); -const prepareSimpleCompletionModelForAgent = vi.hoisted(() => vi.fn()); +const runIsolatedCompletion = vi.hoisted(() => vi.fn()); const resolveSimpleCompletionSelectionForAgent = vi.hoisted(() => vi.fn()); +vi.mock("../../agents/isolated-completion.js", () => ({ runIsolatedCompletion })); vi.mock("../../agents/simple-completion-runtime.js", () => ({ - completeWithPreparedSimpleCompletionModel, - prepareSimpleCompletionModelForAgent, resolveSimpleCompletionSelectionForAgent, })); -vi.mock("../../globals.js", () => ({ logVerbose })); - import { generateConversationLabel, generateConversationLabelWithFallback, } from "./conversation-label-generator.js"; -function firstCompletionArgs() { - const call = completeWithPreparedSimpleCompletionModel.mock.calls.at(0); - if (!call) { - throw new Error("expected simple completion call"); - } - return call[0]; +function resolveSelection({ modelRef, useUtilityModel, agentDir }: Record) { + const ref = + typeof modelRef === "string" + ? modelRef + : useUtilityModel + ? "openai/gpt-mini@work" + : "openai/gpt-main@work"; + const [rawModel, profileId] = ref.split("@"); + const model = rawModel ?? ""; + const slash = model.indexOf("/"); + return { + provider: model.slice(0, slash), + modelId: model.slice(slash + 1), + profileId, + agentDir: typeof agentDir === "string" ? agentDir : "/tmp/openclaw-agent", + }; } describe("generateConversationLabel", () => { beforeEach(() => { - completeWithPreparedSimpleCompletionModel.mockReset(); - logVerbose.mockReset(); - prepareSimpleCompletionModelForAgent.mockReset(); - - prepareSimpleCompletionModelForAgent.mockResolvedValue({ - selection: { - provider: "openai", - modelId: "gpt-test", - agentDir: "/tmp/openclaw-agent", - }, - model: { provider: "openai", id: "gpt-test", maxTokens: 8192 }, - auth: { apiKey: "resolved-key", mode: "api-key" }, - }); - completeWithPreparedSimpleCompletionModel.mockResolvedValue({ - content: [{ type: "text", text: "Topic label" }], - }); + runIsolatedCompletion.mockReset(); + resolveSimpleCompletionSelectionForAgent.mockReset(); + resolveSimpleCompletionSelectionForAgent.mockImplementation(resolveSelection); + runIsolatedCompletion.mockResolvedValue({ text: "Topic label" }); }); - afterEach(() => { - vi.useRealTimers(); - }); - - it("prepares the configured utility model in the routed agent directory", async () => { - const cfg = { agents: { defaults: { utilityModel: "openai/gpt-test" } } }; - - await generateConversationLabel({ - userMessage: "Need help with invoices", - prompt: "prompt", - cfg, - agentId: "billing", - agentDir: "/tmp/agents/billing/agent", - }); - - expect(prepareSimpleCompletionModelForAgent).toHaveBeenCalledWith({ - cfg, - agentId: "billing", - agentDir: "/tmp/agents/billing/agent", - useUtilityModel: true, - useAsyncModelResolution: true, - allowMissingApiKeyModes: ["aws-sdk"], - }); - }); - - it("passes the label prompt and a reasoning-safe bounded completion budget", async () => { - vi.useFakeTimers(); - vi.setSystemTime(1_710_000_000_000); - const cfg = {}; - - await generateConversationLabel({ - userMessage: "Need help with invoices", - prompt: "Generate a label", - cfg, - }); - - expect(firstCompletionArgs()).toMatchObject({ - model: { provider: "openai", id: "gpt-test" }, - auth: { apiKey: "resolved-key", mode: "api-key" }, - cfg, - context: { - systemPrompt: "Generate a label", - messages: [ - { - role: "user", - content: "Need help with invoices", - timestamp: 1_710_000_000_000, - }, - ], - }, - options: { - maxTokens: 4_096, - temperature: 0.3, - }, - }); - expect(firstCompletionArgs().options.signal).toBeInstanceOf(AbortSignal); - }); - - it("caps the completion budget at the model output limit", async () => { - prepareSimpleCompletionModelForAgent.mockResolvedValue({ - selection: { - provider: "openai", - modelId: "gpt-test", - agentDir: "/tmp/openclaw-agent", - }, - model: { provider: "openai", id: "gpt-test", maxTokens: 1_024 }, - auth: { apiKey: "resolved-key", mode: "api-key" }, - }); - - await generateConversationLabel({ - userMessage: "test topic creation", - prompt: "Generate a label", - cfg: {}, - }); - - expect(firstCompletionArgs().options.maxTokens).toBe(1_024); - }); - - it("omits temperature for Codex Responses simple completions", async () => { - prepareSimpleCompletionModelForAgent.mockResolvedValue({ - selection: { - provider: "openai", - modelId: "gpt-5.5", - agentDir: "/tmp/openclaw-agent", - }, - model: { - provider: "openai", - id: "gpt-5.5", - api: "openai-chatgpt-responses", - maxTokens: 8192, - }, - auth: { apiKey: "resolved-key", mode: "api-key" }, - }); - - await generateConversationLabel({ - userMessage: "test topic creation", - prompt: "Generate a label", - cfg: {}, - }); - - expect(firstCompletionArgs().options).not.toHaveProperty("temperature"); - }); - - it("returns null when utility model preparation fails", async () => { - prepareSimpleCompletionModelForAgent.mockResolvedValue({ - error: 'No API key resolved for provider "openai".', - }); + it("routes the utility model through isolated completion with the selected auth owner", async () => { + const cfg = { agents: { defaults: { utilityModel: "openai/gpt-mini" } } }; await expect( generateConversationLabel({ userMessage: "Need help with invoices", prompt: "Generate a label", - cfg: {}, - }), - ).resolves.toBeNull(); - - expect(logVerbose).toHaveBeenCalledWith( - 'conversation-label-generator: No API key resolved for provider "openai".', - ); - expect(completeWithPreparedSimpleCompletionModel).not.toHaveBeenCalled(); - }); - - it("falls back to the primary model when utility model preparation fails", async () => { - prepareSimpleCompletionModelForAgent - .mockResolvedValueOnce({ - error: 'No API key resolved for provider "openai".', - selection: { - provider: "openai", - modelId: "gpt-5.6-luna", - agentDir: "/tmp/openclaw-agent", - }, - }) - .mockResolvedValueOnce({ - selection: { - provider: "openai", - modelId: "gpt-5.6-sol", - agentDir: "/tmp/openclaw-agent", - }, - model: { provider: "openai", id: "gpt-5.6-sol", maxTokens: 8192 }, - auth: { apiKey: "test-api-key", mode: "api-key" }, - }); - - await expect( - generateConversationLabel({ - userMessage: "Need help with invoices", - prompt: "Generate a label", - cfg: {}, + cfg, + agentId: "billing", + agentDir: "/tmp/agents/billing/agent", }), ).resolves.toBe("Topic label"); - expect(prepareSimpleCompletionModelForAgent).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ useUtilityModel: false }), - ); - expect(completeWithPreparedSimpleCompletionModel).toHaveBeenCalledOnce(); + expect(runIsolatedCompletion).toHaveBeenCalledWith({ + config: cfg, + provider: "openai", + model: "gpt-mini", + authProfileId: "work", + agentId: "billing", + agentDir: "/tmp/agents/billing/agent", + systemPrompt: "Generate a label", + prompt: "Need help with invoices", + timeoutMs: 15_000, + streamParams: { maxTokens: 4_096 }, + }); }); - it("falls back to the primary model when the utility completion fails", async () => { - prepareSimpleCompletionModelForAgent - .mockResolvedValueOnce({ - selection: { - provider: "openai", - modelId: "gpt-5.6-luna", - agentDir: "/tmp/openclaw-agent", - }, - model: { provider: "openai", id: "gpt-5.6-luna", maxTokens: 8192 }, - auth: { apiKey: "test-api-key", mode: "oauth" }, - }) - .mockResolvedValueOnce({ - selection: { - provider: "openai", - modelId: "gpt-5.6-sol", - agentDir: "/tmp/openclaw-agent", - }, - model: { provider: "openai", id: "gpt-5.6-sol", maxTokens: 8192 }, - auth: { apiKey: "test-api-key", mode: "oauth" }, - }); - completeWithPreparedSimpleCompletionModel - .mockResolvedValueOnce({ - content: [], - stopReason: "error", - errorMessage: "utility unavailable", - }) - .mockResolvedValueOnce({ content: [{ type: "text", text: "Primary title" }] }); + it("uses one explicit model and timeout when supplied", async () => { + await generateConversationLabel({ + userMessage: "Message", + prompt: "Prompt", + cfg: {}, + modelRef: "anthropic/claude-haiku@team", + timeoutMs: 900, + }); + + expect(runIsolatedCompletion).toHaveBeenCalledOnce(); + expect(runIsolatedCompletion).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "anthropic", + model: "claude-haiku", + authProfileId: "team", + timeoutMs: 900, + }), + ); + }); + + it("falls back to the primary after a utility failure", async () => { + runIsolatedCompletion + .mockRejectedValueOnce(new Error("utility unavailable")) + .mockResolvedValueOnce({ text: "Primary title" }); await expect( - generateConversationLabel({ - userMessage: "Need help with invoices", - prompt: "Generate a label", - cfg: {}, - }), + generateConversationLabel({ userMessage: "Message", prompt: "Prompt", cfg: {} }), ).resolves.toBe("Primary title"); - expect(completeWithPreparedSimpleCompletionModel).toHaveBeenCalledTimes(2); + expect(runIsolatedCompletion).toHaveBeenCalledTimes(2); + expect(runIsolatedCompletion.mock.calls[1]?.[0]?.model).toBe("gpt-main"); }); - it("does not call the same primary model twice when utility routing resolves to it", async () => { - completeWithPreparedSimpleCompletionModel.mockResolvedValue({ - content: [], - stopReason: "error", - errorMessage: "primary unavailable", - }); + it("throws a sanitized error after every configured attempt fails", async () => { + runIsolatedCompletion.mockRejectedValue(new Error("secret-bearing provider failure")); await expect( - generateConversationLabel({ - userMessage: "Need help with invoices", - prompt: "Generate a label", - cfg: {}, - }), + generateConversationLabel({ userMessage: "Message", prompt: "Prompt", cfg: {} }), + ).rejects.toThrow("conversation label generation failed (utility, primary fallback)"); + }); + + it("deduplicates utility and primary when they resolve to the same owner", async () => { + resolveSimpleCompletionSelectionForAgent.mockReturnValue({ + provider: "openai", + modelId: "same-model", + profileId: "work", + agentDir: "/tmp/openclaw-agent", + }); + runIsolatedCompletion.mockResolvedValue({ text: "" }); + + await expect( + generateConversationLabel({ userMessage: "Message", prompt: "Prompt", cfg: {} }), ).resolves.toBeNull(); - - expect(prepareSimpleCompletionModelForAgent).toHaveBeenCalledTimes(2); - expect(completeWithPreparedSimpleCompletionModel).toHaveBeenCalledOnce(); + expect(runIsolatedCompletion).toHaveBeenCalledOnce(); }); - it("logs completion errors instead of treating them as empty labels", async () => { - completeWithPreparedSimpleCompletionModel.mockResolvedValue({ - content: [], - stopReason: "error", - errorMessage: "Codex error: Instructions are required", - }); - - const label = await generateConversationLabel({ - userMessage: "Need help with invoices", - prompt: "Generate a label", - cfg: {}, - }); - - expect(label).toBeNull(); - expect(logVerbose).toHaveBeenCalledWith( - "conversation-label-generator: completion failed: Codex error: Instructions are required", - ); - }); - - it("bounds the generated label length", async () => { - completeWithPreparedSimpleCompletionModel.mockResolvedValue({ - content: [{ type: "text", text: "A very long generated topic label" }], - }); + it("bounds labels without splitting surrogate pairs", async () => { + runIsolatedCompletion.mockResolvedValue({ text: `${"a".repeat(11)}😀tail` }); await expect( generateConversationLabel({ - userMessage: "Need help with invoices", - prompt: "Generate a label", - cfg: {}, - maxLength: 12, - }), - ).resolves.toBe("A very long "); - }); - - it("drops a split emoji instead of returning a lone surrogate", async () => { - completeWithPreparedSimpleCompletionModel.mockResolvedValue({ - content: [{ type: "text", text: `${"a".repeat(11)}😀tail` }], - }); - - await expect( - generateConversationLabel({ - userMessage: "Need help with invoices", - prompt: "Generate a label", + userMessage: "Message", + prompt: "Prompt", cfg: {}, maxLength: 12, }), ).resolves.toBe("a".repeat(11)); }); - - it("returns null when the length cap cannot retain the first emoji", async () => { - completeWithPreparedSimpleCompletionModel.mockResolvedValue({ - content: [{ type: "text", text: "😀 label" }], - }); - - await expect( - generateConversationLabel({ - userMessage: "Need help with invoices", - prompt: "Generate a label", - cfg: {}, - maxLength: 1, - }), - ).resolves.toBeNull(); - }); }); describe("generateConversationLabelWithFallback", () => { @@ -339,292 +149,78 @@ describe("generateConversationLabelWithFallback", () => { }; beforeEach(() => { - completeWithPreparedSimpleCompletionModel.mockReset(); - logVerbose.mockReset(); - prepareSimpleCompletionModelForAgent.mockReset(); + runIsolatedCompletion.mockReset(); resolveSimpleCompletionSelectionForAgent.mockReset(); - resolveSimpleCompletionSelectionForAgent.mockImplementation(({ modelRef }) => { - const [model, profileId] = modelRef.split("@"); - const slash = model.indexOf("/"); - return { - provider: model.slice(0, slash), - modelId: model.slice(slash + 1), - profileId, - agentDir: "/tmp/openclaw-agent", - }; - }); - prepareSimpleCompletionModelForAgent.mockImplementation(async ({ modelRef }) => { - const [model] = modelRef.split("@"); - const slash = model.indexOf("/"); - return { - selection: { - provider: model.slice(0, slash), - modelId: model.slice(slash + 1), - profileId: "work", - agentDir: "/tmp/openclaw-agent", - }, - model: { - provider: model.slice(0, slash), - id: model.slice(slash + 1), - maxTokens: 8192, - }, - auth: { apiKey: "resolved-key", mode: "api-key" }, - }; - }); - completeWithPreparedSimpleCompletionModel.mockResolvedValue({ - content: [{ type: "text", text: "Utility title" }], - }); + resolveSimpleCompletionSelectionForAgent.mockImplementation(resolveSelection); + runIsolatedCompletion.mockResolvedValue({ text: "Utility title" }); }); - afterEach(() => { - vi.useRealTimers(); - }); - - it("uses the utility candidate once with the selected auth owner", async () => { + it("uses the utility candidate once", async () => { await expect(generateConversationLabelWithFallback(params)).resolves.toBe("Utility title"); - - expect(prepareSimpleCompletionModelForAgent).toHaveBeenCalledOnce(); - expect(prepareSimpleCompletionModelForAgent).toHaveBeenCalledWith({ - cfg: {}, - agentId: "billing", - agentDir: undefined, - modelRef: "openai/gpt-mini@work", - bindAuthOwner: true, - useAsyncModelResolution: true, - allowMissingApiKeyModes: ["aws-sdk"], + expect(runIsolatedCompletion).toHaveBeenCalledOnce(); + expect(runIsolatedCompletion.mock.calls[0]?.[0]).toMatchObject({ + provider: "openai", + model: "gpt-mini", + authProfileId: "work", }); }); it("locks an inherited profile onto a same-provider utility ref", async () => { - await expect( - generateConversationLabelWithFallback({ - ...params, - utilityModelRef: "openai/gpt-mini", - }), - ).resolves.toBe("Utility title"); + await generateConversationLabelWithFallback({ ...params, utilityModelRef: "openai/gpt-mini" }); - expect(prepareSimpleCompletionModelForAgent.mock.calls[0]?.[0]).toMatchObject({ - modelRef: "openai/gpt-mini@work", - bindAuthOwner: true, - }); - expect(prepareSimpleCompletionModelForAgent.mock.calls[0]?.[0]).not.toHaveProperty( - "preferredProfile", + expect(resolveSimpleCompletionSelectionForAgent).toHaveBeenCalledWith( + expect.objectContaining({ modelRef: "openai/gpt-mini@work" }), ); + expect(runIsolatedCompletion.mock.calls[0]?.[0]?.authProfileId).toBe("work"); }); - it("does not force the regular profile onto a cross-provider utility model", async () => { - await expect( - generateConversationLabelWithFallback({ - ...params, - utilityModelRef: "anthropic/claude-haiku-4-5", - }), - ).resolves.toBe("Utility title"); - - expect(prepareSimpleCompletionModelForAgent.mock.calls[0]?.[0]).toEqual({ - cfg: {}, - agentId: "billing", - agentDir: undefined, - modelRef: "anthropic/claude-haiku-4-5", - bindAuthOwner: true, - useAsyncModelResolution: true, - allowMissingApiKeyModes: ["aws-sdk"], - }); - }); - - it("does not inherit profiles across logical providers sharing one runtime", async () => { - resolveSimpleCompletionSelectionForAgent.mockImplementation(({ modelRef }) => ({ - provider: modelRef.startsWith("anthropic/") ? "anthropic" : "openai", - runtimeProvider: "openai", - modelId: modelRef.split("/").slice(1).join("/"), - agentDir: "/tmp/openclaw-agent", - })); - - await expect( - generateConversationLabelWithFallback({ - ...params, - utilityModelRef: "anthropic/claude-haiku-4-5", - }), - ).resolves.toBe("Utility title"); - - expect(prepareSimpleCompletionModelForAgent.mock.calls[0]?.[0]).not.toHaveProperty( - "preferredProfile", - ); - expect(prepareSimpleCompletionModelForAgent.mock.calls[0]?.[0]?.modelRef).toBe( - "anthropic/claude-haiku-4-5", - ); - }); - - it("falls back when utility preparation fails", async () => { - prepareSimpleCompletionModelForAgent.mockResolvedValueOnce({ error: "missing auth" }); - completeWithPreparedSimpleCompletionModel.mockResolvedValueOnce({ - content: [{ type: "text", text: "Regular title" }], + it("does not inherit a profile across providers", async () => { + await generateConversationLabelWithFallback({ + ...params, + utilityModelRef: "anthropic/claude-haiku", }); - await expect(generateConversationLabelWithFallback(params)).resolves.toBe("Regular title"); - - expect(prepareSimpleCompletionModelForAgent).toHaveBeenCalledTimes(2); - expect(prepareSimpleCompletionModelForAgent.mock.calls[1]?.[0]?.modelRef).toBe( - "openai/gpt-main@work", - ); + expect(runIsolatedCompletion.mock.calls[0]?.[0]).toMatchObject({ + provider: "anthropic", + model: "claude-haiku", + }); + expect(runIsolatedCompletion.mock.calls[0]?.[0]?.authProfileId).toBeUndefined(); }); - it.each([ - { - name: "error stop reason", - first: { content: [], stopReason: "error", errorMessage: "utility failed" }, - }, - { name: "empty output", first: { content: [] } }, - ])("falls back after utility $name", async ({ first }) => { - completeWithPreparedSimpleCompletionModel - .mockResolvedValueOnce(first) - .mockResolvedValueOnce({ content: [{ type: "text", text: "Regular title" }] }); - - await expect(generateConversationLabelWithFallback(params)).resolves.toBe("Regular title"); - - expect(completeWithPreparedSimpleCompletionModel).toHaveBeenCalledTimes(2); - }); - - it("falls back when utility output fails operation-specific normalization", async () => { - completeWithPreparedSimpleCompletionModel - .mockResolvedValueOnce({ content: [{ type: "text", text: "Title:" }] }) - .mockResolvedValueOnce({ content: [{ type: "text", text: "Regular title" }] }); + it("records an exhausted failure after fallback normalization rejects the result", async () => { + runIsolatedCompletion + .mockRejectedValueOnce(new Error("utility unavailable")) + .mockResolvedValueOnce({ text: "Title:" }); await expect( generateConversationLabelWithFallback({ ...params, normalizeLabel: (label) => (label === "Title:" ? null : label), }), - ).resolves.toBe("Regular title"); + ).rejects.toThrow("conversation label generation failed (utility)"); + expect(runIsolatedCompletion).toHaveBeenCalledTimes(2); }); - it("falls back after a utility completion exception", async () => { - completeWithPreparedSimpleCompletionModel - .mockRejectedValueOnce(new Error("transport failed")) - .mockResolvedValueOnce({ content: [{ type: "text", text: "Regular title" }] }); - - await expect(generateConversationLabelWithFallback(params)).resolves.toBe("Regular title"); - }); - - it("falls back after the utility attempt times out", async () => { - vi.useFakeTimers(); - completeWithPreparedSimpleCompletionModel - .mockImplementationOnce( - ({ options }) => - new Promise((_resolve, reject) => { - options.signal.addEventListener("abort", () => reject(new Error("aborted"))); - }), - ) - .mockResolvedValueOnce({ content: [{ type: "text", text: "Regular title" }] }); - - const generated = generateConversationLabelWithFallback(params); - await vi.advanceTimersByTimeAsync(15_000); - - await expect(generated).resolves.toBe("Regular title"); - }); - - it("returns null when both explicit candidates fail", async () => { - prepareSimpleCompletionModelForAgent - .mockResolvedValueOnce({ error: "utility auth failed" }) - .mockResolvedValueOnce({ error: "regular auth failed" }); - - await expect(generateConversationLabelWithFallback(params)).resolves.toBeNull(); - - expect(prepareSimpleCompletionModelForAgent).toHaveBeenCalledTimes(2); - expect(completeWithPreparedSimpleCompletionModel).not.toHaveBeenCalled(); - }); - - it("skips a regular candidate that resolves to the same model and profile", async () => { - resolveSimpleCompletionSelectionForAgent.mockReturnValue({ - provider: "openai", - modelId: "same-model", - profileId: "work", - agentDir: "/tmp/openclaw-agent", - }); - completeWithPreparedSimpleCompletionModel.mockResolvedValue({ content: [] }); - - await expect(generateConversationLabelWithFallback(params)).resolves.toBeNull(); - - expect(prepareSimpleCompletionModelForAgent).toHaveBeenCalledOnce(); - expect(completeWithPreparedSimpleCompletionModel).toHaveBeenCalledOnce(); - }); - - it("deduplicates candidates after asynchronous preparation resolves them identically", async () => { - prepareSimpleCompletionModelForAgent.mockResolvedValue({ - selection: { - provider: "openai", - modelId: "resolved-same-model", - profileId: "work", - agentDir: "/tmp/openclaw-agent", - }, - model: { provider: "openai", id: "resolved-same-model", maxTokens: 8192 }, - auth: { apiKey: "resolved-key", mode: "api-key" }, - }); - completeWithPreparedSimpleCompletionModel.mockResolvedValue({ content: [] }); - - await expect(generateConversationLabelWithFallback(params)).resolves.toBeNull(); - - expect(prepareSimpleCompletionModelForAgent).toHaveBeenCalledTimes(2); - expect(completeWithPreparedSimpleCompletionModel).toHaveBeenCalledOnce(); - }); - - it("inherits the regular profile for unresolved same-provider utility refs", async () => { - resolveSimpleCompletionSelectionForAgent.mockReturnValue(null); + it("keeps an explicit runtime owner across utility and primary attempts", async () => { + runIsolatedCompletion + .mockRejectedValueOnce(new Error("utility unavailable")) + .mockResolvedValueOnce({ text: "Primary title" }); await expect( generateConversationLabelWithFallback({ ...params, - utilityModelRef: "openai/gpt-mini", + agentHarnessRuntimeOverride: "codex", }), - ).resolves.toBe("Utility title"); + ).resolves.toBe("Primary title"); - expect(prepareSimpleCompletionModelForAgent.mock.calls[0]?.[0]).toMatchObject({ - modelRef: "openai/gpt-mini@work", - bindAuthOwner: true, - }); - expect(prepareSimpleCompletionModelForAgent.mock.calls[0]?.[0]).not.toHaveProperty( - "preferredProfile", - ); + expect( + runIsolatedCompletion.mock.calls.map(([request]) => request.agentHarnessRuntimeOverride), + ).toEqual(["codex", "codex"]); }); - it("does not inherit the regular profile for unresolved cross-provider utility refs", async () => { - resolveSimpleCompletionSelectionForAgent.mockReturnValue(null); - - await expect( - generateConversationLabelWithFallback({ - ...params, - utilityModelRef: "anthropic/claude-haiku-4-5", - }), - ).resolves.toBe("Utility title"); - - expect(prepareSimpleCompletionModelForAgent.mock.calls[0]?.[0]).not.toHaveProperty( - "preferredProfile", - ); - }); - - it("deduplicates identical raw refs when selection resolution is unavailable", async () => { - resolveSimpleCompletionSelectionForAgent.mockReturnValue(null); - completeWithPreparedSimpleCompletionModel.mockResolvedValue({ content: [] }); - - await expect( - generateConversationLabelWithFallback({ - ...params, - utilityModelRef: params.regularModelRef, - }), - ).resolves.toBeNull(); - - expect(prepareSimpleCompletionModelForAgent).toHaveBeenCalledOnce(); - }); - - it("uses the regular candidate directly when no utility model is available", async () => { + it("uses the regular candidate directly when no utility model exists", async () => { const { utilityModelRef: _utilityModelRef, ...regularOnlyParams } = params; - - await expect(generateConversationLabelWithFallback(regularOnlyParams)).resolves.toBe( - "Utility title", - ); - - expect(prepareSimpleCompletionModelForAgent).toHaveBeenCalledOnce(); - expect(prepareSimpleCompletionModelForAgent.mock.calls[0]?.[0]?.modelRef).toBe( - "openai/gpt-main@work", - ); + await generateConversationLabelWithFallback(regularOnlyParams); + expect(runIsolatedCompletion.mock.calls[0]?.[0]?.model).toBe("gpt-main"); }); }); diff --git a/src/auto-reply/reply/conversation-label-generator.ts b/src/auto-reply/reply/conversation-label-generator.ts index 59d428539b0c..a4e2eb4718f7 100644 --- a/src/auto-reply/reply/conversation-label-generator.ts +++ b/src/auto-reply/reply/conversation-label-generator.ts @@ -1,31 +1,21 @@ // Generates short labels for sessions from conversation context. import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import { resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { runIsolatedCompletion } from "../../agents/isolated-completion.js"; import { splitTrailingAuthProfile } from "../../agents/model-ref-profile.js"; -import { - completeWithPreparedSimpleCompletionModel, - prepareSimpleCompletionModelForAgent, - resolveSimpleCompletionSelectionForAgent, -} from "../../agents/simple-completion-runtime.js"; +import { resolveSimpleCompletionSelectionForAgent } from "../../agents/simple-completion-runtime.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; -import { logVerbose } from "../../globals.js"; -import type { TextContent } from "../../llm/types.js"; const DEFAULT_MAX_LABEL_LENGTH = 128; // Reasoning models spend output tokens before emitting the short visible label. -// A tiny cap can leave no text, so keep the bounded title budget large enough -// for reasoning while respecting models with a lower output limit. const CONVERSATION_LABEL_MAX_TOKENS = 4_096; const TIMEOUT_MS = 15_000; -type PreparedLabelModel = Awaited>; -type ReadyLabelModel = Extract; type LabelModelPhase = "utility" | "primary fallback"; type ConversationLabelAttempt = { modelRef?: string; useUtilityModel?: boolean; preferredProfile?: string; - bindAuthOwner?: boolean; }; /** Inputs for generating a short conversation label from the configured utility model. */ @@ -35,6 +25,9 @@ export type ConversationLabelParams = { cfg: OpenClawConfig; agentId?: string; agentDir?: string; + agentHarnessRuntimeOverride?: string; + modelRef?: string; + timeoutMs?: number; maxLength?: number; }; @@ -45,84 +38,16 @@ type ConversationLabelFallbackParams = ConversationLabelParams & { normalizeLabel?: (label: string) => string | null; }; -function isTextContentBlock(block: { type: string }): block is TextContent { - return block.type === "text"; -} - -function isCodexSimpleCompletionModel(model: { api?: string; provider?: string }): boolean { - return model.api === "openai-chatgpt-responses"; -} - -function extractSimpleCompletionError(result: { - stopReason?: string; - errorMessage?: string; -}): string | null { - if (result.stopReason !== "error") { - return null; - } - return result.errorMessage?.trim() || "unknown error"; -} - function resolveMaxLabelLength(value: number | undefined): number { return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : DEFAULT_MAX_LABEL_LENGTH; } -function logLabelFailure(phase: LabelModelPhase, message: string): void { - const prefix = phase === "utility" ? "" : `${phase} `; - logVerbose(`conversation-label-generator: ${prefix}${message}`); -} - -async function prepareLabelModel(params: { - cfg: OpenClawConfig; - agentId: string; - agentDir?: string; - attempt: ConversationLabelAttempt; - phase: LabelModelPhase; -}): Promise { - try { - const prepared = await prepareSimpleCompletionModelForAgent({ - cfg: params.cfg, - agentId: params.agentId, - agentDir: params.agentDir, - ...(params.attempt.modelRef ? { modelRef: params.attempt.modelRef } : {}), - ...(params.attempt.useUtilityModel !== undefined - ? { useUtilityModel: params.attempt.useUtilityModel } - : {}), - ...(params.attempt.preferredProfile - ? { preferredProfile: params.attempt.preferredProfile } - : {}), - ...(params.attempt.bindAuthOwner !== undefined - ? { bindAuthOwner: params.attempt.bindAuthOwner } - : {}), - useAsyncModelResolution: true, - allowMissingApiKeyModes: ["aws-sdk"], - }); - if ("error" in prepared) { - logLabelFailure(params.phase, prepared.error); - } - return prepared; - } catch (err) { - logLabelFailure(params.phase, `model preparation failed: ${String(err)}`); - return null; - } -} - -function selectedLabelModelsMatch( - first: PreparedLabelModel | null, - second: PreparedLabelModel | null, -): boolean { - const firstSelection = first && "selection" in first ? first.selection : undefined; - const secondSelection = second && "selection" in second ? second.selection : undefined; - return Boolean( - firstSelection && - secondSelection && - firstSelection.provider === secondSelection.provider && - firstSelection.runtimeProvider === secondSelection.runtimeProvider && - firstSelection.modelId === secondSelection.modelId && - firstSelection.profileId === secondSelection.profileId, - ); +function resolveTimeoutMs(value: number | undefined): number { + return typeof value === "number" && Number.isFinite(value) && value > 0 + ? Math.floor(value) + : TIMEOUT_MS; } function resolveAttemptSelection(params: { @@ -170,111 +95,96 @@ function resolveAttemptKey(params: { } async function completeLabel(params: { - prepared: ReadyLabelModel; cfg: OpenClawConfig; + agentId: string; + agentDir?: string; + agentHarnessRuntimeOverride?: string; + attempt: ConversationLabelAttempt; userMessage: string; prompt: string; + timeoutMs: number; maxLength: number; - phase: LabelModelPhase; }): Promise { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS); - try { - const maxTokens = Math.min( - CONVERSATION_LABEL_MAX_TOKENS, - Math.floor(params.prepared.model.maxTokens), - ); - // Label generation should never block normal reply handling for long. - const result = await completeWithPreparedSimpleCompletionModel({ - model: params.prepared.model, - auth: params.prepared.auth, - cfg: params.cfg, - context: { - systemPrompt: params.prompt, - messages: [ - { - role: "user", - content: params.userMessage, - timestamp: Date.now(), - }, - ], - }, - options: { - maxTokens, - ...(isCodexSimpleCompletionModel(params.prepared.model) ? {} : { temperature: 0.3 }), - signal: controller.signal, - }, - }); - const errorMessage = extractSimpleCompletionError(result); - if (errorMessage) { - logLabelFailure(params.phase, `completion failed: ${errorMessage}`); - return null; - } - - const text = result.content - .filter(isTextContentBlock) - .map((block) => block.text) - .join("") - .trim(); - return text ? truncateUtf16Safe(text, params.maxLength) || null : null; - } catch (err) { - logLabelFailure(params.phase, `completion failed: ${String(err)}`); - return null; - } finally { - clearTimeout(timeout); + const selection = resolveAttemptSelection(params); + if (!selection) { + throw new Error("conversation label model selection unavailable"); } + const completion = await runIsolatedCompletion({ + config: params.cfg, + provider: selection.runtimeProvider ?? selection.provider, + model: selection.modelId, + authProfileId: selection.profileId ?? params.attempt.preferredProfile, + agentId: params.agentId, + agentDir: params.agentDir ?? selection.agentDir, + ...(params.agentHarnessRuntimeOverride + ? { agentHarnessRuntimeOverride: params.agentHarnessRuntimeOverride } + : {}), + systemPrompt: params.prompt, + prompt: params.userMessage, + timeoutMs: params.timeoutMs, + streamParams: { maxTokens: CONVERSATION_LABEL_MAX_TOKENS }, + }); + return truncateUtf16Safe(completion.text.trim(), params.maxLength) || null; } -/** Generates a bounded human-readable label for a session, or null on failure. */ +async function runLabelAttempts(params: { + cfg: OpenClawConfig; + agentId: string; + agentDir?: string; + agentHarnessRuntimeOverride?: string; + attempts: readonly ConversationLabelAttempt[]; + userMessage: string; + prompt: string; + timeoutMs: number; + maxLength: number; + normalizeLabel?: (label: string) => string | null; +}): Promise { + const seen = new Set(); + const failures: LabelModelPhase[] = []; + for (const [index, attempt] of params.attempts.entries()) { + const key = resolveAttemptKey({ ...params, attempt }); + if (seen.has(key)) { + continue; + } + seen.add(key); + try { + const label = await completeLabel({ ...params, attempt }); + const normalized = label && params.normalizeLabel ? params.normalizeLabel(label) : label; + if (normalized) { + return normalized; + } + } catch { + failures.push(index === params.attempts.length - 1 ? "primary fallback" : "utility"); + } + } + if (failures.length > 0) { + // Keep provider errors and credentials out of logs while still recording the + // owned operation that failed after every configured route was exhausted. + throw new Error(`conversation label generation failed (${failures.join(", ")})`); + } + return null; +} + +/** Generates a bounded human-readable label for a session, or null for empty output. */ export async function generateConversationLabel( params: ConversationLabelParams, ): Promise { - const { userMessage, prompt, cfg, agentId, agentDir } = params; - const maxLength = resolveMaxLabelLength(params.maxLength); - const resolvedAgentId = agentId ?? resolveDefaultAgentId(cfg); - const utilityPrepared = await prepareLabelModel({ - cfg, - agentId: resolvedAgentId, - agentDir, - attempt: { useUtilityModel: true }, - phase: "utility", - }); - const utilityCompletionAttempted = Boolean(utilityPrepared && !("error" in utilityPrepared)); - if (utilityPrepared && !("error" in utilityPrepared)) { - const label = await completeLabel({ - prepared: utilityPrepared, - cfg, - userMessage, - prompt, - maxLength, - phase: "utility", - }); - if (label) { - return label; - } - } - - const primaryPrepared = await prepareLabelModel({ - cfg, - agentId: resolvedAgentId, - agentDir, - attempt: { useUtilityModel: false }, - phase: "primary fallback", - }); - if ( - !primaryPrepared || - "error" in primaryPrepared || - (utilityCompletionAttempted && selectedLabelModelsMatch(utilityPrepared, primaryPrepared)) - ) { - return null; - } - return await completeLabel({ - prepared: primaryPrepared, - cfg, - userMessage, - prompt, - maxLength, - phase: "primary fallback", + const agentId = params.agentId ?? resolveDefaultAgentId(params.cfg); + const attempts: ConversationLabelAttempt[] = params.modelRef + ? [{ modelRef: params.modelRef }] + : [{ useUtilityModel: true }, { useUtilityModel: false }]; + return await runLabelAttempts({ + cfg: params.cfg, + agentId, + agentDir: params.agentDir, + ...(params.agentHarnessRuntimeOverride + ? { agentHarnessRuntimeOverride: params.agentHarnessRuntimeOverride } + : {}), + attempts, + userMessage: params.userMessage, + prompt: params.prompt, + timeoutMs: resolveTimeoutMs(params.timeoutMs), + maxLength: resolveMaxLabelLength(params.maxLength), }); } @@ -286,12 +196,11 @@ export async function generateConversationLabelWithFallback( const regularAttempt: ConversationLabelAttempt = { modelRef: params.regularModelRef, ...(params.preferredProfile ? { preferredProfile: params.preferredProfile } : {}), - bindAuthOwner: true, }; const utilityRef = params.utilityModelRef?.trim(); let utilityAttempt: ConversationLabelAttempt | undefined; if (utilityRef) { - const candidate: ConversationLabelAttempt = { modelRef: utilityRef, bindAuthOwner: true }; + const candidate: ConversationLabelAttempt = { modelRef: utilityRef }; const utilitySelection = resolveAttemptSelection({ cfg: params.cfg, agentId, @@ -315,56 +224,21 @@ export async function generateConversationLabelWithFallback( utilityAuthProvider && utilityAuthProvider === regularAuthProvider; utilityAttempt = inheritsRegularProfile - ? { modelRef: `${utilityRef}@${params.preferredProfile}`, bindAuthOwner: true } + ? { modelRef: `${utilityRef}@${params.preferredProfile}` } : candidate; } - const attempts: ConversationLabelAttempt[] = [ - ...(utilityAttempt ? [utilityAttempt] : []), - regularAttempt, - ]; - const seen = new Set(); - const maxLength = resolveMaxLabelLength(params.maxLength); - let previousCompletedModel: PreparedLabelModel | null = null; - for (const attempt of attempts) { - const key = resolveAttemptKey({ - cfg: params.cfg, - agentId, - agentDir: params.agentDir, - attempt, - }); - if (seen.has(key)) { - continue; - } - seen.add(key); - const phase = attempt === regularAttempt ? "primary fallback" : "utility"; - const prepared = await prepareLabelModel({ - cfg: params.cfg, - agentId, - agentDir: params.agentDir, - attempt, - phase, - }); - if (!prepared || "error" in prepared) { - continue; - } - if (previousCompletedModel && selectedLabelModelsMatch(previousCompletedModel, prepared)) { - continue; - } - previousCompletedModel = prepared; - const label = await completeLabel({ - prepared, - cfg: params.cfg, - userMessage: params.userMessage, - prompt: params.prompt, - maxLength, - phase, - }); - if (label) { - const normalized = params.normalizeLabel ? params.normalizeLabel(label) : label; - if (normalized) { - return normalized; - } - } - } - return null; + return await runLabelAttempts({ + cfg: params.cfg, + agentId, + agentDir: params.agentDir, + ...(params.agentHarnessRuntimeOverride + ? { agentHarnessRuntimeOverride: params.agentHarnessRuntimeOverride } + : {}), + attempts: [...(utilityAttempt ? [utilityAttempt] : []), regularAttempt], + userMessage: params.userMessage, + prompt: params.prompt, + timeoutMs: resolveTimeoutMs(params.timeoutMs), + maxLength: resolveMaxLabelLength(params.maxLength), + normalizeLabel: params.normalizeLabel, + }); } diff --git a/src/gateway/dashboard-session-title.test.ts b/src/gateway/dashboard-session-title.test.ts index 025be9af0def..270e2accfd4a 100644 --- a/src/gateway/dashboard-session-title.test.ts +++ b/src/gateway/dashboard-session-title.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const generateConversationLabelWithFallback = vi.hoisted(() => vi.fn()); const resolveUtilityModelRefForAgent = vi.hoisted(() => vi.fn()); +const readSessionTitleFieldsFromTranscript = vi.hoisted(() => vi.fn()); const updateSessionEntry = vi.hoisted(() => vi.fn()); vi.mock("../agents/utility-model.js", () => ({ resolveUtilityModelRefForAgent })); @@ -10,6 +11,7 @@ vi.mock("../auto-reply/reply/conversation-label-generator.js", () => ({ generateConversationLabelWithFallback, })); vi.mock("../config/sessions/session-accessor.js", () => ({ updateSessionEntry })); +vi.mock("./session-transcript-title-reader.js", () => ({ readSessionTitleFieldsFromTranscript })); import type { SessionEntry } from "../config/sessions/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; @@ -51,6 +53,11 @@ describe("maybeGenerateDashboardSessionTitle", () => { generateConversationLabelWithFallback.mockReset(); resolveUtilityModelRefForAgent.mockReset(); updateSessionEntry.mockReset(); + readSessionTitleFieldsFromTranscript.mockReset(); + readSessionTitleFieldsFromTranscript.mockReturnValue({ + firstUserMessage: null, + lastMessagePreview: null, + }); generateConversationLabelWithFallback.mockResolvedValue("Release Planning"); resolveUtilityModelRefForAgent.mockReturnValue("openai/gpt-5.6-luna"); mockSessionUpdate(baseEntry); @@ -116,6 +123,38 @@ describe("maybeGenerateDashboardSessionTitle", () => { ); }); + it("preserves a locked session harness as the title runtime owner", async () => { + const entry = { + ...baseEntry, + agentHarnessId: "codex", + agentRuntimeOverride: "openclaw", + modelSelectionLocked: true, + }; + mockSessionUpdate(entry); + + await expect(maybeGenerateDashboardSessionTitle(titleParams(entry))).resolves.toBe(true); + + expect(generateConversationLabelWithFallback).toHaveBeenCalledWith( + expect.objectContaining({ agentHarnessRuntimeOverride: "codex" }), + ); + }); + + it("preserves a compatible session runtime override for title generation", async () => { + const entry = { + ...baseEntry, + providerOverride: "anthropic", + modelOverride: "claude-fable-5", + agentRuntimeOverride: "claude-cli", + }; + mockSessionUpdate(entry); + + await expect(maybeGenerateDashboardSessionTitle(titleParams(entry))).resolves.toBe(true); + + expect(generateConversationLabelWithFallback).toHaveBeenCalledWith( + expect.objectContaining({ agentHarnessRuntimeOverride: "claude-cli" }), + ); + }); + it("preserves the configured primary auth profile for explicit utility models", async () => { const profiledCfg = { agents: { @@ -201,7 +240,6 @@ describe("maybeGenerateDashboardSessionTitle", () => { ["group subject", { entry: { ...baseEntry, subject: "Release team" } }], ["channel name", { entry: { ...baseEntry, groupChannel: "releases" } }], ["space name", { entry: { ...baseEntry, space: "Engineering" } }], - ["existing session history", { entry: { ...baseEntry, systemSent: true } }], ])("skips %s", async (_name, override) => { await expect( maybeGenerateDashboardSessionTitle({ ...titleParams(), ...override }), @@ -211,6 +249,58 @@ describe("maybeGenerateDashboardSessionTitle", () => { expect(updateSessionEntry).not.toHaveBeenCalled(); }); + it("retries a historical session from the transcript's first user message", async () => { + const entry = { ...baseEntry, systemSent: true }; + readSessionTitleFieldsFromTranscript.mockReturnValue({ + firstUserMessage: "[Mon 2026-08-10 12:00 UTC] Original release plan", + lastMessagePreview: "Latest follow-up", + }); + mockSessionUpdate(entry); + + await expect( + maybeGenerateDashboardSessionTitle({ + ...titleParams(entry), + currentUserMessage: "Latest follow-up", + userMessage: "Latest follow-up", + }), + ).resolves.toBe(true); + + expect(generateConversationLabelWithFallback.mock.calls[0]?.[0]?.userMessage).toBe( + "Original release plan", + ); + }); + + it("preserves attachment-aware input when the first turn is already in the transcript", async () => { + readSessionTitleFieldsFromTranscript.mockReturnValue({ + firstUserMessage: "[Mon 2026-08-10 12:00 UTC] Review this rollout", + lastMessagePreview: "Review this rollout", + }); + + await expect( + maybeGenerateDashboardSessionTitle({ + ...titleParams(), + currentUserMessage: "Review this rollout", + userMessage: "Review this rollout\nDeployment context", + }), + ).resolves.toBe(true); + + expect(generateConversationLabelWithFallback.mock.calls[0]?.[0]?.userMessage).toBe( + "Review this rollout\nDeployment context", + ); + }); + + it("evicts a failed request so later activity can retry", async () => { + generateConversationLabelWithFallback + .mockRejectedValueOnce(new Error("route unavailable")) + .mockResolvedValueOnce("Release Planning"); + + await expect(maybeGenerateDashboardSessionTitle(titleParams())).rejects.toThrow( + "route unavailable", + ); + await expect(maybeGenerateDashboardSessionTitle(titleParams())).resolves.toBe(true); + expect(generateConversationLabelWithFallback).toHaveBeenCalledTimes(2); + }); + it("does not overwrite a name added while the model request is running", async () => { mockSessionUpdate({ ...baseEntry, label: "Manual title" }); diff --git a/src/gateway/dashboard-session-title.ts b/src/gateway/dashboard-session-title.ts index d2e187c5df30..4311d2fe0a48 100644 --- a/src/gateway/dashboard-session-title.ts +++ b/src/gateway/dashboard-session-title.ts @@ -3,8 +3,10 @@ import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import { resolveAgentEffectiveModelPrimary } from "../agents/agent-scope.js"; import { splitTrailingAuthProfile } from "../agents/model-ref-profile.js"; import { resolveSessionModelRef } from "../agents/session-model-ref.js"; +import { resolveSessionRuntimeOverrideForProvider } from "../agents/session-runtime-compat.js"; import { resolveUtilityModelRefForAgent } from "../agents/utility-model.js"; import { generateConversationLabelWithFallback } from "../auto-reply/reply/conversation-label-generator.js"; +import { stripInboundMetadata } from "../auto-reply/reply/strip-inbound-meta.js"; import { updateSessionEntry } from "../config/sessions/session-accessor.js"; import type { SessionEntry } from "../config/sessions/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; @@ -12,10 +14,18 @@ import { parseAgentSessionKey } from "../sessions/session-key-utils.js"; import { getOrCreatePromise } from "../shared/lazy-promise.js"; import { stripInlineDirectiveTagsForDisplay } from "../utils/directive-tags.js"; import { isValidAttachmentBase64, type ChatAttachment } from "./chat-attachments.js"; +import { readSessionTitleFieldsFromTranscript } from "./session-transcript-title-reader.js"; type DashboardSessionTitleModelEntry = Pick< SessionEntry, - "authProfileOverride" | "model" | "modelOverride" | "modelProvider" | "providerOverride" + | "agentHarnessId" + | "agentRuntimeOverride" + | "authProfileOverride" + | "model" + | "modelOverride" + | "modelProvider" + | "modelSelectionLocked" + | "providerOverride" >; const DASHBOARD_SESSION_TITLE_MAX_CHARS = 60; @@ -23,11 +33,11 @@ const DASHBOARD_SESSION_TITLE_SOURCE_MAX_CHARS = 1_000; const DASHBOARD_SESSION_TITLE_PROMPT = "Generate a concise session title (3-6 words, max 60 characters) from the user's first message. Use the same language as the message. No emoji. Return only the title."; -// One title request per first turn. Concurrent sends cannot race duplicate model +// One title request per session generation. Concurrent triggers cannot race duplicate model // calls or metadata writes; late callers receive the in-flight promise so they // may await the persisted title before proceeding. Stored promises always -// settle: the label generator aborts internally (TIMEOUT_MS), so a hung model -// call cannot pin an entry here and block future attempts. +// settle: isolated completion enforces a timeout, so a hung model call cannot +// pin an entry here and block future attempts. const sessionTitleRequests = new Map>(); function decodeTextAttachmentPrefix(attachment: ChatAttachment, maxChars: number): string | null { @@ -161,6 +171,11 @@ export async function generateDashboardSessionTitle(params: { return null; } const regularModel = resolveSessionModelRef(params.cfg, params.entry, params.agentId); + const agentHarnessRuntimeOverride = resolveSessionRuntimeOverrideForProvider({ + provider: regularModel.provider, + entry: params.entry, + cfg: params.cfg, + }); const preferredProfile = resolveDashboardTitleAuthProfile({ cfg: params.cfg, agentId: params.agentId, @@ -181,6 +196,7 @@ export async function generateDashboardSessionTitle(params: { prompt: DASHBOARD_SESSION_TITLE_PROMPT, cfg: params.cfg, agentId: params.agentId, + ...(agentHarnessRuntimeOverride ? { agentHarnessRuntimeOverride } : {}), ...(utilityModelRef ? { utilityModelRef } : {}), regularModelRef, ...(preferredProfile ? { preferredProfile } : {}), @@ -197,6 +213,7 @@ export async function maybeGenerateDashboardSessionTitle(params: { sessionId: string; sessionKey: string; storePath: string; + currentUserMessage?: string; userMessage: string; }): Promise { const sourceText = params.userMessage.trim(); @@ -221,14 +238,10 @@ export async function maybeGenerateSessionTitle(params: { sessionId: string; sessionKey: string; storePath: string; + currentUserMessage?: string; userMessage: string; }): Promise { - const sourceText = params.userMessage.trim(); - if ( - hasExplicitSessionName(params.entry) || - params.entry?.systemSent === true || - params.entry?.sessionId !== params.sessionId - ) { + if (hasExplicitSessionName(params.entry) || params.entry?.sessionId !== params.sessionId) { return { kind: "skipped" }; } @@ -237,6 +250,32 @@ export async function maybeGenerateSessionTitle(params: { if (existing) { return { kind: "in-flight", settled: existing }; } + + // A retry may be triggered by a later send or by discussion open. Always + // title the session from its original user message when the transcript owns it. + const transcriptSource = readSessionTitleFieldsFromTranscript({ + agentId: params.agentId, + sessionEntry: params.entry, + sessionId: params.sessionId, + sessionKey: params.sessionKey, + storePath: params.storePath, + }).firstUserMessage; + const transcriptText = transcriptSource + ? stripInlineDirectiveTagsForDisplay(stripInboundMetadata(transcriptSource)).text.trim() + : ""; + const currentText = params.currentUserMessage + ? stripInlineDirectiveTagsForDisplay(params.currentUserMessage).text.trim() + : ""; + // A first-turn transcript may win the persistence race before title work starts. + // When it is the current turn, retain the supplied attachment-enriched source. + const sourceText = + !transcriptText || (currentText && currentText === transcriptText) + ? params.userMessage.trim() + : transcriptText; + if (!sourceText) { + return { kind: "skipped" }; + } + const request = getOrCreatePromise( sessionTitleRequests, requestKey, diff --git a/src/gateway/server-methods/chat-send-background.ts b/src/gateway/server-methods/chat-send-background.ts index 13071fcecfed..305c4ba20c31 100644 --- a/src/gateway/server-methods/chat-send-background.ts +++ b/src/gateway/server-methods/chat-send-background.ts @@ -71,6 +71,7 @@ export function scheduleChatDashboardSessionTitle(params: { sessionId: titleSessionId, sessionKey: params.sessionKey, storePath: params.storePath, + currentUserMessage: params.request.rawMessage, userMessage: titleSource, }); if (updated) { diff --git a/src/gateway/server-methods/session-discussion.test.ts b/src/gateway/server-methods/session-discussion.test.ts index 693d32724486..cd670dc1eea5 100644 --- a/src/gateway/server-methods/session-discussion.test.ts +++ b/src/gateway/server-methods/session-discussion.test.ts @@ -174,7 +174,7 @@ describe("session discussion gateway methods", () => { sessionId: "session-1", sessionKey, storePath, - userMessage: "Plan the release", + userMessage: "", }), ); expect(persistedEntry?.displayName).toBe("Release Planning"); @@ -187,6 +187,26 @@ describe("session discussion gateway methods", () => { ); }); + it("attempts a title when system prompt state already exists", async () => { + const entry: SessionEntry = { sessionId: "session-1", updatedAt: 1, systemSent: true }; + mockSession(entry); + mocks.readSessionTitleFields.mockReturnValue({ + firstUserMessage: "Plan the release", + lastMessagePreview: null, + }); + mocks.updateSessionEntry.mockImplementation(async (_scope, update) => { + const patch = await update({ ...entry }); + return patch ? { ...entry, ...patch } : entry; + }); + const registered = provider(); + mocks.getProvider.mockReturnValue(registered.value); + + await invoke("session.discussion.open", { sessionKey }); + + expect(mocks.maybeGenerateSessionTitle).toHaveBeenCalledOnce(); + expect(mocks.generateConversationLabelWithFallback).toHaveBeenCalledOnce(); + }); + it("titles via the canonical session key when opened through an alias key", async () => { const entry: SessionEntry = { sessionId: "session-1", updatedAt: 1 }; mocks.loadSessionTarget.mockReturnValue({ diff --git a/src/gateway/server-methods/session-discussion.ts b/src/gateway/server-methods/session-discussion.ts index 8460db7de82a..15936ddc6237 100644 --- a/src/gateway/server-methods/session-discussion.ts +++ b/src/gateway/server-methods/session-discussion.ts @@ -7,10 +7,9 @@ import { validateSessionDiscussionOpenParams, validateSessionDiscussionOpenResult, } from "../../../packages/gateway-protocol/src/index.js"; -import { stripInboundMetadata } from "../../auto-reply/reply/strip-inbound-meta.js"; import { getSessionDiscussionProvider } from "../../plugins/session-discussion-registry.js"; import { hasExplicitSessionName, maybeGenerateSessionTitle } from "../dashboard-session-title.js"; -import { readSessionTitleFieldsFromTranscript } from "../session-transcript-title-reader.js"; +import { formatForLog } from "../ws-log.js"; import { emitSessionsChanged } from "./session-change-event.js"; import { loadAccessorSessionEntryForGatewayTarget } from "./sessions-shared.js"; import type { GatewayRequestContext, GatewayRequestHandlers } from "./types.js"; @@ -30,20 +29,7 @@ async function maybeGenerateTitleBeforeDiscussionOpen(params: { }); const { entry } = resolved; const sessionId = entry?.sessionId; - if (!entry || !sessionId || entry.systemSent === true || hasExplicitSessionName(entry)) { - return; - } - const fields = readSessionTitleFieldsFromTranscript({ - agentId: resolved.target.agentId, - sessionEntry: entry, - sessionId, - sessionKey: resolved.canonicalKey, - storePath: resolved.storePath, - }); - const userMessage = fields.firstUserMessage - ? stripInboundMetadata(fields.firstUserMessage).trim() - : ""; - if (!userMessage) { + if (!entry || !sessionId || hasExplicitSessionName(entry)) { return; } @@ -59,7 +45,7 @@ async function maybeGenerateTitleBeforeDiscussionOpen(params: { // the open request addresses the session through an alias key. sessionKey: resolved.canonicalKey, storePath: resolved.storePath, - userMessage, + userMessage: "", }).then(async (attempt) => { if (attempt.kind === "in-flight") { await attempt.settled.catch(() => {}); @@ -67,6 +53,12 @@ async function maybeGenerateTitleBeforeDiscussionOpen(params: { } return attempt.kind === "persisted"; }); + const observedTitleRequest = titleRequest.catch((error: unknown) => { + params.context.logGateway.warn( + `dashboard session title generation failed: ${formatForLog(error)}`, + ); + return false; + }); let timeout: NodeJS.Timeout | undefined; let persisted = false; // Discussion open waits at most 10 seconds for best-effort titling. @@ -74,7 +66,7 @@ async function maybeGenerateTitleBeforeDiscussionOpen(params: { // picks up any title that completes after the timeout. try { persisted = await Promise.race([ - titleRequest.catch(() => false), + observedTitleRequest, new Promise((resolve) => { timeout = setTimeout(() => resolve(false), DISCUSSION_TITLE_TIMEOUT_MS); timeout.unref?.(); @@ -94,8 +86,11 @@ async function maybeGenerateTitleBeforeDiscussionOpen(params: { reason: "chat.title", }); } - } catch { + } catch (error) { // Titling is best-effort; provider open remains the authoritative operation. + params.context.logGateway.warn( + `dashboard session title generation failed: ${formatForLog(error)}`, + ); } } diff --git a/src/plugin-sdk/agent-harness-runtime.test.ts b/src/plugin-sdk/agent-harness-runtime.test.ts index aa44e224aa9e..a6840e2aa05f 100644 --- a/src/plugin-sdk/agent-harness-runtime.test.ts +++ b/src/plugin-sdk/agent-harness-runtime.test.ts @@ -210,6 +210,15 @@ describe("agent harness runtime SDK facade", () => { NonNullable["runtimePolicy"] >().toEqualTypeOf(); }); + + it("exports the V2 isolated-completion authorization contract through the harness", () => { + type IsolatedCompletionV2 = NonNullable; + + expectTypeOf[0]["authorization"]["owner"]>().toEqualTypeOf< + "host" | "harness" + >(); + expectTypeOf>["assistant"]>().not.toBeNever(); + }); }); describe("agent harness user input helpers", () => {