Skip to content

Commit 9e1e597

Browse files
authored
feat(plugin-sdk): add LLM completion API to plugin (#64294)
1 parent e259751 commit 9e1e597

35 files changed

Lines changed: 1637 additions & 26 deletions

CHANGELOG.md

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,6 @@ Docs: https://docs.openclaw.ai
44

55
## Unreleased
66

7-
### Highlights
8-
9-
- 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.
10-
117
### Changes
128

139
- 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
159155
- 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.
160156
- 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.
161157
- 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.
162-
- 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.
158+
- 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.
163159

164160
### Breaking
165161

@@ -263,11 +259,6 @@ Docs: https://docs.openclaw.ai
263259
- 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.
264260
- 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.
265261
- 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.
266-
- Channels/iMessage: probe all persistable echo-cache scope shapes (`chat_id:N`, `chat_guid:<guid>`, `chat_identifier:<id>`, `imessage:<handle>`) 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.
267-
- 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.
268-
- 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.
269-
- 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.
270-
- 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.
271262
- 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`.
272263
- Providers/xAI: clamp the bundled xAI thinking profile to `off` so live Gateway runs cannot send unsupported reasoning levels to native Grok Responses models.
273264
- 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.
Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
0a77e8265b3bf5d75e06c2e5aad7f0b7c60667de2ec57c9676e2b18305b0cc08 config-baseline.json
2-
b2ed92dd6a269d54f263728a2a761d8f6e60f849ec0562dfad17c959bfe90dfa config-baseline.core.json
1+
885a734aa93cf04f6c14f8d83c1e96a66a5b96705327ea2de7b2aa7314238976 config-baseline.json
2+
074eb9a1480ff40836d98090ccb9be3465345ac4b46e0d273b7995504bbb8008 config-baseline.core.json
33
ed15b24c1ccf0234e6b3435149a6f1c1e709579d1259f1d09402688799b149bd config-baseline.channel.json
4-
dfc16c21bdd6d727c920de871bf7fe86b771c80df86335c6376b436c0c4898ee config-baseline.plugin.json
4+
c4e8d8898eebc4d40f35b167c987870e426e6c82121696dc055ff929f6a24046 config-baseline.plugin.json
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
887d2fee5f77f1de984bfb6ec0f001c0484c0367dbc8b5f42b62027df352c2e1 plugin-sdk-api-baseline.json
2-
8e2b4e64a801b47c4d45d5d4a2073180abcc1ecf7e677fae035799c6a68f7c82 plugin-sdk-api-baseline.jsonl
1+
fecac0023b0a8de6334740483ef03500c72f3235e5b636e089bf581b00e8734a plugin-sdk-api-baseline.json
2+
b427b2c8bddefb6c0ab4f411065adeec230d1e126a792ed30e6d0a45053dd4e3 plugin-sdk-api-baseline.jsonl

docs/gateway/configuration-reference.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,9 @@ See [MCP](/cli/mcp#openclaw-as-an-mcp-client-registry) and
198198
- `plugins.entries.<id>.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`.
199199
- `plugins.entries.<id>.subagent.allowModelOverride`: explicitly trust this plugin to request per-run `provider` and `model` overrides for background subagent runs.
200200
- `plugins.entries.<id>.subagent.allowedModels`: optional allowlist of canonical `provider/model` targets for trusted subagent overrides. Use `"*"` only when you intentionally want to allow any model.
201+
- `plugins.entries.<id>.llm.allowModelOverride`: explicitly trust this plugin to request model overrides for `api.runtime.llm.complete`.
202+
- `plugins.entries.<id>.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.
203+
- `plugins.entries.<id>.llm.allowAgentIdOverride`: explicitly trust this plugin to run `api.runtime.llm.complete` against a non-default agent id.
201204
- `plugins.entries.<id>.config`: plugin-defined config object (validated by native OpenClaw plugin schema when available).
202205
- Channel plugin account/runtime settings live under `channels.<id>` and should be described by the owning plugin's manifest `channelConfigs` metadata, not by a central OpenClaw option registry.
203206

docs/plugins/sdk-runtime.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,32 @@ Provider and channel execution paths must use the active runtime config snapshot
133133
const provider = api.runtime.agent.defaults.provider; // e.g. "anthropic"
134134
```
135135

136+
</Accordion>
137+
138+
<Accordion title="api.runtime.llm">
139+
Run a host-owned text completion without importing provider internals or
140+
duplicating OpenClaw model/auth/base URL preparation.
141+
142+
```typescript
143+
const result = await api.runtime.llm.complete({
144+
messages: [{ role: "user", content: "Summarize this transcript." }],
145+
purpose: "my-plugin.summary",
146+
maxTokens: 512,
147+
temperature: 0.2,
148+
});
149+
```
150+
151+
The helper uses the same simple-completion preparation path as OpenClaw's
152+
built-in runtime and the host-owned runtime config snapshot. Context engines
153+
receive a session-bound `llm.complete` capability, so model calls use the
154+
active session's agent and do not silently fall back to the default agent. The
155+
result includes provider/model/agent attribution plus normalized token,
156+
cache, and estimated cost usage when available.
157+
158+
<Warning>
159+
Model overrides require operator opt-in via `plugins.entries.<id>.llm.allowModelOverride: true` in config. Use `plugins.entries.<id>.llm.allowedModels` to restrict trusted plugins to specific canonical `provider/model` targets. Cross-agent completions require `plugins.entries.<id>.llm.allowAgentIdOverride: true`.
160+
</Warning>
161+
136162
</Accordion>
137163
<Accordion title="api.runtime.subagent">
138164
Launch and manage background subagent runs.

src/agents/pi-embedded-runner/compact.hooks.harness.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,10 @@ export const resolveMemorySearchConfigMock = vi.fn(() => ({
7272
},
7373
}));
7474
export const resolveSessionAgentIdMock = vi.fn(() => "main");
75+
export const resolveSessionAgentIdsMock = vi.fn(() => ({
76+
defaultAgentId: "main",
77+
sessionAgentId: "main",
78+
}));
7579
export const estimateTokensMock = vi.fn((_message?: unknown) => 10);
7680
function createDefaultSessionMessages(): unknown[] {
7781
return [
@@ -168,6 +172,8 @@ export function resetCompactSessionStateMocks(): void {
168172
});
169173
resolveSessionAgentIdMock.mockReset();
170174
resolveSessionAgentIdMock.mockReturnValue("main");
175+
resolveSessionAgentIdsMock.mockReset();
176+
resolveSessionAgentIdsMock.mockReturnValue({ defaultAgentId: "main", sessionAgentId: "main" });
171177
estimateTokensMock.mockReset();
172178
estimateTokensMock.mockReturnValue(10);
173179
sessionMessages.splice(0, sessionMessages.length, ...createDefaultSessionMessages());
@@ -384,6 +390,7 @@ export async function loadCompactHooksHarness(): Promise<{
384390

385391
vi.doMock("../../context-engine/registry.js", () => ({
386392
resolveContextEngine: resolveContextEngineMock,
393+
resolveContextEngineOwnerPluginId: vi.fn(() => "lossless-claw"),
387394
}));
388395

389396
vi.doMock("../../process/command-queue.js", () => ({
@@ -551,7 +558,7 @@ export async function loadCompactHooksHarness(): Promise<{
551558
resolveDefaultAgentId: vi.fn(() => "main"),
552559
resolveRunModelFallbacksOverride: vi.fn(() => undefined),
553560
resolveSessionAgentId: resolveSessionAgentIdMock,
554-
resolveSessionAgentIds: vi.fn(() => ({ defaultAgentId: "main", sessionAgentId: "main" })),
561+
resolveSessionAgentIds: resolveSessionAgentIdsMock,
555562
}));
556563

557564
vi.doMock("../auth-profiles/source-check.js", () => ({

src/agents/pi-embedded-runner/compact.hooks.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
resolveModelMock,
1818
resolveSandboxContextMock,
1919
resolveSessionAgentIdMock,
20+
resolveSessionAgentIdsMock,
2021
rotateTranscriptAfterCompactionMock,
2122
resetCompactHooksHarnessMocks,
2223
resetCompactSessionStateMocks,
@@ -1049,6 +1050,57 @@ describe("compactEmbeddedPiSession hooks (ownsCompaction engine)", () => {
10491050
mockResolvedModel();
10501051
});
10511052

1053+
it("binds context-engine compaction runtime LLM to the session agent", async () => {
1054+
resolveSessionAgentIdsMock.mockReturnValueOnce({
1055+
defaultAgentId: "main",
1056+
sessionAgentId: "lossless-agent",
1057+
});
1058+
1059+
await compactEmbeddedPiSession(
1060+
wrappedCompactionArgs({
1061+
config: {
1062+
agents: {
1063+
defaults: {
1064+
model: "openai/gpt-5.5",
1065+
},
1066+
},
1067+
},
1068+
sessionKey: "legacy-topic-47",
1069+
}),
1070+
);
1071+
1072+
expect(contextEngineCompactMock).toHaveBeenCalledWith(
1073+
expect.objectContaining({
1074+
runtimeContext: expect.objectContaining({
1075+
llm: expect.objectContaining({ complete: expect.any(Function) }),
1076+
}),
1077+
}),
1078+
);
1079+
const contextEngineCompactCalls = contextEngineCompactMock.mock.calls as unknown as Array<
1080+
[
1081+
{
1082+
runtimeContext?: {
1083+
llm?: {
1084+
complete?: (params: {
1085+
messages: Array<{ role: "user"; content: string }>;
1086+
agentId?: string;
1087+
}) => Promise<unknown>;
1088+
};
1089+
};
1090+
},
1091+
]
1092+
>;
1093+
const runtimeContext = contextEngineCompactCalls[0]?.[0]?.runtimeContext;
1094+
expect(runtimeContext).toBeDefined();
1095+
1096+
await expect(
1097+
runtimeContext?.llm?.complete?.({
1098+
messages: [{ role: "user", content: "summarize" }],
1099+
agentId: "other-agent",
1100+
}),
1101+
).rejects.toThrow("cannot override the active session agent");
1102+
});
1103+
10521104
it("fires before_compaction with sentinel -1 and after_compaction on success", async () => {
10531105
hookRunner.hasHooks.mockReturnValue(true);
10541106

src/agents/pi-embedded-runner/compact.queued.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { ensureContextEnginesInitialized } from "../../context-engine/init.js";
2-
import { resolveContextEngine } from "../../context-engine/registry.js";
2+
import {
3+
resolveContextEngine,
4+
resolveContextEngineOwnerPluginId,
5+
} from "../../context-engine/registry.js";
36
import type { ContextEngineRuntimeContext } from "../../context-engine/types.js";
47
import {
58
captureCompactionCheckpointSnapshotAsync,
@@ -29,6 +32,7 @@ import {
2932
rotateTranscriptFileAfterCompaction,
3033
shouldRotateCompactionTranscript,
3134
} from "./compaction-successor-transcript.js";
35+
import { resolveContextEngineCapabilities } from "./context-engine-capabilities.js";
3236
import { runContextEngineMaintenance } from "./context-engine-maintenance.js";
3337
import { resolveGlobalLane, resolveSessionLane } from "./lanes.js";
3438
import { log } from "./logger.js";
@@ -92,6 +96,7 @@ export async function compactEmbeddedPiSession(
9296
params,
9397
agentDir,
9498
contextTokenBudget,
99+
contextEnginePluginId: resolveContextEngineOwnerPluginId(contextEngine),
95100
});
96101
const harnessResult = await maybeCompactAgentHarnessSession({
97102
...params,
@@ -302,8 +307,13 @@ export async function compactEmbeddedPiSession(
302307
function buildCompactionContextEngineRuntimeContext(params: {
303308
params: CompactEmbeddedPiSessionParams;
304309
agentDir: string;
310+
contextEnginePluginId?: string;
305311
contextTokenBudget?: number;
306312
}): ContextEngineRuntimeContext {
313+
const { sessionAgentId } = resolveSessionAgentIds({
314+
sessionKey: params.params.sessionKey,
315+
config: params.params.config,
316+
});
307317
return {
308318
...params.params,
309319
...buildEmbeddedCompactionRuntimeContext({
@@ -331,6 +341,13 @@ function buildCompactionContextEngineRuntimeContext(params: {
331341
sourceReplyDeliveryMode: params.params.sourceReplyDeliveryMode,
332342
ownerNumbers: params.params.ownerNumbers,
333343
}),
344+
...resolveContextEngineCapabilities({
345+
config: params.params.config,
346+
sessionKey: params.params.sessionKey,
347+
agentId: sessionAgentId,
348+
contextEnginePluginId: params.contextEnginePluginId,
349+
purpose: "context-engine.compaction",
350+
}),
334351
tokenBudget: params.contextTokenBudget,
335352
currentTokenCount: params.params.currentTokenCount,
336353
};
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import type { OpenClawConfig } from "../../config/types.openclaw.js";
2+
import type { ContextEngineRuntimeContext } from "../../context-engine/types.js";
3+
import {
4+
parseAgentSessionKey,
5+
normalizeAgentId,
6+
normalizeMainKey,
7+
} from "../../routing/session-key.js";
8+
import {
9+
normalizeLowercaseStringOrEmpty,
10+
normalizeOptionalString,
11+
} from "../../shared/string-coerce.js";
12+
import { resolveDefaultAgentId } from "../agent-scope.js";
13+
14+
export type ResolveContextEngineCapabilitiesParams = {
15+
config?: OpenClawConfig;
16+
sessionKey?: string;
17+
agentId?: string;
18+
contextEnginePluginId?: string;
19+
purpose: string;
20+
};
21+
22+
function resolveBoundAgentId(params: {
23+
config?: OpenClawConfig;
24+
sessionKey?: string;
25+
agentId?: string;
26+
}): string | undefined {
27+
// Explicit agent ids are host-resolved at call sites that already know the
28+
// active session agent, such as embedded attempts.
29+
const explicitAgentId = normalizeOptionalString(params.agentId);
30+
if (explicitAgentId) {
31+
return normalizeAgentId(explicitAgentId);
32+
}
33+
// Canonical agent session keys carry the binding directly.
34+
const normalizedSessionKey = normalizeOptionalString(params.sessionKey);
35+
if (!normalizedSessionKey) {
36+
return undefined;
37+
}
38+
const parsed = parseAgentSessionKey(normalizedSessionKey);
39+
if (parsed?.agentId) {
40+
return normalizeAgentId(parsed.agentId);
41+
}
42+
// Legacy main-session aliases are still active sessions; arbitrary legacy
43+
// aliases stay unbound and fail closed in runtime LLM authorization.
44+
const loweredSessionKey = normalizeLowercaseStringOrEmpty(normalizedSessionKey);
45+
const mainKey = normalizeMainKey(params.config?.session?.mainKey);
46+
if (loweredSessionKey === "main" || loweredSessionKey === mainKey) {
47+
return resolveDefaultAgentId(params.config ?? {});
48+
}
49+
return undefined;
50+
}
51+
52+
/**
53+
* Build host-owned capabilities that are bound to one context-engine runtime call.
54+
*/
55+
export function resolveContextEngineCapabilities(
56+
params: ResolveContextEngineCapabilitiesParams,
57+
): Pick<ContextEngineRuntimeContext, "llm"> {
58+
const sessionKey = normalizeOptionalString(params.sessionKey);
59+
const agentId = resolveBoundAgentId({
60+
config: params.config,
61+
sessionKey,
62+
agentId: params.agentId,
63+
});
64+
const contextEnginePluginId = normalizeOptionalString(params.contextEnginePluginId);
65+
return {
66+
llm: {
67+
complete: async (request) => {
68+
const { createRuntimeLlm } = await import("../../plugins/runtime/runtime-llm.runtime.js");
69+
return await createRuntimeLlm({
70+
getConfig: () => params.config,
71+
authority: {
72+
caller: { kind: "context-engine", id: params.purpose },
73+
requiresBoundAgent: true,
74+
...(sessionKey ? { sessionKey } : {}),
75+
...(agentId ? { agentId } : {}),
76+
...(contextEnginePluginId ? { pluginIdForPolicy: contextEnginePluginId } : {}),
77+
allowAgentIdOverride: false,
78+
allowModelOverride: false,
79+
allowComplete: true,
80+
},
81+
}).complete(request);
82+
},
83+
},
84+
};
85+
}

src/agents/pi-embedded-runner/context-engine-maintenance.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,10 @@ async function waitForAssertion(
6363
}
6464
}
6565

66+
vi.mock("./context-engine-capabilities.js", () => ({
67+
resolveContextEngineCapabilities: () => ({ llm: undefined }),
68+
}));
69+
6670
vi.mock("./transcript-rewrite.js", () => ({
6771
rewriteTranscriptEntriesInSessionManager: (params: unknown) =>
6872
rewriteTranscriptEntriesInSessionManagerMock(params),

0 commit comments

Comments
 (0)