Plugins: remove public extension-api surface (#48462)

* Plugins: remove public extension-api surface

* Plugins: fix loader setup routing follow-ups

* CI: ignore non-extension helper dirs in extension-fast

* Docs: note extension-api removal as breaking
This commit is contained in:
Tak Hoffman
2026-03-16 15:51:08 -05:00
committed by GitHub
parent 412811ec19
commit 2de28379dd
26 changed files with 179 additions and 306 deletions

View File

@@ -1,17 +1,11 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("openclaw/extension-api", () => {
return {
runEmbeddedPiAgent: vi.fn(async () => ({
meta: { startedAt: Date.now() },
payloads: [{ text: "{}" }],
})),
};
});
import { runEmbeddedPiAgent } from "openclaw/extension-api";
import { createLlmTaskTool } from "./llm-task-tool.js";
const runEmbeddedPiAgent = vi.fn(async () => ({
meta: { startedAt: Date.now() },
payloads: [{ text: "{}" }],
}));
// oxlint-disable-next-line typescript/no-explicit-any
function fakeApi(overrides: any = {}) {
return {
@@ -22,7 +16,12 @@ function fakeApi(overrides: any = {}) {
agents: { defaults: { workspace: "/tmp", model: { primary: "openai-codex/gpt-5.2" } } },
},
pluginConfig: {},
runtime: { version: "test" },
runtime: {
version: "test",
agent: {
runEmbeddedPiAgent,
},
},
logger: { debug() {}, info() {}, warn() {}, error() {} },
registerTool() {},
...overrides,

View File

@@ -2,7 +2,6 @@ import fs from "node:fs/promises";
import path from "node:path";
import { Type } from "@sinclair/typebox";
import Ajv from "ajv";
import { runEmbeddedPiAgent } from "openclaw/extension-api";
import {
formatThinkingLevels,
formatXHighModelHint,
@@ -179,7 +178,7 @@ export function createLlmTaskTool(api: OpenClawPluginApi) {
const sessionId = `llm-task-${Date.now()}`;
const sessionFile = path.join(tmpDir, "session.json");
const result = await runEmbeddedPiAgent({
const result = await api.runtime.agent.runEmbeddedPiAgent({
sessionId,
sessionFile,
workspaceDir: api.config?.agents?.defaults?.workspace ?? process.cwd(),

View File

@@ -1,6 +1,7 @@
import type { PluginRuntime } from "openclaw/plugin-sdk/test-utils";
import { removeAckReactionAfterReply, shouldAckReaction } from "openclaw/plugin-sdk/test-utils";
import { vi } from "vitest";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../../src/agents/defaults.js";
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends (...args: never[]) => unknown
@@ -39,6 +40,50 @@ export function createPluginRuntimeMock(overrides: DeepPartial<PluginRuntime> =
loadConfig: vi.fn(() => ({})) as unknown as PluginRuntime["config"]["loadConfig"],
writeConfigFile: vi.fn() as unknown as PluginRuntime["config"]["writeConfigFile"],
},
agent: {
defaults: {
model: DEFAULT_MODEL,
provider: DEFAULT_PROVIDER,
},
resolveAgentDir: vi.fn(
() => "/tmp/agent",
) as unknown as PluginRuntime["agent"]["resolveAgentDir"],
resolveAgentWorkspaceDir: vi.fn(
() => "/tmp/workspace",
) as unknown as PluginRuntime["agent"]["resolveAgentWorkspaceDir"],
resolveAgentIdentity: vi.fn(() => ({
name: "test-agent",
})) as unknown as PluginRuntime["agent"]["resolveAgentIdentity"],
resolveThinkingDefault: vi.fn(
() => "off",
) as unknown as PluginRuntime["agent"]["resolveThinkingDefault"],
runEmbeddedPiAgent: vi.fn().mockResolvedValue({
payloads: [],
meta: {},
}) as unknown as PluginRuntime["agent"]["runEmbeddedPiAgent"],
resolveAgentTimeoutMs: vi.fn(
() => 30_000,
) as unknown as PluginRuntime["agent"]["resolveAgentTimeoutMs"],
ensureAgentWorkspace: vi
.fn()
.mockResolvedValue(undefined) as unknown as PluginRuntime["agent"]["ensureAgentWorkspace"],
session: {
resolveStorePath: vi.fn(
() => "/tmp/agent-sessions.json",
) as unknown as PluginRuntime["agent"]["session"]["resolveStorePath"],
loadSessionStore: vi.fn(
() => ({}),
) as unknown as PluginRuntime["agent"]["session"]["loadSessionStore"],
saveSessionStore: vi
.fn()
.mockResolvedValue(
undefined,
) as unknown as PluginRuntime["agent"]["session"]["saveSessionStore"],
resolveSessionFilePath: vi.fn(
(sessionId: string) => `/tmp/${sessionId}.json`,
) as unknown as PluginRuntime["agent"]["session"]["resolveSessionFilePath"],
},
},
system: {
enqueueSystemEvent: vi.fn() as unknown as PluginRuntime["system"]["enqueueSystemEvent"],
requestHeartbeatNow: vi.fn() as unknown as PluginRuntime["system"]["requestHeartbeatNow"],

View File

@@ -180,6 +180,7 @@ const voiceCallPlugin = {
runtimePromise = createVoiceCallRuntime({
config,
coreConfig: api.config as CoreConfig,
agentRuntime: api.runtime.agent,
ttsRuntime: api.runtime.tts,
logger: api.logger,
});

View File

@@ -1,6 +1,4 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/voice-call";
import type { VoiceCallTtsConfig } from "./config.js";
export type CoreConfig = {
@@ -13,147 +11,4 @@ export type CoreConfig = {
[key: string]: unknown;
};
type CoreAgentDeps = {
resolveAgentDir: (cfg: CoreConfig, agentId: string) => string;
resolveAgentWorkspaceDir: (cfg: CoreConfig, agentId: string) => string;
resolveAgentIdentity: (
cfg: CoreConfig,
agentId: string,
) => { name?: string | null } | null | undefined;
resolveThinkingDefault: (params: {
cfg: CoreConfig;
provider?: string;
model?: string;
}) => string;
runEmbeddedPiAgent: (params: {
sessionId: string;
sessionKey?: string;
messageProvider?: string;
sessionFile: string;
workspaceDir: string;
config?: CoreConfig;
prompt: string;
provider?: string;
model?: string;
thinkLevel?: string;
verboseLevel?: string;
timeoutMs: number;
runId: string;
lane?: string;
extraSystemPrompt?: string;
agentDir?: string;
}) => Promise<{
payloads?: Array<{ text?: string; isError?: boolean }>;
meta?: { aborted?: boolean };
}>;
resolveAgentTimeoutMs: (opts: { cfg: CoreConfig }) => number;
ensureAgentWorkspace: (params?: { dir: string }) => Promise<void>;
resolveStorePath: (store?: string, opts?: { agentId?: string }) => string;
loadSessionStore: (storePath: string) => Record<string, unknown>;
saveSessionStore: (storePath: string, store: Record<string, unknown>) => Promise<void>;
resolveSessionFilePath: (
sessionId: string,
entry: unknown,
opts?: { agentId?: string },
) => string;
DEFAULT_MODEL: string;
DEFAULT_PROVIDER: string;
};
let coreRootCache: string | null = null;
let coreDepsPromise: Promise<CoreAgentDeps> | null = null;
function findPackageRoot(startDir: string, name: string): string | null {
let dir = startDir;
for (;;) {
const pkgPath = path.join(dir, "package.json");
try {
if (fs.existsSync(pkgPath)) {
const raw = fs.readFileSync(pkgPath, "utf8");
const pkg = JSON.parse(raw) as { name?: string };
if (pkg.name === name) {
return dir;
}
}
} catch {
// ignore parse errors and keep walking
}
const parent = path.dirname(dir);
if (parent === dir) {
return null;
}
dir = parent;
}
}
function resolveOpenClawRoot(): string {
if (coreRootCache) {
return coreRootCache;
}
const override = process.env.OPENCLAW_ROOT?.trim();
if (override) {
coreRootCache = override;
return override;
}
const candidates = new Set<string>();
if (process.argv[1]) {
candidates.add(path.dirname(process.argv[1]));
}
candidates.add(process.cwd());
try {
const urlPath = fileURLToPath(import.meta.url);
candidates.add(path.dirname(urlPath));
} catch {
// ignore
}
for (const start of candidates) {
for (const name of ["openclaw"]) {
const found = findPackageRoot(start, name);
if (found) {
coreRootCache = found;
return found;
}
}
}
throw new Error("Unable to resolve core root. Set OPENCLAW_ROOT to the package root.");
}
async function importCoreExtensionAPI(): Promise<{
resolveAgentDir: CoreAgentDeps["resolveAgentDir"];
resolveAgentWorkspaceDir: CoreAgentDeps["resolveAgentWorkspaceDir"];
DEFAULT_MODEL: string;
DEFAULT_PROVIDER: string;
resolveAgentIdentity: CoreAgentDeps["resolveAgentIdentity"];
resolveThinkingDefault: CoreAgentDeps["resolveThinkingDefault"];
runEmbeddedPiAgent: CoreAgentDeps["runEmbeddedPiAgent"];
resolveAgentTimeoutMs: CoreAgentDeps["resolveAgentTimeoutMs"];
ensureAgentWorkspace: CoreAgentDeps["ensureAgentWorkspace"];
resolveStorePath: CoreAgentDeps["resolveStorePath"];
loadSessionStore: CoreAgentDeps["loadSessionStore"];
saveSessionStore: CoreAgentDeps["saveSessionStore"];
resolveSessionFilePath: CoreAgentDeps["resolveSessionFilePath"];
}> {
// Do not import any other module. You can't touch this or you will be fired.
const distPath = path.join(resolveOpenClawRoot(), "dist", "extensionAPI.js");
if (!fs.existsSync(distPath)) {
throw new Error(
`Missing core module at ${distPath}. Run \`pnpm build\` or install the official package.`,
);
}
return await import(pathToFileURL(distPath).href);
}
export async function loadCoreAgentDeps(): Promise<CoreAgentDeps> {
if (coreDepsPromise) {
return coreDepsPromise;
}
coreDepsPromise = (async () => {
return await importCoreExtensionAPI();
})();
return coreDepsPromise;
}
export type CoreAgentDeps = OpenClawPluginApi["runtime"]["agent"];

View File

@@ -4,14 +4,17 @@
*/
import crypto from "node:crypto";
import type { SessionEntry } from "openclaw/plugin-sdk/voice-call";
import type { VoiceCallConfig } from "./config.js";
import { loadCoreAgentDeps, type CoreConfig } from "./core-bridge.js";
import type { CoreAgentDeps, CoreConfig } from "./core-bridge.js";
export type VoiceResponseParams = {
/** Voice call config */
voiceConfig: VoiceCallConfig;
/** Core OpenClaw config */
coreConfig: CoreConfig;
/** Injected host agent runtime */
agentRuntime: CoreAgentDeps;
/** Call ID for session tracking */
callId: string;
/** Caller's phone number */
@@ -27,11 +30,6 @@ export type VoiceResponseResult = {
error?: string;
};
type SessionEntry = {
sessionId: string;
updatedAt: number;
};
/**
* Generate a voice response using the embedded Pi agent with full tool support.
* Uses the same agent infrastructure as messaging for consistent behavior.
@@ -39,21 +37,11 @@ type SessionEntry = {
export async function generateVoiceResponse(
params: VoiceResponseParams,
): Promise<VoiceResponseResult> {
const { voiceConfig, callId, from, transcript, userMessage, coreConfig } = params;
const { voiceConfig, callId, from, transcript, userMessage, coreConfig, agentRuntime } = params;
if (!coreConfig) {
return { text: null, error: "Core config unavailable for voice response" };
}
let deps: Awaited<ReturnType<typeof loadCoreAgentDeps>>;
try {
deps = await loadCoreAgentDeps();
} catch (err) {
return {
text: null,
error: err instanceof Error ? err.message : "Unable to load core agent dependencies",
};
}
const cfg = coreConfig;
// Build voice-specific session key based on phone number
@@ -62,15 +50,15 @@ export async function generateVoiceResponse(
const agentId = "main";
// Resolve paths
const storePath = deps.resolveStorePath(cfg.session?.store, { agentId });
const agentDir = deps.resolveAgentDir(cfg, agentId);
const workspaceDir = deps.resolveAgentWorkspaceDir(cfg, agentId);
const storePath = agentRuntime.session.resolveStorePath(cfg.session?.store, { agentId });
const agentDir = agentRuntime.resolveAgentDir(cfg, agentId);
const workspaceDir = agentRuntime.resolveAgentWorkspaceDir(cfg, agentId);
// Ensure workspace exists
await deps.ensureAgentWorkspace({ dir: workspaceDir });
await agentRuntime.ensureAgentWorkspace({ dir: workspaceDir });
// Load or create session entry
const sessionStore = deps.loadSessionStore(storePath);
const sessionStore = agentRuntime.session.loadSessionStore(storePath);
const now = Date.now();
let sessionEntry = sessionStore[sessionKey] as SessionEntry | undefined;
@@ -80,25 +68,27 @@ export async function generateVoiceResponse(
updatedAt: now,
};
sessionStore[sessionKey] = sessionEntry;
await deps.saveSessionStore(storePath, sessionStore);
await agentRuntime.session.saveSessionStore(storePath, sessionStore);
}
const sessionId = sessionEntry.sessionId;
const sessionFile = deps.resolveSessionFilePath(sessionId, sessionEntry, {
const sessionFile = agentRuntime.session.resolveSessionFilePath(sessionId, sessionEntry, {
agentId,
});
// Resolve model from config
const modelRef = voiceConfig.responseModel || `${deps.DEFAULT_PROVIDER}/${deps.DEFAULT_MODEL}`;
const modelRef =
voiceConfig.responseModel || `${agentRuntime.defaults.provider}/${agentRuntime.defaults.model}`;
const slashIndex = modelRef.indexOf("/");
const provider = slashIndex === -1 ? deps.DEFAULT_PROVIDER : modelRef.slice(0, slashIndex);
const provider =
slashIndex === -1 ? agentRuntime.defaults.provider : modelRef.slice(0, slashIndex);
const model = slashIndex === -1 ? modelRef : modelRef.slice(slashIndex + 1);
// Resolve thinking level
const thinkLevel = deps.resolveThinkingDefault({ cfg, provider, model });
const thinkLevel = agentRuntime.resolveThinkingDefault({ cfg, provider, model });
// Resolve agent identity for personalized prompt
const identity = deps.resolveAgentIdentity(cfg, agentId);
const identity = agentRuntime.resolveAgentIdentity(cfg, agentId);
const agentName = identity?.name?.trim() || "assistant";
// Build system prompt with conversation history
@@ -115,11 +105,11 @@ export async function generateVoiceResponse(
}
// Resolve timeout
const timeoutMs = voiceConfig.responseTimeoutMs ?? deps.resolveAgentTimeoutMs({ cfg });
const timeoutMs = voiceConfig.responseTimeoutMs ?? agentRuntime.resolveAgentTimeoutMs({ cfg });
const runId = `voice:${callId}:${Date.now()}`;
try {
const result = await deps.runEmbeddedPiAgent({
const result = await agentRuntime.runEmbeddedPiAgent({
sessionId,
sessionKey,
messageProvider: "voice",

View File

@@ -76,6 +76,7 @@ describe("createVoiceCallRuntime lifecycle", () => {
createVoiceCallRuntime({
config: createBaseConfig(),
coreConfig: {},
agentRuntime: {} as never,
}),
).rejects.toThrow("init failed");
@@ -95,6 +96,7 @@ describe("createVoiceCallRuntime lifecycle", () => {
const runtime = await createVoiceCallRuntime({
config: createBaseConfig(),
coreConfig: {} as CoreConfig,
agentRuntime: {} as never,
});
await runtime.stop();

View File

@@ -1,6 +1,6 @@
import type { VoiceCallConfig } from "./config.js";
import { resolveVoiceCallConfig, validateProviderConfig } from "./config.js";
import type { CoreConfig } from "./core-bridge.js";
import type { CoreAgentDeps, CoreConfig } from "./core-bridge.js";
import { CallManager } from "./manager.js";
import type { VoiceCallProvider } from "./providers/base.js";
import { MockProvider } from "./providers/mock.js";
@@ -135,10 +135,11 @@ function resolveProvider(config: VoiceCallConfig): VoiceCallProvider {
export async function createVoiceCallRuntime(params: {
config: VoiceCallConfig;
coreConfig: CoreConfig;
agentRuntime: CoreAgentDeps;
ttsRuntime?: TelephonyTtsRuntime;
logger?: Logger;
}): Promise<VoiceCallRuntime> {
const { config: rawConfig, coreConfig, ttsRuntime, logger } = params;
const { config: rawConfig, coreConfig, agentRuntime, ttsRuntime, logger } = params;
const log = logger ?? {
info: console.log,
warn: console.warn,
@@ -165,7 +166,13 @@ export async function createVoiceCallRuntime(params: {
const provider = resolveProvider(config);
const manager = new CallManager(config);
const webhookServer = new VoiceCallWebhookServer(config, manager, provider, coreConfig);
const webhookServer = new VoiceCallWebhookServer(
config,
manager,
provider,
coreConfig,
agentRuntime,
);
const lifecycle = createRuntimeResourceLifecycle({ config, webhookServer });
const localUrl = await webhookServer.start();

View File

@@ -6,7 +6,7 @@ import {
requestBodyErrorToText,
} from "openclaw/plugin-sdk/voice-call";
import { normalizeVoiceCallConfig, type VoiceCallConfig } from "./config.js";
import type { CoreConfig } from "./core-bridge.js";
import type { CoreAgentDeps, CoreConfig } from "./core-bridge.js";
import type { CallManager } from "./manager.js";
import type { MediaStreamConfig } from "./media-stream.js";
import { MediaStreamHandler } from "./media-stream.js";
@@ -55,6 +55,7 @@ export class VoiceCallWebhookServer {
private manager: CallManager;
private provider: VoiceCallProvider;
private coreConfig: CoreConfig | null;
private agentRuntime: CoreAgentDeps | null;
private stopStaleCallReaper: (() => void) | null = null;
/** Media stream handler for bidirectional audio (when streaming enabled) */
@@ -65,11 +66,13 @@ export class VoiceCallWebhookServer {
manager: CallManager,
provider: VoiceCallProvider,
coreConfig?: CoreConfig,
agentRuntime?: CoreAgentDeps,
) {
this.config = normalizeVoiceCallConfig(config);
this.manager = manager;
this.provider = provider;
this.coreConfig = coreConfig ?? null;
this.agentRuntime = agentRuntime ?? null;
// Initialize media stream handler if streaming is enabled
if (this.config.streaming.enabled) {
@@ -458,6 +461,10 @@ export class VoiceCallWebhookServer {
console.warn("[voice-call] Core config missing; skipping auto-response");
return;
}
if (!this.agentRuntime) {
console.warn("[voice-call] Agent runtime missing; skipping auto-response");
return;
}
try {
const { generateVoiceResponse } = await import("./response-generator.js");
@@ -465,6 +472,7 @@ export class VoiceCallWebhookServer {
const result = await generateVoiceResponse({
voiceConfig: this.config,
coreConfig: this.coreConfig,
agentRuntime: this.agentRuntime,
callId,
from: call.from,
transcript: call.transcript,