diff --git a/CHANGELOG.md b/CHANGELOG.md index ee1e5b41988..a5f9f86f4e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,6 @@ Docs: https://docs.openclaw.ai ## Unreleased -### Highlights - -- Channels/iMessage: bundled `imessage` plugin upgraded with full BlueBubbles parity over `imsg` JSON-RPC, offering a complete replacement for BlueBubbles-backed setups. See [docs/channels/imessage-from-bluebubbles.md](docs/channels/imessage-from-bluebubbles.md) for the migration guide. (#78317) Thanks @omarshahine. - ### Changes - Agents/failover: harden state-aware lane suspension by persisting quota resume transitions, restoring configured lane concurrency, preserving non-quota failure reasons, and exporting model failover events through diagnostics OTLP. Thanks @BunsDev. @@ -159,7 +155,7 @@ Docs: https://docs.openclaw.ai - Plugins/hooks: add a `before_agent_run` pass/block gate that can stop a user prompt before model submission while preserving a redacted transcript entry for the user, and clarify that raw conversation hooks require `hooks.allowConversationAccess=true`. (#75035) Thanks @jesse-merhi. - Config/Nix: keep startup-derived plugin enablement, gateway auth tokens, control UI origins, and owner-display secrets runtime-only instead of rewriting `openclaw.json`; in Nix mode, config writers, mutating `openclaw update`, plugin lifecycle mutators, and doctor repair/token-generation now refuse with agent-first nix-openclaw guidance. (#78047) Thanks @joshp123. - Agents/context engine: invalidate cached assembled context views when source history shrinks or assembly fails, preventing stale pre-reset history from being reused. Fixes #77968. (#78163) Thanks @brokemac79 and @ChrisBot2026. -- Channels/iMessage: drive the bundled `imessage` plugin over `imsg` JSON-RPC so private API actions (`react`, `edit`, `unsend`, `reply`, `sendWithEffect`, `renameGroup`, `setGroupIcon`, `addParticipant`, `removeParticipant`, `leaveGroup`, `sendAttachment`) are reachable when `imsg launch` is running, capability-gated per-method via `imsg status --json`, and inbound chats are marked read with a typing bubble before dispatch unless `channels.imessage.sendReadReceipts: false` [AI-assisted]. (#78317) Thanks @omarshahine. +- Plugin SDK: add a generic `api.runtime.llm.complete` host completion helper with runtime-derived caller attribution, config-gated model/agent overrides, session-bound context-engine access, request-scoped config, audit metadata, and normalized usage attribution. (#64294) Thanks @DaevMithran. ### Breaking @@ -263,11 +259,6 @@ Docs: https://docs.openclaw.ai - LINE: reject `dmPolicy: "open"` configs without wildcard `allowFrom` so webhook DMs fail validation instead of being acknowledged and silently blocked before inbound processing. Fixes #78316. - Telegram/Codex: keep message-tool-only progress drafts visible and render native Codex tool progress once per tool instead of duplicating item/tool draft lines. Fixes #75641. (#77949) Thanks @keshavbotagent. - Telegram/sessions: gap-fill delivered embedded final replies into the session JSONL even when the runner trace is missing, so Telegram answers after tool calls do not vanish from the durable transcript. Fixes #77814. (#78426) Thanks @obviyus, @ChushulSuri, and @DougButdorf. -- Channels/iMessage: probe all persistable echo-cache scope shapes (`chat_id:N`, `chat_guid:`, `chat_identifier:`, `imessage:`) on inbound match, so an outbound message addressed by `chat_guid` no longer bypasses the chat_id-only inbound lookup and re-feeds the agent its own reply [AI-assisted]. Thanks @omarshahine. -- Security/iMessage: clamp `reply-cache.jsonl` to `0600` (parent dir `0700`) on every write/append and chmod existing entries from older gateway versions, blocking same-UID enumeration of conversation guids and shortId injection on multi-user hosts [AI-assisted]. Thanks @omarshahine. -- Security/iMessage: apply the same `0600`/`0700` clamp to `sent-echoes.jsonl` so outbound message text and scope keys are not world-readable on multi-user hosts [AI-assisted]. Thanks @omarshahine. -- Config/iMessage: add `probeTimeoutMs` to `IMessageAccountSchemaBase` so the `channels.imessage.probeTimeoutMs` option declared on `IMessageAccountConfig` actually round-trips through validation instead of being silently stripped by zod parse [AI-assisted]. Thanks @omarshahine. -- Security/iMessage: gate `edit` and `unsend` private API actions on `isFromMe`, so an agent in a group chat can only modify messages the gateway itself sent, not messages received from other participants. Records `isFromMe: true` for outbound sends and `false` for inbound, then refuses to resolve message ids that fail the check before dispatch [AI-assisted]. Thanks @omarshahine. - Providers/xAI: stop sending OpenAI-style reasoning effort controls to native Grok Responses models, so `xai/grok-4.3` no longer fails live Docker/Gateway runs with `Invalid reasoning effort`. - Providers/xAI: clamp the bundled xAI thinking profile to `off` so live Gateway runs cannot send unsupported reasoning levels to native Grok Responses models. - Matrix/approvals: retry approval delivery up to 3 times with a short backoff so transient Matrix send failures do not strand pending approval prompts. (#78179) Thanks @Patrick-Erichsen. diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index f661e2131fd..5f7a7019a69 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -0a77e8265b3bf5d75e06c2e5aad7f0b7c60667de2ec57c9676e2b18305b0cc08 config-baseline.json -b2ed92dd6a269d54f263728a2a761d8f6e60f849ec0562dfad17c959bfe90dfa config-baseline.core.json +885a734aa93cf04f6c14f8d83c1e96a66a5b96705327ea2de7b2aa7314238976 config-baseline.json +074eb9a1480ff40836d98090ccb9be3465345ac4b46e0d273b7995504bbb8008 config-baseline.core.json ed15b24c1ccf0234e6b3435149a6f1c1e709579d1259f1d09402688799b149bd config-baseline.channel.json -dfc16c21bdd6d727c920de871bf7fe86b771c80df86335c6376b436c0c4898ee config-baseline.plugin.json +c4e8d8898eebc4d40f35b167c987870e426e6c82121696dc055ff929f6a24046 config-baseline.plugin.json diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 88f072a177e..d8b9d1b4740 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -887d2fee5f77f1de984bfb6ec0f001c0484c0367dbc8b5f42b62027df352c2e1 plugin-sdk-api-baseline.json -8e2b4e64a801b47c4d45d5d4a2073180abcc1ecf7e677fae035799c6a68f7c82 plugin-sdk-api-baseline.jsonl +fecac0023b0a8de6334740483ef03500c72f3235e5b636e089bf581b00e8734a plugin-sdk-api-baseline.json +b427b2c8bddefb6c0ab4f411065adeec230d1e126a792ed30e6d0a45053dd4e3 plugin-sdk-api-baseline.jsonl diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index ea05b97fed5..b2c722d8ca7 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -198,6 +198,9 @@ See [MCP](/cli/mcp#openclaw-as-an-mcp-client-registry) and - `plugins.entries..hooks.allowConversationAccess`: when `true`, trusted non-bundled plugins may read raw conversation content from typed hooks such as `llm_input`, `llm_output`, `before_model_resolve`, `before_agent_reply`, `before_agent_run`, `before_agent_finalize`, and `agent_end`. - `plugins.entries..subagent.allowModelOverride`: explicitly trust this plugin to request per-run `provider` and `model` overrides for background subagent runs. - `plugins.entries..subagent.allowedModels`: optional allowlist of canonical `provider/model` targets for trusted subagent overrides. Use `"*"` only when you intentionally want to allow any model. +- `plugins.entries..llm.allowModelOverride`: explicitly trust this plugin to request model overrides for `api.runtime.llm.complete`. +- `plugins.entries..llm.allowedModels`: optional allowlist of canonical `provider/model` targets for trusted plugin LLM completion overrides. Use `"*"` only when you intentionally want to allow any model. +- `plugins.entries..llm.allowAgentIdOverride`: explicitly trust this plugin to run `api.runtime.llm.complete` against a non-default agent id. - `plugins.entries..config`: plugin-defined config object (validated by native OpenClaw plugin schema when available). - Channel plugin account/runtime settings live under `channels.` and should be described by the owning plugin's manifest `channelConfigs` metadata, not by a central OpenClaw option registry. diff --git a/docs/plugins/sdk-runtime.md b/docs/plugins/sdk-runtime.md index 775e5dbba39..7d00c7c0b52 100644 --- a/docs/plugins/sdk-runtime.md +++ b/docs/plugins/sdk-runtime.md @@ -133,6 +133,32 @@ Provider and channel execution paths must use the active runtime config snapshot const provider = api.runtime.agent.defaults.provider; // e.g. "anthropic" ``` + + + + Run a host-owned text completion without importing provider internals or + duplicating OpenClaw model/auth/base URL preparation. + + ```typescript + const result = await api.runtime.llm.complete({ + messages: [{ role: "user", content: "Summarize this transcript." }], + purpose: "my-plugin.summary", + maxTokens: 512, + temperature: 0.2, + }); + ``` + + The helper uses the same simple-completion preparation path as OpenClaw's + built-in runtime and the host-owned runtime config snapshot. Context engines + receive a session-bound `llm.complete` capability, so model calls use the + active session's agent and do not silently fall back to the default agent. The + result includes provider/model/agent attribution plus normalized token, + cache, and estimated cost usage when available. + + + Model overrides require operator opt-in via `plugins.entries..llm.allowModelOverride: true` in config. Use `plugins.entries..llm.allowedModels` to restrict trusted plugins to specific canonical `provider/model` targets. Cross-agent completions require `plugins.entries..llm.allowAgentIdOverride: true`. + + Launch and manage background subagent runs. diff --git a/src/agents/pi-embedded-runner/compact.hooks.harness.ts b/src/agents/pi-embedded-runner/compact.hooks.harness.ts index ba903fa8281..8218e9cd363 100644 --- a/src/agents/pi-embedded-runner/compact.hooks.harness.ts +++ b/src/agents/pi-embedded-runner/compact.hooks.harness.ts @@ -72,6 +72,10 @@ export const resolveMemorySearchConfigMock = vi.fn(() => ({ }, })); export const resolveSessionAgentIdMock = vi.fn(() => "main"); +export const resolveSessionAgentIdsMock = vi.fn(() => ({ + defaultAgentId: "main", + sessionAgentId: "main", +})); export const estimateTokensMock = vi.fn((_message?: unknown) => 10); function createDefaultSessionMessages(): unknown[] { return [ @@ -168,6 +172,8 @@ export function resetCompactSessionStateMocks(): void { }); resolveSessionAgentIdMock.mockReset(); resolveSessionAgentIdMock.mockReturnValue("main"); + resolveSessionAgentIdsMock.mockReset(); + resolveSessionAgentIdsMock.mockReturnValue({ defaultAgentId: "main", sessionAgentId: "main" }); estimateTokensMock.mockReset(); estimateTokensMock.mockReturnValue(10); sessionMessages.splice(0, sessionMessages.length, ...createDefaultSessionMessages()); @@ -384,6 +390,7 @@ export async function loadCompactHooksHarness(): Promise<{ vi.doMock("../../context-engine/registry.js", () => ({ resolveContextEngine: resolveContextEngineMock, + resolveContextEngineOwnerPluginId: vi.fn(() => "lossless-claw"), })); vi.doMock("../../process/command-queue.js", () => ({ @@ -551,7 +558,7 @@ export async function loadCompactHooksHarness(): Promise<{ resolveDefaultAgentId: vi.fn(() => "main"), resolveRunModelFallbacksOverride: vi.fn(() => undefined), resolveSessionAgentId: resolveSessionAgentIdMock, - resolveSessionAgentIds: vi.fn(() => ({ defaultAgentId: "main", sessionAgentId: "main" })), + resolveSessionAgentIds: resolveSessionAgentIdsMock, })); vi.doMock("../auth-profiles/source-check.js", () => ({ diff --git a/src/agents/pi-embedded-runner/compact.hooks.test.ts b/src/agents/pi-embedded-runner/compact.hooks.test.ts index 07210432ca2..fa654d58731 100644 --- a/src/agents/pi-embedded-runner/compact.hooks.test.ts +++ b/src/agents/pi-embedded-runner/compact.hooks.test.ts @@ -17,6 +17,7 @@ import { resolveModelMock, resolveSandboxContextMock, resolveSessionAgentIdMock, + resolveSessionAgentIdsMock, rotateTranscriptAfterCompactionMock, resetCompactHooksHarnessMocks, resetCompactSessionStateMocks, @@ -1049,6 +1050,57 @@ describe("compactEmbeddedPiSession hooks (ownsCompaction engine)", () => { mockResolvedModel(); }); + it("binds context-engine compaction runtime LLM to the session agent", async () => { + resolveSessionAgentIdsMock.mockReturnValueOnce({ + defaultAgentId: "main", + sessionAgentId: "lossless-agent", + }); + + await compactEmbeddedPiSession( + wrappedCompactionArgs({ + config: { + agents: { + defaults: { + model: "openai/gpt-5.5", + }, + }, + }, + sessionKey: "legacy-topic-47", + }), + ); + + expect(contextEngineCompactMock).toHaveBeenCalledWith( + expect.objectContaining({ + runtimeContext: expect.objectContaining({ + llm: expect.objectContaining({ complete: expect.any(Function) }), + }), + }), + ); + const contextEngineCompactCalls = contextEngineCompactMock.mock.calls as unknown as Array< + [ + { + runtimeContext?: { + llm?: { + complete?: (params: { + messages: Array<{ role: "user"; content: string }>; + agentId?: string; + }) => Promise; + }; + }; + }, + ] + >; + const runtimeContext = contextEngineCompactCalls[0]?.[0]?.runtimeContext; + expect(runtimeContext).toBeDefined(); + + await expect( + runtimeContext?.llm?.complete?.({ + messages: [{ role: "user", content: "summarize" }], + agentId: "other-agent", + }), + ).rejects.toThrow("cannot override the active session agent"); + }); + it("fires before_compaction with sentinel -1 and after_compaction on success", async () => { hookRunner.hasHooks.mockReturnValue(true); diff --git a/src/agents/pi-embedded-runner/compact.queued.ts b/src/agents/pi-embedded-runner/compact.queued.ts index 01bafcc3826..d9cca9654d4 100644 --- a/src/agents/pi-embedded-runner/compact.queued.ts +++ b/src/agents/pi-embedded-runner/compact.queued.ts @@ -1,5 +1,8 @@ import { ensureContextEnginesInitialized } from "../../context-engine/init.js"; -import { resolveContextEngine } from "../../context-engine/registry.js"; +import { + resolveContextEngine, + resolveContextEngineOwnerPluginId, +} from "../../context-engine/registry.js"; import type { ContextEngineRuntimeContext } from "../../context-engine/types.js"; import { captureCompactionCheckpointSnapshotAsync, @@ -29,6 +32,7 @@ import { rotateTranscriptFileAfterCompaction, shouldRotateCompactionTranscript, } from "./compaction-successor-transcript.js"; +import { resolveContextEngineCapabilities } from "./context-engine-capabilities.js"; import { runContextEngineMaintenance } from "./context-engine-maintenance.js"; import { resolveGlobalLane, resolveSessionLane } from "./lanes.js"; import { log } from "./logger.js"; @@ -92,6 +96,7 @@ export async function compactEmbeddedPiSession( params, agentDir, contextTokenBudget, + contextEnginePluginId: resolveContextEngineOwnerPluginId(contextEngine), }); const harnessResult = await maybeCompactAgentHarnessSession({ ...params, @@ -302,8 +307,13 @@ export async function compactEmbeddedPiSession( function buildCompactionContextEngineRuntimeContext(params: { params: CompactEmbeddedPiSessionParams; agentDir: string; + contextEnginePluginId?: string; contextTokenBudget?: number; }): ContextEngineRuntimeContext { + const { sessionAgentId } = resolveSessionAgentIds({ + sessionKey: params.params.sessionKey, + config: params.params.config, + }); return { ...params.params, ...buildEmbeddedCompactionRuntimeContext({ @@ -331,6 +341,13 @@ function buildCompactionContextEngineRuntimeContext(params: { sourceReplyDeliveryMode: params.params.sourceReplyDeliveryMode, ownerNumbers: params.params.ownerNumbers, }), + ...resolveContextEngineCapabilities({ + config: params.params.config, + sessionKey: params.params.sessionKey, + agentId: sessionAgentId, + contextEnginePluginId: params.contextEnginePluginId, + purpose: "context-engine.compaction", + }), tokenBudget: params.contextTokenBudget, currentTokenCount: params.params.currentTokenCount, }; diff --git a/src/agents/pi-embedded-runner/context-engine-capabilities.ts b/src/agents/pi-embedded-runner/context-engine-capabilities.ts new file mode 100644 index 00000000000..7d6449f10e4 --- /dev/null +++ b/src/agents/pi-embedded-runner/context-engine-capabilities.ts @@ -0,0 +1,85 @@ +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import type { ContextEngineRuntimeContext } from "../../context-engine/types.js"; +import { + parseAgentSessionKey, + normalizeAgentId, + normalizeMainKey, +} from "../../routing/session-key.js"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalString, +} from "../../shared/string-coerce.js"; +import { resolveDefaultAgentId } from "../agent-scope.js"; + +export type ResolveContextEngineCapabilitiesParams = { + config?: OpenClawConfig; + sessionKey?: string; + agentId?: string; + contextEnginePluginId?: string; + purpose: string; +}; + +function resolveBoundAgentId(params: { + config?: OpenClawConfig; + sessionKey?: string; + agentId?: string; +}): string | undefined { + // Explicit agent ids are host-resolved at call sites that already know the + // active session agent, such as embedded attempts. + const explicitAgentId = normalizeOptionalString(params.agentId); + if (explicitAgentId) { + return normalizeAgentId(explicitAgentId); + } + // Canonical agent session keys carry the binding directly. + const normalizedSessionKey = normalizeOptionalString(params.sessionKey); + if (!normalizedSessionKey) { + return undefined; + } + const parsed = parseAgentSessionKey(normalizedSessionKey); + if (parsed?.agentId) { + return normalizeAgentId(parsed.agentId); + } + // Legacy main-session aliases are still active sessions; arbitrary legacy + // aliases stay unbound and fail closed in runtime LLM authorization. + const loweredSessionKey = normalizeLowercaseStringOrEmpty(normalizedSessionKey); + const mainKey = normalizeMainKey(params.config?.session?.mainKey); + if (loweredSessionKey === "main" || loweredSessionKey === mainKey) { + return resolveDefaultAgentId(params.config ?? {}); + } + return undefined; +} + +/** + * Build host-owned capabilities that are bound to one context-engine runtime call. + */ +export function resolveContextEngineCapabilities( + params: ResolveContextEngineCapabilitiesParams, +): Pick { + const sessionKey = normalizeOptionalString(params.sessionKey); + const agentId = resolveBoundAgentId({ + config: params.config, + sessionKey, + agentId: params.agentId, + }); + const contextEnginePluginId = normalizeOptionalString(params.contextEnginePluginId); + return { + llm: { + complete: async (request) => { + const { createRuntimeLlm } = await import("../../plugins/runtime/runtime-llm.runtime.js"); + return await createRuntimeLlm({ + getConfig: () => params.config, + authority: { + caller: { kind: "context-engine", id: params.purpose }, + requiresBoundAgent: true, + ...(sessionKey ? { sessionKey } : {}), + ...(agentId ? { agentId } : {}), + ...(contextEnginePluginId ? { pluginIdForPolicy: contextEnginePluginId } : {}), + allowAgentIdOverride: false, + allowModelOverride: false, + allowComplete: true, + }, + }).complete(request); + }, + }, + }; +} diff --git a/src/agents/pi-embedded-runner/context-engine-maintenance.test.ts b/src/agents/pi-embedded-runner/context-engine-maintenance.test.ts index 3b9ea9dcdb9..e0ef5f80fb8 100644 --- a/src/agents/pi-embedded-runner/context-engine-maintenance.test.ts +++ b/src/agents/pi-embedded-runner/context-engine-maintenance.test.ts @@ -63,6 +63,10 @@ async function waitForAssertion( } } +vi.mock("./context-engine-capabilities.js", () => ({ + resolveContextEngineCapabilities: () => ({ llm: undefined }), +})); + vi.mock("./transcript-rewrite.js", () => ({ rewriteTranscriptEntriesInSessionManager: (params: unknown) => rewriteTranscriptEntriesInSessionManagerMock(params), diff --git a/src/agents/pi-embedded-runner/context-engine-maintenance.ts b/src/agents/pi-embedded-runner/context-engine-maintenance.ts index 949dfc357c8..274c3ca2d13 100644 --- a/src/agents/pi-embedded-runner/context-engine-maintenance.ts +++ b/src/agents/pi-embedded-runner/context-engine-maintenance.ts @@ -1,4 +1,6 @@ import { randomUUID } from "node:crypto"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { resolveContextEngineOwnerPluginId } from "../../context-engine/registry.js"; import type { ContextEngine, ContextEngineMaintenanceResult, @@ -22,7 +24,7 @@ import { updateTaskNotifyPolicyForOwner, } from "../../tasks/task-owner-access.js"; import { findActiveSessionTask } from "../session-async-task-status.js"; -import type { SessionWriteLockAcquireTimeoutConfig } from "../session-write-lock.js"; +import { resolveContextEngineCapabilities } from "./context-engine-capabilities.js"; import { resolveSessionLane } from "./lanes.js"; import { log } from "./logger.js"; import { @@ -46,7 +48,8 @@ type DeferredTurnMaintenanceScheduleParams = { sessionFile: string; sessionManager?: Parameters[0]["sessionManager"]; runtimeContext?: ContextEngineRuntimeContext; - config?: SessionWriteLockAcquireTimeoutConfig; + agentId?: string; + config?: OpenClawConfig; }; type DeferredTurnMaintenanceRunState = { @@ -275,12 +278,22 @@ export function buildContextEngineMaintenanceRuntimeContext(params: { sessionFile: string; sessionManager?: Parameters[0]["sessionManager"]; runtimeContext?: ContextEngineRuntimeContext; + agentId?: string; allowDeferredCompactionExecution?: boolean; deferTranscriptRewriteToSessionLane?: boolean; - config?: SessionWriteLockAcquireTimeoutConfig; + config?: OpenClawConfig; + purpose?: string; + contextEnginePluginId?: string; }): ContextEngineRuntimeContext { return { ...params.runtimeContext, + ...resolveContextEngineCapabilities({ + config: params.config, + sessionKey: params.sessionKey, + agentId: params.agentId, + contextEnginePluginId: params.contextEnginePluginId, + purpose: params.purpose ?? "context-engine.maintenance", + }), ...(params.allowDeferredCompactionExecution ? { allowDeferredCompactionExecution: true } : {}), rewriteTranscriptEntries: async (request) => { if (params.sessionManager) { @@ -317,8 +330,9 @@ async function executeContextEngineMaintenance(params: { reason: "bootstrap" | "compaction" | "turn"; sessionManager?: Parameters[0]["sessionManager"]; runtimeContext?: ContextEngineRuntimeContext; + agentId?: string; executionMode: "foreground" | "background"; - config?: SessionWriteLockAcquireTimeoutConfig; + config?: OpenClawConfig; }): Promise { if (typeof params.contextEngine.maintain !== "function") { return undefined; @@ -333,9 +347,12 @@ async function executeContextEngineMaintenance(params: { sessionFile: params.sessionFile, sessionManager: params.executionMode === "background" ? undefined : params.sessionManager, runtimeContext: params.runtimeContext, + agentId: params.agentId, allowDeferredCompactionExecution: params.executionMode === "background", deferTranscriptRewriteToSessionLane: params.executionMode === "background", config: params.config, + purpose: `context-engine.${params.reason}.maintenance`, + contextEnginePluginId: resolveContextEngineOwnerPluginId(params.contextEngine), }), }); if (result.changed) { @@ -355,8 +372,9 @@ async function runDeferredTurnMaintenanceWorker(params: { sessionFile: string; sessionManager?: Parameters[0]["sessionManager"]; runtimeContext?: ContextEngineRuntimeContext; + agentId?: string; runId: string; - config?: SessionWriteLockAcquireTimeoutConfig; + config?: OpenClawConfig; }): Promise { let surfacedUserNotice = false; let longRunningTimer: ReturnType | null = null; @@ -435,6 +453,7 @@ async function runDeferredTurnMaintenanceWorker(params: { reason: "turn", sessionManager: params.sessionManager, runtimeContext: params.runtimeContext, + agentId: params.agentId, config: params.config, executionMode: "background", }); @@ -558,6 +577,7 @@ function scheduleDeferredTurnMaintenance(params: DeferredTurnMaintenanceSchedule sessionFile: params.sessionFile, sessionManager: params.sessionManager, runtimeContext: params.runtimeContext, + agentId: params.agentId, config: params.config, runId: task.runId!, }), @@ -614,8 +634,9 @@ export async function runContextEngineMaintenance(params: { reason: "bootstrap" | "compaction" | "turn"; sessionManager?: Parameters[0]["sessionManager"]; runtimeContext?: ContextEngineRuntimeContext; + agentId?: string; executionMode?: "foreground" | "background"; - config?: SessionWriteLockAcquireTimeoutConfig; + config?: OpenClawConfig; }): Promise { if (typeof params.contextEngine?.maintain !== "function") { return undefined; @@ -636,6 +657,7 @@ export async function runContextEngineMaintenance(params: { sessionFile: params.sessionFile, sessionManager: params.sessionManager, runtimeContext: params.runtimeContext, + agentId: params.agentId, config: params.config, }); } catch (err) { @@ -653,6 +675,7 @@ export async function runContextEngineMaintenance(params: { reason: params.reason, sessionManager: params.sessionManager, runtimeContext: params.runtimeContext, + agentId: params.agentId, executionMode, config: params.config, }); diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index 318886aacef..d7a825abc36 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -4,7 +4,10 @@ import type { ReplyPayload } from "../../auto-reply/reply-payload.js"; import type { ThinkLevel } from "../../auto-reply/thinking.js"; import { SILENT_REPLY_TOKEN } from "../../auto-reply/tokens.js"; import { ensureContextEnginesInitialized } from "../../context-engine/init.js"; -import { resolveContextEngine } from "../../context-engine/registry.js"; +import { + resolveContextEngine, + resolveContextEngineOwnerPluginId, +} from "../../context-engine/registry.js"; import { emitAgentPlanEvent } from "../../infra/agent-events.js"; import { sleepWithAbort } from "../../infra/backoff.js"; import { freezeDiagnosticTraceContext } from "../../infra/diagnostic-trace-context.js"; @@ -89,6 +92,7 @@ import { derivePromptTokens, normalizeUsage, type UsageLike } from "../usage.js" import { redactRunIdentifier, resolveRunWorkspaceDir } from "../workspace-run.js"; import { runPostCompactionSideEffects } from "./compaction-hooks.js"; import { buildEmbeddedCompactionRuntimeContext } from "./compaction-runtime-context.js"; +import { resolveContextEngineCapabilities } from "./context-engine-capabilities.js"; import { runContextEngineMaintenance } from "./context-engine-maintenance.js"; import { hasMessagingToolDeliveryEvidence } from "./delivery-evidence.js"; import { resolveEmbeddedRunFailureSignal } from "./failure-signal.js"; @@ -957,6 +961,7 @@ export async function runEmbeddedPiAgent( agentDir, workspaceDir: resolvedWorkspace, }); + const contextEnginePluginId = resolveContextEngineOwnerPluginId(contextEngine); startupStages.mark("context-engine"); try { const resolveActiveHookContext = () => ({ @@ -1491,6 +1496,13 @@ export async function runEmbeddedPiAgent( sourceReplyDeliveryMode: params.sourceReplyDeliveryMode, ownerNumbers: params.ownerNumbers, }), + ...resolveContextEngineCapabilities({ + config: params.config, + sessionKey: params.sessionKey, + agentId: sessionAgentId, + contextEnginePluginId, + purpose: "context-engine.timeout-compaction", + }), onCompactionHookMessages, ...(attempt.promptCache ? { promptCache: attempt.promptCache } : {}), runId: params.runId, @@ -1649,6 +1661,13 @@ export async function runEmbeddedPiAgent( sourceReplyDeliveryMode: params.sourceReplyDeliveryMode, ownerNumbers: params.ownerNumbers, }), + ...resolveContextEngineCapabilities({ + config: params.config, + sessionKey: params.sessionKey, + agentId: sessionAgentId, + contextEnginePluginId, + purpose: "context-engine.overflow-compaction", + }), onCompactionHookMessages, ...(attempt.promptCache ? { promptCache: attempt.promptCache } : {}), runId: params.runId, @@ -1682,6 +1701,7 @@ export async function runEmbeddedPiAgent( reason: "compaction", runtimeContext: overflowCompactionRuntimeContext, config: params.config, + agentId: sessionAgentId, }); } } catch (compactErr) { diff --git a/src/agents/pi-embedded-runner/run/attempt.prompt-helpers.ts b/src/agents/pi-embedded-runner/run/attempt.prompt-helpers.ts index ce77c5388fa..a09bda75db9 100644 --- a/src/agents/pi-embedded-runner/run/attempt.prompt-helpers.ts +++ b/src/agents/pi-embedded-runner/run/attempt.prompt-helpers.ts @@ -21,6 +21,7 @@ import { resolveEffectiveToolFsWorkspaceOnly } from "../../tool-fs-policy.js"; import { derivePromptTokens, type NormalizedUsage } from "../../usage.js"; import { buildActiveVideoGenerationTaskPromptContextForSession } from "../../video-generation-task-status.js"; import { buildEmbeddedCompactionRuntimeContext } from "../compaction-runtime-context.js"; +import { resolveContextEngineCapabilities } from "../context-engine-capabilities.js"; import { log } from "../logger.js"; import { shouldInjectHeartbeatPromptForTrigger } from "./trigger-policy.js"; import type { EmbeddedRunAttemptParams } from "./types.js"; @@ -512,6 +513,8 @@ export function buildAfterTurnRuntimeContext(params: { >; workspaceDir: string; agentDir: string; + activeAgentId?: string; + contextEnginePluginId?: string; tokenBudget?: number; currentTokenCount?: number; promptCache?: ContextEnginePromptCacheInfo; @@ -540,6 +543,13 @@ export function buildAfterTurnRuntimeContext(params: { extraSystemPrompt: params.attempt.extraSystemPrompt, ownerNumbers: params.attempt.ownerNumbers, }), + ...resolveContextEngineCapabilities({ + config: params.attempt.config, + sessionKey: params.attempt.sessionKey, + agentId: params.activeAgentId, + contextEnginePluginId: params.contextEnginePluginId, + purpose: "context-engine.after-turn", + }), ...(typeof params.tokenBudget === "number" && Number.isFinite(params.tokenBudget) && params.tokenBudget > 0 diff --git a/src/agents/pi-embedded-runner/run/attempt.test.ts b/src/agents/pi-embedded-runner/run/attempt.test.ts index ad5f59f6614..5b6a57e79e3 100644 --- a/src/agents/pi-embedded-runner/run/attempt.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.test.ts @@ -1,5 +1,9 @@ import { streamSimple } from "@mariozechner/pi-ai"; import { describe, expect, it, vi } from "vitest"; + +vi.mock("../context-engine-capabilities.js", () => ({ + resolveContextEngineCapabilities: async () => ({ llm: undefined }), +})); import type { OpenClawConfig } from "../../../config/config.js"; import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "../../system-prompt-cache-boundary.js"; import { buildAgentSystemPrompt } from "../../system-prompt.js"; diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 1a725a44e10..682b91ecd04 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -17,6 +17,7 @@ import { runQuotaSuspensionMaintenance, updateSessionStoreEntry, } from "../../../config/sessions/store.js"; +import { resolveContextEngineOwnerPluginId } from "../../../context-engine/registry.js"; import type { AssembleResult } from "../../../context-engine/types.js"; import { emitTrustedDiagnosticEvent } from "../../../infra/diagnostic-events.js"; import { @@ -788,6 +789,7 @@ export async function runEmbeddedAttempt( ); } const activeContextEngine = isRawModelRun ? undefined : params.contextEngine; + const activeContextEnginePluginId = resolveContextEngineOwnerPluginId(activeContextEngine); const agentDir = params.agentDir ?? resolveAgentDir(params.config ?? {}, sessionAgentId); const diagnosticTrace = freezeDiagnosticTraceContext( createDiagnosticTraceContextFromActiveScope(), @@ -1462,6 +1464,8 @@ export async function runEmbeddedAttempt( workspaceDir: effectiveWorkspace, agentDir, tokenBudget: params.contextTokenBudget, + activeAgentId: sessionAgentId, + contextEnginePluginId: activeContextEnginePluginId, }), runMaintenance: async (contextParams) => await runContextEngineMaintenance({ @@ -1473,6 +1477,7 @@ export async function runEmbeddedAttempt( sessionManager: contextParams.sessionManager as never, runtimeContext: contextParams.runtimeContext, config: params.config, + agentId: sessionAgentId, }), warn: (message) => log.warn(message), }); @@ -3442,6 +3447,8 @@ export async function runEmbeddedAttempt( tokenBudget: params.contextTokenBudget, lastCallUsage, promptCache, + activeAgentId: sessionAgentId, + contextEnginePluginId: activeContextEnginePluginId, }); await finalizeAttemptContextEngineTurn({ contextEngine: activeContextEngine, @@ -3465,6 +3472,7 @@ export async function runEmbeddedAttempt( sessionManager: contextParams.sessionManager as never, runtimeContext: contextParams.runtimeContext, config: params.config, + agentId: sessionAgentId, }), sessionManager, config: params.config, diff --git a/src/agents/simple-completion-runtime.ts b/src/agents/simple-completion-runtime.ts index 9e5604176be..4c1d2906bd7 100644 --- a/src/agents/simple-completion-runtime.ts +++ b/src/agents/simple-completion-runtime.ts @@ -31,6 +31,7 @@ type AllowedMissingApiKeyMode = ResolvedProviderAuth["mode"]; export type SimpleCompletionModelOptions = { maxTokens?: number; + temperature?: number; signal?: AbortSignal; }; diff --git a/src/config/config-misc.test.ts b/src/config/config-misc.test.ts index 1a37810a321..5fd7e290aa8 100644 --- a/src/config/config-misc.test.ts +++ b/src/config/config-misc.test.ts @@ -501,6 +501,42 @@ describe("plugins.entries.*.subagent", () => { }); }); +describe("plugins.entries.*.llm", () => { + it("accepts trusted llm override settings", () => { + const result = OpenClawSchema.safeParse({ + plugins: { + entries: { + "voice-call": { + llm: { + allowModelOverride: true, + allowedModels: ["anthropic/claude-haiku-4-5"], + allowAgentIdOverride: true, + }, + }, + }, + }, + }); + expect(result.success).toBe(true); + }); + + it("rejects invalid trusted llm override settings", () => { + const result = OpenClawSchema.safeParse({ + plugins: { + entries: { + "voice-call": { + llm: { + allowModelOverride: "yes", + allowedModels: [1], + allowAgentIdOverride: "yes", + }, + }, + }, + }, + }); + expect(result.success).toBe(false); + }); +}); + describe("web search provider config", () => { it("accepts kimi provider and config", () => { const res = validateConfigObject( diff --git a/src/config/plugin-auto-enable.shared.ts b/src/config/plugin-auto-enable.shared.ts index 99a8b2fa4c0..e78c0cd3401 100644 --- a/src/config/plugin-auto-enable.shared.ts +++ b/src/config/plugin-auto-enable.shared.ts @@ -871,6 +871,7 @@ function hasMaterialPluginEntryConfig(entry: unknown): boolean { isRecord(entry.config) || isRecord(entry.hooks) || isRecord(entry.subagent) || + isRecord(entry.llm) || entry.apiKey !== undefined || entry.env !== undefined ); diff --git a/src/config/schema.help.quality.test.ts b/src/config/schema.help.quality.test.ts index e530364a85f..8dbd3168874 100644 --- a/src/config/schema.help.quality.test.ts +++ b/src/config/schema.help.quality.test.ts @@ -368,6 +368,10 @@ const TARGET_KEYS = [ "plugins.entries.*.subagent", "plugins.entries.*.subagent.allowModelOverride", "plugins.entries.*.subagent.allowedModels", + "plugins.entries.*.llm", + "plugins.entries.*.llm.allowModelOverride", + "plugins.entries.*.llm.allowedModels", + "plugins.entries.*.llm.allowAgentIdOverride", "plugins.entries.*.apiKey", "plugins.entries.*.env", "plugins.entries.*.config", diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 4da2cb2b0bd..2e2f2966a06 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -1243,6 +1243,14 @@ export const FIELD_HELP: Record = { "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", "plugins.entries.*.subagent.allowedModels": 'Allowed override targets for trusted plugin subagent runs as canonical "provider/model" refs. Use "*" only when you intentionally allow any model.', + "plugins.entries.*.llm": + "Per-plugin api.runtime.llm.complete controls for model and agent override trust. Keep this unset unless a plugin must explicitly steer host-owned completion calls.", + "plugins.entries.*.llm.allowModelOverride": + "Explicitly allows this plugin to request model overrides in api.runtime.llm.complete. Keep false unless the plugin is trusted to steer model selection.", + "plugins.entries.*.llm.allowedModels": + 'Allowed override targets for trusted plugin LLM completions as canonical "provider/model" refs. Use "*" only when you intentionally allow any model.', + "plugins.entries.*.llm.allowAgentIdOverride": + "Explicitly allows this plugin to request api.runtime.llm.complete against a non-default agent id. Keep false unless the plugin is trusted for cross-agent model access.", "plugins.entries.*.apiKey": "Optional API key field consumed by plugins that accept direct key configuration in entry settings. Use secret/env substitution and avoid committing real credentials into config files.", "plugins.entries.*.env": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index df1861c6418..b96e19b2c78 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -929,6 +929,10 @@ export const FIELD_LABELS: Record = { "plugins.entries.*.subagent": "Plugin Subagent Policy", "plugins.entries.*.subagent.allowModelOverride": "Allow Plugin Subagent Model Override", "plugins.entries.*.subagent.allowedModels": "Plugin Subagent Allowed Models", + "plugins.entries.*.llm": "Plugin LLM Policy", + "plugins.entries.*.llm.allowModelOverride": "Allow Plugin LLM Model Override", + "plugins.entries.*.llm.allowedModels": "Plugin LLM Allowed Models", + "plugins.entries.*.llm.allowAgentIdOverride": "Allow Plugin LLM Agent Override", "plugins.entries.*.apiKey": "Plugin API Key", // pragma: allowlist secret "plugins.entries.*.env": "Plugin Environment Variables", "plugins.entries.*.config": "Plugin Config", diff --git a/src/config/types.plugins.ts b/src/config/types.plugins.ts index fade95eccad..6fce9bc96aa 100644 --- a/src/config/types.plugins.ts +++ b/src/config/types.plugins.ts @@ -24,6 +24,17 @@ export type PluginEntryConfig = { */ allowedModels?: string[]; }; + llm?: { + /** Explicitly allow this plugin to request a model override for api.runtime.llm.complete. */ + allowModelOverride?: boolean; + /** + * Allowed completion model override targets as canonical provider/model refs. + * Use "*" to explicitly allow any model for this plugin. + */ + allowedModels?: string[]; + /** Explicitly allow this plugin to run completions against a non-default agent id. */ + allowAgentIdOverride?: boolean; + }; config?: Record; }; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 5c192513910..13326011a66 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -212,6 +212,14 @@ const PluginEntrySchema = z }) .strict() .optional(), + llm: z + .object({ + allowModelOverride: z.boolean().optional(), + allowedModels: z.array(z.string()).optional(), + allowAgentIdOverride: z.boolean().optional(), + }) + .strict() + .optional(), config: z.record(z.string(), z.unknown()).optional(), }) .strict(); diff --git a/src/context-engine/context-engine.test.ts b/src/context-engine/context-engine.test.ts index 0bfb890aed2..43dc27dab7a 100644 --- a/src/context-engine/context-engine.test.ts +++ b/src/context-engine/context-engine.test.ts @@ -16,6 +16,7 @@ import { getContextEngineFactory, listContextEngineIds, resolveContextEngine, + resolveContextEngineOwnerPluginId, } from "./registry.js"; import type { ContextEngineFactory, @@ -505,6 +506,17 @@ describe("Registry tests", () => { expect(getContextEngineFactory("reg-owner-guard")).toBe(factory1); }); + it("exposes the trusted plugin owner for a resolved registered engine", async () => { + const engineId = `owner-policy-${Date.now().toString(36)}`; + registerContextEngineForOwner(engineId, () => new MockContextEngine(), "plugin:lossless-claw", { + allowSameOwnerRefresh: true, + }); + + const engine = await resolveContextEngine(configWithSlot(engineId)); + + expect(resolveContextEngineOwnerPluginId(engine)).toBe("lossless-claw"); + }); + it("public registerContextEngine cannot spoof owner or refresh existing ids", () => { const ownedFactory = () => new MockContextEngine(); expect( diff --git a/src/context-engine/registry.ts b/src/context-engine/registry.ts index 04eacf829a8..cc4751d6e55 100644 --- a/src/context-engine/registry.ts +++ b/src/context-engine/registry.ts @@ -34,6 +34,7 @@ type RegisterContextEngineForOwnerOptions = { }; const LEGACY_SESSION_KEY_COMPAT = Symbol.for("openclaw.contextEngine.sessionKeyCompat"); +const RESOLVED_CONTEXT_ENGINE_METADATA = new WeakMap(); const SESSION_KEY_COMPAT_METHODS = [ "bootstrap", "maintain", @@ -317,6 +318,15 @@ function wrapContextEngineWithSessionKeyCompat(engine: ContextEngine): ContextEn return proxy; } +function wrapResolvedContextEngine( + engine: ContextEngine, + metadata: { owner: string }, +): ContextEngine { + const wrapped = wrapContextEngineWithSessionKeyCompat(engine); + RESOLVED_CONTEXT_ENGINE_METADATA.set(wrapped, metadata); + return wrapped; +} + // --------------------------------------------------------------------------- // Registry (module-level singleton) // --------------------------------------------------------------------------- @@ -424,6 +434,23 @@ export function clearContextEnginesForOwner(owner: string): void { } } +/** + * Return the trusted plugin id that registered a resolved context engine. + */ +export function resolveContextEngineOwnerPluginId( + engine: ContextEngine | undefined | null, +): string | undefined { + if (!engine) { + return undefined; + } + const owner = RESOLVED_CONTEXT_ENGINE_METADATA.get(engine)?.owner; + if (!owner?.startsWith("plugin:")) { + return undefined; + } + const pluginId = owner.slice("plugin:".length).trim(); + return pluginId || undefined; +} + function describeResolvedContextEngineContractError( engineId: string, engine: unknown, @@ -571,7 +598,7 @@ export async function resolveContextEngine( return resolveDefaultContextEngine(defaultEngineId, factoryCtx); } - return wrapContextEngineWithSessionKeyCompat(engine); + return wrapResolvedContextEngine(engine, { owner: entry.owner }); } /** @@ -596,5 +623,5 @@ async function resolveDefaultContextEngine( if (contractError) { throw new Error(`[context-engine] ${contractError}`); } - return wrapContextEngineWithSessionKeyCompat(engine); + return wrapResolvedContextEngine(engine, { owner: defaultEntry.owner }); } diff --git a/src/context-engine/types.ts b/src/context-engine/types.ts index 8dd07487a29..b870192a3ee 100644 --- a/src/context-engine/types.ts +++ b/src/context-engine/types.ts @@ -173,6 +173,12 @@ export type ContextEngineRuntimeContext = Record & { rewriteTranscriptEntries?: ( request: TranscriptRewriteRequest, ) => Promise; + /** LLM completion capability for engines that need model inference. */ + llm?: { + complete: ( + params: import("../plugins/runtime/types-core.js").LlmCompleteParams, + ) => Promise; + }; }; /** diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index e959f2de5e5..d31c2f66e18 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -61,6 +61,13 @@ export type { SubagentRunParams, SubagentRunResult, } from "../plugins/runtime/types.js"; +export type { + LlmCompleteCaller, + LlmCompleteMessage, + LlmCompleteParams, + LlmCompleteResult, + LlmCompleteUsage, +} from "../plugins/runtime/types-core.js"; export type { BoundTaskFlowsRuntime, BoundTaskRunsRuntime, diff --git a/src/plugin-sdk/test-helpers/plugin-runtime-mock.ts b/src/plugin-sdk/test-helpers/plugin-runtime-mock.ts index c5c23046563..6610c3b5195 100644 --- a/src/plugin-sdk/test-helpers/plugin-runtime-mock.ts +++ b/src/plugin-sdk/test-helpers/plugin-runtime-mock.ts @@ -722,6 +722,9 @@ export function createPluginRuntimeMock(overrides: DeepPartial = getSession: vi.fn(), deleteSession: vi.fn(), }, + llm: { + complete: vi.fn(), + }, nodes: { list: vi.fn(async () => ({ nodes: [] })), invoke: vi.fn(), diff --git a/src/plugins/config-normalization-shared.ts b/src/plugins/config-normalization-shared.ts index 33482bcd986..f630c0cb722 100644 --- a/src/plugins/config-normalization-shared.ts +++ b/src/plugins/config-normalization-shared.ts @@ -30,6 +30,12 @@ export type NormalizedPluginsConfig = { allowedModels?: string[]; hasAllowedModelsConfig?: boolean; }; + llm?: { + allowModelOverride?: boolean; + allowedModels?: string[]; + hasAllowedModelsConfig?: boolean; + allowAgentIdOverride?: boolean; + }; config?: unknown; } >; @@ -164,12 +170,49 @@ function normalizePluginEntries( : {}), } : undefined; + const llmRaw = entry.llm; + const llm = + llmRaw && typeof llmRaw === "object" && !Array.isArray(llmRaw) + ? { + allowModelOverride: (llmRaw as { allowModelOverride?: unknown }).allowModelOverride, + hasAllowedModelsConfig: Array.isArray( + (llmRaw as { allowedModels?: unknown }).allowedModels, + ), + allowedModels: Array.isArray((llmRaw as { allowedModels?: unknown }).allowedModels) + ? ((llmRaw as { allowedModels?: unknown }).allowedModels as unknown[]) + .map((model) => normalizeOptionalString(model)) + .filter((model): model is string => Boolean(model)) + : undefined, + allowAgentIdOverride: (llmRaw as { allowAgentIdOverride?: unknown }) + .allowAgentIdOverride, + } + : undefined; + const normalizedLlm = + llm && + (typeof llm.allowModelOverride === "boolean" || + llm.hasAllowedModelsConfig || + (Array.isArray(llm.allowedModels) && llm.allowedModels.length > 0) || + typeof llm.allowAgentIdOverride === "boolean") + ? { + ...(typeof llm.allowModelOverride === "boolean" + ? { allowModelOverride: llm.allowModelOverride } + : {}), + ...(llm.hasAllowedModelsConfig ? { hasAllowedModelsConfig: true } : {}), + ...(Array.isArray(llm.allowedModels) && llm.allowedModels.length > 0 + ? { allowedModels: llm.allowedModels } + : {}), + ...(typeof llm.allowAgentIdOverride === "boolean" + ? { allowAgentIdOverride: llm.allowAgentIdOverride } + : {}), + } + : undefined; normalized[normalizedKey] = { ...normalized[normalizedKey], enabled: typeof entry.enabled === "boolean" ? entry.enabled : normalized[normalizedKey]?.enabled, hooks: normalizedHooks ?? normalized[normalizedKey]?.hooks, subagent: normalizedSubagent ?? normalized[normalizedKey]?.subagent, + llm: normalizedLlm ?? normalized[normalizedKey]?.llm, config: "config" in entry ? entry.config : normalized[normalizedKey]?.config, }; } diff --git a/src/plugins/config-state.test.ts b/src/plugins/config-state.test.ts index 0fe9c5f7f94..4e9f09f68df 100644 --- a/src/plugins/config-state.test.ts +++ b/src/plugins/config-state.test.ts @@ -145,6 +145,23 @@ describe("normalizePluginsConfig", () => { expect(normalizeVoiceCallEntry({ subagent })?.subagent).toEqual(expected); }); + it("normalizes plugin llm override policy settings", () => { + expect( + normalizeVoiceCallEntry({ + llm: { + allowModelOverride: true, + allowedModels: [" openai/gpt-5.4 ", "", "anthropic/claude-sonnet-4-6"], + allowAgentIdOverride: false, + }, + })?.llm, + ).toEqual({ + allowModelOverride: true, + hasAllowedModelsConfig: true, + allowedModels: ["openai/gpt-5.4", "anthropic/claude-sonnet-4-6"], + allowAgentIdOverride: false, + }); + }); + it("normalizes legacy plugin ids to their merged bundled plugin id", () => { const result = normalizePluginsConfig({ allow: ["openai-codex", "google-gemini-cli", "minimax-portal-auth"], diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index a8d3e1da21f..c86dafc0423 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -2244,6 +2244,13 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { }, } satisfies PluginRuntime["state"]; } + if (prop === "llm") { + const llm = Reflect.get(target, prop, receiver); + return { + complete: (params) => + withPluginRuntimePluginIdScope(pluginId, () => llm.complete(params)), + } satisfies PluginRuntime["llm"]; + } if (prop !== "subagent") { return Reflect.get(target, prop, receiver); } diff --git a/src/plugins/runtime/index.ts b/src/plugins/runtime/index.ts index 011fcfb71ac..1e2ce62acba 100644 --- a/src/plugins/runtime/index.ts +++ b/src/plugins/runtime/index.ts @@ -1,3 +1,4 @@ +import { getRuntimeConfig } from "../../config/config.js"; import { resolveStateDir } from "../../config/paths.js"; import { generateImage as generateRuntimeImage, @@ -12,6 +13,7 @@ import { createLazyRuntimeMethod, createLazyRuntimeMethodBinder, createLazyRuntimeModule, + createLazyRuntimeSurface, } from "../../shared/lazy-runtime.js"; import { VERSION } from "../../version.js"; import { @@ -98,6 +100,25 @@ function createRuntimeMusicGeneration(): PluginRuntime["musicGeneration"] { }; } +function createRuntimeLlmFacade(): PluginRuntime["llm"] { + const loadLlm = createLazyRuntimeSurface( + () => import("./runtime-llm.runtime.js"), + (m) => + m.createRuntimeLlm({ + getConfig: getRuntimeConfig, + authority: { + allowComplete: true, + }, + }), + ); + return { + complete: async (params) => { + const llm = await loadLlm(); + return llm.complete(params); + }, + }; +} + function createRuntimeModelAuth(): PluginRuntime["modelAuth"] { const getApiKeyForModel = createLazyRuntimeMethod( loadModelAuthRuntime, @@ -246,6 +267,7 @@ export function createPluginRuntime(_options: CreatePluginRuntimeOptions = {}): | "imageGeneration" | "videoGeneration" | "musicGeneration" + | "llm" > & Partial< Pick< @@ -257,6 +279,7 @@ export function createPluginRuntime(_options: CreatePluginRuntimeOptions = {}): | "imageGeneration" | "videoGeneration" | "musicGeneration" + | "llm" > >; @@ -269,6 +292,7 @@ export function createPluginRuntime(_options: CreatePluginRuntimeOptions = {}): defineCachedValue(runtime, "imageGeneration", createRuntimeImageGeneration); defineCachedValue(runtime, "videoGeneration", createRuntimeVideoGeneration); defineCachedValue(runtime, "musicGeneration", createRuntimeMusicGeneration); + defineCachedValue(runtime, "llm", createRuntimeLlmFacade); return runtime as unknown as PluginRuntime; } diff --git a/src/plugins/runtime/runtime-llm.runtime.test.ts b/src/plugins/runtime/runtime-llm.runtime.test.ts new file mode 100644 index 00000000000..3c05dcf8ecd --- /dev/null +++ b/src/plugins/runtime/runtime-llm.runtime.test.ts @@ -0,0 +1,607 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { resolveContextEngineCapabilities } from "../../agents/pi-embedded-runner/context-engine-capabilities.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { withPluginRuntimePluginIdScope } from "./gateway-request-scope.js"; +import { createRuntimeLlm } from "./runtime-llm.runtime.js"; +import type { RuntimeLogger } from "./types-core.js"; + +const hoisted = vi.hoisted(() => ({ + prepareSimpleCompletionModelForAgent: vi.fn(), + completeWithPreparedSimpleCompletionModel: vi.fn(), + resolveSimpleCompletionSelectionForAgent: vi.fn(), +})); + +vi.mock("../../agents/simple-completion-runtime.js", () => ({ + prepareSimpleCompletionModelForAgent: hoisted.prepareSimpleCompletionModelForAgent, + completeWithPreparedSimpleCompletionModel: hoisted.completeWithPreparedSimpleCompletionModel, + resolveSimpleCompletionSelectionForAgent: hoisted.resolveSimpleCompletionSelectionForAgent, +})); + +const cfg = { + agents: { + defaults: { + model: "openai/gpt-5.5", + }, + }, +} satisfies OpenClawConfig; + +function createPreparedModel(modelId = "gpt-5.5") { + return { + selection: { + provider: "openai", + modelId, + agentDir: "/tmp/openclaw-agent", + }, + model: { + provider: "openai", + id: modelId, + name: modelId, + api: "openai", + input: ["text"], + reasoning: false, + contextWindow: 128_000, + maxTokens: 4096, + cost: { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 0.2 }, + }, + auth: { + apiKey: "test-api-key", + source: "test", + mode: "api-key", + }, + }; +} + +function createLogger(): RuntimeLogger { + return { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; +} + +function primeCompletionMocks() { + hoisted.prepareSimpleCompletionModelForAgent.mockResolvedValue(createPreparedModel()); + hoisted.resolveSimpleCompletionSelectionForAgent.mockImplementation( + (params: { modelRef?: string; agentId: string }) => { + if (!params.modelRef) { + return { + provider: "openai", + modelId: "gpt-5.5", + agentDir: `/tmp/${params.agentId}`, + }; + } + const slash = params.modelRef.indexOf("/"); + return { + provider: slash > 0 ? params.modelRef.slice(0, slash) : "openai", + modelId: slash > 0 ? params.modelRef.slice(slash + 1) : params.modelRef, + agentDir: `/tmp/${params.agentId}`, + }; + }, + ); + hoisted.completeWithPreparedSimpleCompletionModel.mockResolvedValue({ + content: [{ type: "text", text: "done" }], + usage: { + input: 11, + output: 7, + cacheRead: 5, + cacheWrite: 2, + total: 25, + cost: { total: 0.0042 }, + }, + }); +} + +describe("runtime.llm.complete", () => { + beforeEach(() => { + hoisted.prepareSimpleCompletionModelForAgent.mockReset(); + hoisted.completeWithPreparedSimpleCompletionModel.mockReset(); + hoisted.resolveSimpleCompletionSelectionForAgent.mockReset(); + primeCompletionMocks(); + }); + + it("binds context-engine completions to the active session agent", async () => { + const runtimeContext = resolveContextEngineCapabilities({ + config: cfg, + sessionKey: "agent:ada:session:abc", + purpose: "context-engine.after-turn", + }); + + const result = await runtimeContext.llm!.complete({ + messages: [{ role: "user", content: "summarize" }], + purpose: "memory-maintenance", + }); + + expect(hoisted.prepareSimpleCompletionModelForAgent).toHaveBeenCalledWith( + expect.objectContaining({ + cfg, + agentId: "ada", + allowMissingApiKeyModes: ["aws-sdk"], + }), + ); + expect(result.agentId).toBe("ada"); + expect(result.audit).toMatchObject({ + caller: { kind: "context-engine", id: "context-engine.after-turn" }, + purpose: "memory-maintenance", + sessionKey: "agent:ada:session:abc", + }); + }); + + it("uses trusted context-engine attribution inside plugin runtime scope", async () => { + const runtimeContext = resolveContextEngineCapabilities({ + config: cfg, + sessionKey: "agent:ada:session:abc", + purpose: "context-engine.after-turn", + }); + + const result = await withPluginRuntimePluginIdScope("memory-core", () => + runtimeContext.llm!.complete({ + messages: [{ role: "user", content: "summarize" }], + purpose: "memory-maintenance", + }), + ); + + expect(result.audit.caller).toEqual({ + kind: "context-engine", + id: "context-engine.after-turn", + }); + expect(result.agentId).toBe("ada"); + }); + + it("does not fall back to the default agent for unbound active-session hooks", async () => { + const runtimeContext = resolveContextEngineCapabilities({ + config: cfg, + sessionKey: "legacy-session", + purpose: "context-engine.after-turn", + }); + + await expect( + runtimeContext.llm!.complete({ + messages: [{ role: "user", content: "summarize" }], + }), + ).rejects.toThrow("not bound to an active session agent"); + expect(hoisted.prepareSimpleCompletionModelForAgent).not.toHaveBeenCalled(); + }); + + it("fails closed for context-engine completions without any session agent", async () => { + const runtimeContext = resolveContextEngineCapabilities({ + config: cfg, + purpose: "context-engine.after-turn", + }); + + await expect( + runtimeContext.llm!.complete({ + messages: [{ role: "user", content: "summarize" }], + }), + ).rejects.toThrow("not bound to an active session agent"); + expect(hoisted.prepareSimpleCompletionModelForAgent).not.toHaveBeenCalled(); + }); + + it("denies context-engine model overrides without owning plugin llm policy", async () => { + const runtimeContext = resolveContextEngineCapabilities({ + config: cfg, + sessionKey: "agent:main:session:abc", + contextEnginePluginId: "lossless-claw", + purpose: "context-engine.compaction", + }); + + await expect( + runtimeContext.llm!.complete({ + model: "openai-codex/gpt-5.4-mini", + messages: [{ role: "user", content: "summarize" }], + }), + ).rejects.toThrow("cannot override the target model"); + expect(hoisted.prepareSimpleCompletionModelForAgent).not.toHaveBeenCalled(); + }); + + it("allows context-engine model overrides through the owning plugin llm policy", async () => { + const runtimeContext = resolveContextEngineCapabilities({ + config: { + ...cfg, + plugins: { + entries: { + "lossless-claw": { + llm: { + allowModelOverride: true, + allowedModels: ["openai-codex/gpt-5.4-mini", "minimax/MiniMax-M2.7"], + }, + }, + }, + }, + }, + sessionKey: "agent:main:session:abc", + contextEnginePluginId: "lossless-claw", + purpose: "context-engine.compaction", + }); + + const result = await runtimeContext.llm!.complete({ + agentId: "main", + model: "openai-codex/gpt-5.4-mini", + messages: [{ role: "user", content: "summarize" }], + }); + + expect(hoisted.prepareSimpleCompletionModelForAgent).toHaveBeenCalledWith( + expect.objectContaining({ + agentId: "main", + modelRef: "openai-codex/gpt-5.4-mini", + }), + ); + expect(result.audit).toMatchObject({ + caller: { kind: "context-engine", id: "context-engine.compaction" }, + sessionKey: "agent:main:session:abc", + }); + }); + + it("denies context-engine model overrides outside the owning plugin allowlist", async () => { + const runtimeContext = resolveContextEngineCapabilities({ + config: { + ...cfg, + plugins: { + entries: { + "lossless-claw": { + llm: { + allowModelOverride: true, + allowedModels: ["openai-codex/gpt-5.4-mini"], + }, + }, + }, + }, + }, + sessionKey: "agent:main:session:abc", + contextEnginePluginId: "lossless-claw", + purpose: "context-engine.compaction", + }); + + await expect( + runtimeContext.llm!.complete({ + model: "openai-codex/gpt-5.5", + messages: [{ role: "user", content: "summarize" }], + }), + ).rejects.toThrow( + 'model override "openai-codex/gpt-5.5" is not allowlisted for plugin "lossless-claw"', + ); + expect(hoisted.prepareSimpleCompletionModelForAgent).not.toHaveBeenCalled(); + }); + + it("keeps context-engine attribution and host-derived policy inside plugin runtime scope", async () => { + const runtimeContext = resolveContextEngineCapabilities({ + config: { + ...cfg, + plugins: { + entries: { + "lossless-claw": { + llm: { + allowModelOverride: true, + allowedModels: ["openai-codex/gpt-5.4-mini"], + }, + }, + }, + }, + }, + sessionKey: "agent:main:session:abc", + contextEnginePluginId: "lossless-claw", + purpose: "context-engine.compaction", + }); + + const result = await withPluginRuntimePluginIdScope("spoofed-plugin", () => + runtimeContext.llm!.complete({ + model: "openai-codex/gpt-5.4-mini", + messages: [{ role: "user", content: "summarize" }], + caller: { kind: "plugin", id: "spoofed-plugin" }, + } as Parameters["complete"]>[0] & { + caller: unknown; + }), + ); + + expect(result.audit.caller).toEqual({ + kind: "context-engine", + id: "context-engine.compaction", + }); + expect(hoisted.prepareSimpleCompletionModelForAgent).toHaveBeenCalledWith( + expect.objectContaining({ + modelRef: "openai-codex/gpt-5.4-mini", + }), + ); + }); + + it("allows the bound context-engine agent and denies cross-agent overrides", async () => { + const runtimeContext = resolveContextEngineCapabilities({ + config: cfg, + sessionKey: "main", + purpose: "context-engine.compaction", + }); + + await runtimeContext.llm!.complete({ + agentId: "main", + messages: [{ role: "user", content: "summarize" }], + }); + expect(hoisted.prepareSimpleCompletionModelForAgent).toHaveBeenCalledWith( + expect.objectContaining({ + agentId: "main", + }), + ); + + await expect( + runtimeContext.llm!.complete({ + agentId: "worker", + messages: [{ role: "user", content: "summarize" }], + }), + ).rejects.toThrow("cannot override the active session agent"); + }); + + it("allows explicit agentId for non-session plugin calls", async () => { + const logger = createLogger(); + const llm = createRuntimeLlm({ + getConfig: () => cfg, + logger, + authority: { + allowAgentIdOverride: true, + allowModelOverride: true, + allowComplete: true, + }, + }); + + await llm.complete({ + agentId: "worker", + messages: [{ role: "user", content: "draft" }], + }); + + expect(hoisted.prepareSimpleCompletionModelForAgent).toHaveBeenCalledWith( + expect.objectContaining({ + cfg, + agentId: "worker", + }), + ); + }); + + it("allows host model overrides only when explicit authority allowlists the model", async () => { + const llm = createRuntimeLlm({ + getConfig: () => cfg, + authority: { + caller: { kind: "host", id: "runtime-test" }, + allowModelOverride: true, + allowedModels: ["openai/gpt-5.4"], + allowComplete: true, + }, + }); + + await llm.complete({ + model: "openai/gpt-5.4", + messages: [{ role: "user", content: "Ping" }], + }); + expect(hoisted.prepareSimpleCompletionModelForAgent).toHaveBeenCalledWith( + expect.objectContaining({ + modelRef: "openai/gpt-5.4", + }), + ); + + await expect( + llm.complete({ + model: "openai/gpt-5.5", + messages: [{ role: "user", content: "Ping" }], + }), + ).rejects.toThrow('model override "openai/gpt-5.5" is not allowlisted'); + }); + + it("uses runtime-scoped config and the host preparation/dispatch path", async () => { + const logger = createLogger(); + const llm = createRuntimeLlm({ + getConfig: () => cfg, + logger, + authority: { + caller: { kind: "host", id: "runtime-test" }, + allowComplete: true, + }, + }); + + const result = await llm.complete({ + messages: [ + { role: "system", content: "Be terse." }, + { role: "user", content: "Ping" }, + ], + temperature: 0.2, + maxTokens: 64, + purpose: "test-purpose", + }); + + expect(hoisted.prepareSimpleCompletionModelForAgent).toHaveBeenCalledWith( + expect.objectContaining({ cfg, agentId: "main" }), + ); + expect(hoisted.completeWithPreparedSimpleCompletionModel).toHaveBeenCalledWith( + expect.objectContaining({ + cfg, + context: expect.objectContaining({ + systemPrompt: "Be terse.", + messages: [expect.objectContaining({ role: "user", content: "Ping" })], + }), + options: expect.objectContaining({ + maxTokens: 64, + temperature: 0.2, + }), + }), + ); + expect(result).toMatchObject({ + text: "done", + provider: "openai", + model: "gpt-5.5", + usage: { + inputTokens: 11, + outputTokens: 7, + cacheReadTokens: 5, + cacheWriteTokens: 2, + totalTokens: 25, + costUsd: 0.0042, + }, + }); + expect(logger.info).toHaveBeenCalledWith( + "plugin llm completion", + expect.objectContaining({ + caller: { kind: "host", id: "runtime-test" }, + purpose: "test-purpose", + usage: expect.objectContaining({ costUsd: 0.0042 }), + }), + ); + }); + + it("uses scoped plugin identity and ignores caller-shaped spoofing input", async () => { + const logger = createLogger(); + const llm = createRuntimeLlm({ + getConfig: () => cfg, + logger, + authority: { + caller: { kind: "host", id: "ignored-host" }, + allowComplete: true, + }, + }); + + const result = await withPluginRuntimePluginIdScope("trusted-plugin", () => + llm.complete({ + messages: [{ role: "user", content: "Ping" }], + purpose: "identity-test", + caller: { kind: "plugin", id: "spoofed-plugin" }, + } as Parameters[0] & { caller: unknown }), + ); + + expect(result.audit.caller).toEqual({ kind: "plugin", id: "trusted-plugin" }); + expect(logger.info).toHaveBeenCalledWith( + "plugin llm completion", + expect.objectContaining({ + caller: { kind: "plugin", id: "trusted-plugin" }, + purpose: "identity-test", + }), + ); + }); + + it("denies plugin model overrides by default", async () => { + const llm = createRuntimeLlm({ + getConfig: () => cfg, + authority: { + allowComplete: true, + }, + }); + + await expect( + withPluginRuntimePluginIdScope("plain-plugin", () => + llm.complete({ + model: "openai/gpt-5.4", + messages: [{ role: "user", content: "Ping" }], + }), + ), + ).rejects.toThrow("cannot override the target model"); + expect(hoisted.prepareSimpleCompletionModelForAgent).not.toHaveBeenCalled(); + }); + + it("denies plugin agent overrides by default and allows them only when configured", async () => { + const denied = createRuntimeLlm({ + getConfig: () => cfg, + authority: { + allowComplete: true, + }, + }); + + await expect( + withPluginRuntimePluginIdScope("plain-plugin", () => + denied.complete({ + agentId: "worker", + messages: [{ role: "user", content: "Ping" }], + }), + ), + ).rejects.toThrow("cannot override the target agent"); + + const allowed = createRuntimeLlm({ + getConfig: () => ({ + ...cfg, + plugins: { + entries: { + "trusted-plugin": { + llm: { + allowAgentIdOverride: true, + }, + }, + }, + }, + }), + authority: { + allowComplete: true, + }, + }); + + await withPluginRuntimePluginIdScope("trusted-plugin", () => + allowed.complete({ + agentId: "worker", + messages: [{ role: "user", content: "Ping" }], + }), + ); + expect(hoisted.prepareSimpleCompletionModelForAgent).toHaveBeenCalledWith( + expect.objectContaining({ + agentId: "worker", + }), + ); + }); + + it("allows plugin model overrides only when configured and allowlisted", async () => { + const llm = createRuntimeLlm({ + getConfig: () => ({ + ...cfg, + plugins: { + entries: { + "trusted-plugin": { + llm: { + allowModelOverride: true, + allowedModels: ["openai/gpt-5.4"], + }, + }, + }, + }, + }), + authority: { + allowComplete: true, + }, + }); + + await withPluginRuntimePluginIdScope("trusted-plugin", () => + llm.complete({ + model: "openai/gpt-5.4", + messages: [{ role: "user", content: "Ping" }], + }), + ); + expect(hoisted.prepareSimpleCompletionModelForAgent).toHaveBeenCalledWith( + expect.objectContaining({ + agentId: "main", + modelRef: "openai/gpt-5.4", + }), + ); + + await expect( + withPluginRuntimePluginIdScope("trusted-plugin", () => + llm.complete({ + model: "openai/gpt-5.5", + messages: [{ role: "user", content: "Ping" }], + }), + ), + ).rejects.toThrow('model override "openai/gpt-5.5" is not allowlisted'); + }); + + it("denies completions when runtime authority disables the capability", async () => { + const logger = createLogger(); + const llm = createRuntimeLlm({ + getConfig: () => cfg, + logger, + authority: { + allowComplete: false, + denyReason: "not trusted", + }, + }); + + await expect( + llm.complete({ + messages: [{ role: "user", content: "Ping" }], + }), + ).rejects.toThrow("Plugin LLM completion denied: not trusted"); + expect(hoisted.prepareSimpleCompletionModelForAgent).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledWith( + "plugin llm completion denied", + expect.objectContaining({ reason: "not trusted" }), + ); + }); +}); diff --git a/src/plugins/runtime/runtime-llm.runtime.ts b/src/plugins/runtime/runtime-llm.runtime.ts new file mode 100644 index 00000000000..8567058d4d1 --- /dev/null +++ b/src/plugins/runtime/runtime-llm.runtime.ts @@ -0,0 +1,485 @@ +import type { Api, Message } from "@mariozechner/pi-ai"; +import { normalizeModelRef } from "../../agents/model-selection.js"; +import type { NormalizedUsage, UsageLike } from "../../agents/usage.js"; +import { normalizeUsage } from "../../agents/usage.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { getChildLogger } from "../../logging.js"; +import { normalizeAgentId } from "../../routing/session-key.js"; +import { normalizeOptionalString } from "../../shared/string-coerce.js"; +import { estimateUsageCost, resolveModelCostConfig } from "../../utils/usage-format.js"; +import { normalizePluginsConfig } from "../config-state.js"; +import { getPluginRuntimeGatewayRequestScope } from "./gateway-request-scope.js"; +import type { + LlmCompleteCaller, + LlmCompleteParams, + LlmCompleteResult, + LlmCompleteUsage, + PluginRuntimeCore, + RuntimeLogger, +} from "./types-core.js"; + +export type RuntimeLlmAuthority = { + caller?: LlmCompleteCaller; + /** Trusted host-derived plugin id used only for config policy lookup. */ + pluginIdForPolicy?: string; + sessionKey?: string; + agentId?: string; + requiresBoundAgent?: boolean; + allowAgentIdOverride?: boolean; + allowModelOverride?: boolean; + allowedModels?: readonly string[]; + allowComplete?: boolean; + denyReason?: string; +}; + +export type CreateRuntimeLlmOptions = { + getConfig?: () => OpenClawConfig | undefined; + authority?: RuntimeLlmAuthority; + logger?: RuntimeLogger; +}; + +type RuntimeLlmOverridePolicy = { + allowAgentIdOverride: boolean; + allowModelOverride: boolean; + hasConfiguredAllowedModels: boolean; + allowAnyModel: boolean; + allowedModels: Set; +}; + +const defaultLogger = getChildLogger({ capability: "runtime.llm" }); + +function toRuntimeLogger(logger: typeof defaultLogger): RuntimeLogger { + return { + debug: (message, meta) => logger.debug?.(meta, message), + info: (message, meta) => logger.info(meta, message), + warn: (message, meta) => logger.warn(meta, message), + error: (message, meta) => logger.error(meta, message), + }; +} + +function normalizeCaller( + caller?: LlmCompleteCaller, + fallback?: LlmCompleteCaller, +): LlmCompleteCaller { + const source = caller ?? fallback; + if (!source) { + return { kind: "unknown" }; + } + return { + kind: source.kind, + ...(normalizeOptionalString(source.id) ? { id: source.id!.trim() } : {}), + ...(normalizeOptionalString(source.name) ? { name: source.name!.trim() } : {}), + }; +} + +function resolveTrustedCaller(authority?: RuntimeLlmAuthority): LlmCompleteCaller { + if (authority?.caller?.kind === "context-engine") { + return normalizeCaller(authority.caller); + } + const scope = getPluginRuntimeGatewayRequestScope(); + const scopedPluginId = normalizeOptionalString(scope?.pluginId); + if (scopedPluginId) { + return { kind: "plugin", id: scopedPluginId }; + } + return normalizeCaller(authority?.caller); +} + +function resolveRuntimeConfig(options: CreateRuntimeLlmOptions): OpenClawConfig { + const cfg = options.getConfig?.(); + if (!cfg) { + throw new Error("Plugin LLM completion requires an injected runtime config scope."); + } + return cfg; +} + +async function resolveAgentId(params: { + request: LlmCompleteParams; + cfg: OpenClawConfig; + authority?: RuntimeLlmAuthority; + allowAgentIdOverride: boolean; +}): Promise { + const authorityAgentIdRaw = normalizeOptionalString(params.authority?.agentId); + const requestedAgentIdRaw = normalizeOptionalString(params.request.agentId); + const authorityAgentId = authorityAgentIdRaw ? normalizeAgentId(authorityAgentIdRaw) : undefined; + const requestedAgentId = requestedAgentIdRaw ? normalizeAgentId(requestedAgentIdRaw) : undefined; + if (params.authority?.requiresBoundAgent && !authorityAgentId) { + throw new Error("Plugin LLM completion is not bound to an active session agent."); + } + if (authorityAgentId) { + if (requestedAgentId && requestedAgentId !== authorityAgentId && !params.allowAgentIdOverride) { + throw new Error("Plugin LLM completion cannot override the active session agent."); + } + return authorityAgentId; + } + if (requestedAgentId) { + if (!params.allowAgentIdOverride) { + throw new Error("Plugin LLM completion cannot override the target agent."); + } + return requestedAgentId; + } + const { resolveDefaultAgentId } = await import("../../agents/agent-scope.js"); + return resolveDefaultAgentId(params.cfg); +} + +function buildSystemPrompt(params: LlmCompleteParams): string | undefined { + const segments = [ + normalizeOptionalString(params.systemPrompt), + ...params.messages + .filter((message) => message.role === "system") + .map((message) => normalizeOptionalString(message.content)), + ].filter((segment): segment is string => Boolean(segment)); + return segments.length > 0 ? segments.join("\n\n") : undefined; +} + +function buildMessages(params: { + request: LlmCompleteParams; + provider: string; + model: string; + api: Api; +}): Message[] { + const now = Date.now(); + return params.request.messages + .filter((message) => message.role !== "system") + .map((message) => + message.role === "user" + ? { role: "user" as const, content: message.content, timestamp: now } + : { + role: "assistant" as const, + content: [{ type: "text" as const, text: message.content }], + api: params.api, + provider: params.provider, + model: params.model, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop" as const, + timestamp: now, + }, + ); +} + +function readFiniteNonNegativeNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined; +} + +function readExplicitCostUsd(raw: unknown): number | undefined { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + return undefined; + } + const cost = (raw as { cost?: unknown }).cost; + if (typeof cost === "number") { + return readFiniteNonNegativeNumber(cost); + } + if (!cost || typeof cost !== "object" || Array.isArray(cost)) { + return undefined; + } + return ( + readFiniteNonNegativeNumber((cost as { total?: unknown; totalUsd?: unknown }).totalUsd) ?? + readFiniteNonNegativeNumber((cost as { total?: unknown }).total) + ); +} + +function buildUsage(params: { + rawUsage: unknown; + normalized: NormalizedUsage | undefined; + cfg: OpenClawConfig; + provider: string; + model: string; +}): LlmCompleteUsage { + const costConfig = resolveModelCostConfig({ + provider: params.provider, + model: params.model, + config: params.cfg, + }); + const costUsd = + readExplicitCostUsd(params.rawUsage) ?? + estimateUsageCost({ usage: params.normalized, cost: costConfig }); + return { + ...(params.normalized?.input !== undefined ? { inputTokens: params.normalized.input } : {}), + ...(params.normalized?.output !== undefined ? { outputTokens: params.normalized.output } : {}), + ...(params.normalized?.cacheRead !== undefined + ? { cacheReadTokens: params.normalized.cacheRead } + : {}), + ...(params.normalized?.cacheWrite !== undefined + ? { cacheWriteTokens: params.normalized.cacheWrite } + : {}), + ...(params.normalized?.total !== undefined ? { totalTokens: params.normalized.total } : {}), + ...(costUsd !== undefined ? { costUsd } : {}), + }; +} + +function finiteOption(value: number | undefined): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function normalizeAllowedModelRef(raw: string): string | null { + const trimmed = raw.trim(); + if (!trimmed) { + return null; + } + if (trimmed === "*") { + return "*"; + } + const slash = trimmed.indexOf("/"); + if (slash <= 0 || slash >= trimmed.length - 1) { + return null; + } + const provider = trimmed.slice(0, slash).trim(); + const model = trimmed.slice(slash + 1).trim(); + if (!provider || !model) { + return null; + } + const normalized = normalizeModelRef(provider, model); + return `${normalized.provider}/${normalized.model}`; +} + +function buildPolicyFromEntry(entry: { + allowAgentIdOverride?: boolean; + allowModelOverride?: boolean; + hasAllowedModelsConfig?: boolean; + allowedModels?: readonly string[]; +}): RuntimeLlmOverridePolicy { + const allowedModels = new Set(); + let allowAnyModel = false; + for (const modelRef of entry.allowedModels ?? []) { + const normalizedModelRef = normalizeAllowedModelRef(modelRef); + if (!normalizedModelRef) { + continue; + } + if (normalizedModelRef === "*") { + allowAnyModel = true; + continue; + } + allowedModels.add(normalizedModelRef); + } + return { + allowAgentIdOverride: entry.allowAgentIdOverride === true, + allowModelOverride: entry.allowModelOverride === true, + hasConfiguredAllowedModels: entry.hasAllowedModelsConfig === true, + allowAnyModel, + allowedModels, + }; +} + +function resolvePluginPolicyId( + authority: RuntimeLlmAuthority | undefined, + caller: LlmCompleteCaller, +): string | undefined { + const authorityPluginId = normalizeOptionalString(authority?.pluginIdForPolicy); + if (authorityPluginId) { + return authorityPluginId; + } + if (caller.kind !== "plugin") { + return undefined; + } + const pluginId = normalizeOptionalString(caller.id); + return pluginId; +} + +function resolvePluginLlmOverridePolicy( + cfg: OpenClawConfig, + pluginId: string | undefined, +): RuntimeLlmOverridePolicy | undefined { + if (!pluginId) { + return undefined; + } + const entry = normalizePluginsConfig(cfg.plugins).entries[pluginId]?.llm; + return entry ? buildPolicyFromEntry(entry) : undefined; +} + +function resolveAuthorityModelPolicy( + authority?: RuntimeLlmAuthority, +): RuntimeLlmOverridePolicy | undefined { + if ( + authority?.allowAgentIdOverride !== true && + authority?.allowModelOverride !== true && + authority?.allowedModels === undefined + ) { + return undefined; + } + return buildPolicyFromEntry({ + allowAgentIdOverride: authority.allowAgentIdOverride, + allowModelOverride: authority.allowModelOverride, + hasAllowedModelsConfig: authority.allowedModels !== undefined, + allowedModels: authority.allowedModels, + }); +} + +function assertAllowedModelOverride(params: { + resolvedModelRef: string | null; + pluginPolicyId: string | undefined; + authorityPolicy: RuntimeLlmOverridePolicy | undefined; + pluginPolicy: RuntimeLlmOverridePolicy | undefined; +}): void { + let policy: RuntimeLlmOverridePolicy | undefined; + let policyOwnerPluginId: string | undefined; + if (params.authorityPolicy?.allowModelOverride) { + policy = params.authorityPolicy; + } else if (params.pluginPolicy?.allowModelOverride) { + policy = params.pluginPolicy; + policyOwnerPluginId = params.pluginPolicyId; + } + if (!policy) { + throw new Error("Plugin LLM completion cannot override the target model."); + } + if (policy.allowAnyModel) { + return; + } + if (policy.hasConfiguredAllowedModels && policy.allowedModels.size === 0) { + throw new Error("Plugin LLM completion model override allowlist has no valid models."); + } + if (policy.allowedModels.size === 0) { + return; + } + if (!params.resolvedModelRef) { + throw new Error( + "Plugin LLM completion model override allowlist requires a resolvable provider/model target.", + ); + } + if (!policy.allowedModels.has(params.resolvedModelRef)) { + const owner = policyOwnerPluginId ? ` for plugin "${policyOwnerPluginId}"` : ""; + throw new Error( + `Plugin LLM completion model override "${params.resolvedModelRef}" is not allowlisted${owner}.`, + ); + } +} + +/** + * Create the host-owned generic LLM completion runtime for trusted plugin callers. + */ +export function createRuntimeLlm(options: CreateRuntimeLlmOptions = {}): PluginRuntimeCore["llm"] { + const logger = options.logger ?? toRuntimeLogger(defaultLogger); + return { + complete: async (params: LlmCompleteParams): Promise => { + const caller = resolveTrustedCaller(options.authority); + if (options.authority?.allowComplete === false) { + const reason = options.authority.denyReason ?? "capability denied"; + logger.warn("plugin llm completion denied", { + caller, + purpose: params.purpose, + reason, + }); + throw new Error(`Plugin LLM completion denied: ${reason}`); + } + + const [ + { + prepareSimpleCompletionModelForAgent, + completeWithPreparedSimpleCompletionModel, + resolveSimpleCompletionSelectionForAgent, + }, + cfg, + ] = await Promise.all([ + import("../../agents/simple-completion-runtime.js"), + Promise.resolve(resolveRuntimeConfig(options)), + ]); + const pluginPolicyId = resolvePluginPolicyId(options.authority, caller); + const pluginPolicy = resolvePluginLlmOverridePolicy(cfg, pluginPolicyId); + const authorityPolicy = resolveAuthorityModelPolicy(options.authority); + const agentId = await resolveAgentId({ + request: params, + cfg, + authority: options.authority, + allowAgentIdOverride: + options.authority?.allowAgentIdOverride === false + ? false + : authorityPolicy?.allowAgentIdOverride === true || + pluginPolicy?.allowAgentIdOverride === true, + }); + const requestedModel = normalizeOptionalString(params.model); + if (requestedModel) { + const selection = resolveSimpleCompletionSelectionForAgent({ + cfg, + agentId, + modelRef: requestedModel, + }); + const normalizedSelection = selection + ? normalizeModelRef(selection.provider, selection.modelId) + : null; + const resolvedModelRef = normalizedSelection + ? `${normalizedSelection.provider}/${normalizedSelection.model}` + : null; + assertAllowedModelOverride({ + resolvedModelRef, + pluginPolicyId, + authorityPolicy, + pluginPolicy, + }); + } + + const prepared = await prepareSimpleCompletionModelForAgent({ + cfg, + agentId, + modelRef: params.model, + allowMissingApiKeyModes: ["aws-sdk"], + }); + + if ("error" in prepared) { + throw new Error(`Plugin LLM completion failed: ${prepared.error}`); + } + + const context = { + systemPrompt: buildSystemPrompt(params), + messages: buildMessages({ + request: params, + provider: prepared.model.provider, + model: prepared.model.id, + api: prepared.model.api, + }), + }; + + const result = await completeWithPreparedSimpleCompletionModel({ + model: prepared.model, + auth: prepared.auth, + cfg, + context, + options: { + maxTokens: finiteOption(params.maxTokens), + temperature: finiteOption(params.temperature), + signal: params.signal, + }, + }); + + const text = result.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join(""); + const normalizedUsage = normalizeUsage(result.usage as UsageLike | undefined); + const usage = buildUsage({ + rawUsage: result.usage, + normalized: normalizedUsage, + cfg, + provider: prepared.selection.provider, + model: prepared.selection.modelId, + }); + + logger.info("plugin llm completion", { + caller, + purpose: params.purpose, + sessionKey: options.authority?.sessionKey, + agentId, + provider: prepared.selection.provider, + model: prepared.selection.modelId, + usage, + }); + + return { + text, + provider: prepared.selection.provider, + model: prepared.selection.modelId, + agentId, + usage, + audit: { + caller, + ...(params.purpose ? { purpose: params.purpose } : {}), + ...(options.authority?.sessionKey ? { sessionKey: options.authority.sessionKey } : {}), + }, + }; + }, + }; +} diff --git a/src/plugins/runtime/types-core.ts b/src/plugins/runtime/types-core.ts index 87c9969c01c..cc2d6e4260f 100644 --- a/src/plugins/runtime/types-core.ts +++ b/src/plugins/runtime/types-core.ts @@ -88,6 +88,53 @@ export type RunHeartbeatOnceOptions = { heartbeat?: { target?: string }; }; +export type LlmCompleteMessage = { + role: "system" | "user" | "assistant"; + content: string; +}; + +export type LlmCompleteCaller = { + kind: "plugin" | "context-engine" | "host" | "unknown"; + id?: string; + name?: string; +}; + +export type LlmCompleteUsage = { + inputTokens?: number; + outputTokens?: number; + cacheReadTokens?: number; + cacheWriteTokens?: number; + totalTokens?: number; + costUsd?: number; +}; + +export type LlmCompleteParams = { + messages: LlmCompleteMessage[]; + /** Model ref (e.g. "anthropic/claude-sonnet-4-6"); defaults to the target agent's configured model. */ + model?: string; + maxTokens?: number; + temperature?: number; + systemPrompt?: string; + signal?: AbortSignal; + /** Human-readable reason for audit/debug output. */ + purpose?: string; + /** Agent whose model/credentials to use. Session-bound capabilities may disallow overrides. */ + agentId?: string; +}; + +export type LlmCompleteResult = { + text: string; + provider: string; + model: string; + agentId: string; + usage: LlmCompleteUsage; + audit: { + caller: LlmCompleteCaller; + purpose?: string; + sessionKey?: string; + }; +}; + type RuntimeRunEmbeddedPiAgent = ( params: import("../../agents/pi-embedded-runner/run/params.js").RunEmbeddedPiAgentParams, ) => Promise; @@ -263,6 +310,9 @@ export type PluginRuntimeCore = { }; /** @deprecated Use runtime.tasks.flows for DTO-based TaskFlow access. */ taskFlow: import("./runtime-taskflow.types.js").PluginRuntimeTaskFlow; + llm: { + complete: (params: LlmCompleteParams) => Promise; + }; modelAuth: { /** Resolve auth for a model. Only provider/model, optional cfg, and workspaceDir are used. */ getApiKeyForModel: (params: {