feat(FN-2701): route OpenClaw runtime sessions through gateway client

- Add a dedicated gateway client seam and wire runtime adapter sessions through the new request path
- Remove legacy engine-guard/pi seam exports and align runtime types with gateway-facing behavior
- Fix gateway request/stream handling by preventing duplicate user turns, stabilizing tool-call callbacks, and adding a no-op session dispose hook
- Expand plugin test coverage for gateway client, adapter, and index behavior and document runtime gateway behavior in the README
This commit is contained in:
Fusion
2026-04-27 09:46:25 -07:00
committed by gsxdsm
parent 205370894e
commit b67d409480
14 changed files with 676 additions and 292 deletions

View File

@@ -1,25 +0,0 @@
/**
* Resolution stub for @fusion/engine in plugin test mode.
*
* Vitest resolves mocked module IDs before applying vi.mock factories. The
* real @fusion/engine workspace package points to dist outputs that are not
* built for plugin-local test runs, so we alias to this stub in vitest config.
*
* Any direct import of @fusion/engine in plugin tests should still fail fast.
*/
const guardError = () =>
new Error(
"Guard: @fusion/engine was imported without an explicit mock. Runtime plugin tests must mock '../pi-module.js' to prevent loading the real engine.",
);
export const createFnAgent = () => {
throw guardError();
};
export const promptWithFallback = async () => {
throw guardError();
};
export const describeModel = () => {
throw guardError();
};

View File

@@ -1,33 +0,0 @@
/**
* Verifies the engine guard mock is active.
*
* This test ensures that the setup-engine-guard.ts setup file is correctly
* loaded and that any test importing @fusion/engine without mocking pi-module
* would fail fast with a descriptive error.
*/
import { describe, it, expect } from "vitest";
describe("engine import guard", () => {
it("should have @fusion/engine mock installed (prevents real engine load)", () => {
// The guard is verified indirectly: if this test file runs at all,
// the setup-engine-guard.ts loaded successfully. The guard throws only
// when a test file actually imports @fusion/engine without mocking
// pi-module.js — and since we don't do that here, we confirm the setup
// is wired without triggering the error.
expect(true).toBe(true);
});
it("should mock pi-module seam (not load real engine)", async () => {
// Dynamically import pi-module to verify it is mocked, not the real one.
// Since this test file has no vi.mock("../pi-module.js"), it relies on
// no code path reaching pi-module at all. The guard in setup-engine-guard.ts
// would throw if the real @fusion/engine were loaded.
//
// We do NOT import pi-module here because that would trigger the guard.
// Instead we verify the setup file exists and is wired via vitest config.
const { existsSync } = await import("node:fs");
const { join } = await import("node:path");
const guardPath = join(import.meta.dirname, "setup-engine-guard.ts");
expect(existsSync(guardPath)).toBe(true);
});
});

View File

@@ -0,0 +1,208 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
createGatewaySession,
probeGateway,
promptGateway,
resolveGatewayConfig,
} from "../pi-module.js";
function createSseResponse(events: string[], init?: ResponseInit): Response {
const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
start(controller) {
for (const event of events) {
controller.enqueue(encoder.encode(event));
}
controller.close();
},
});
return new Response(stream, {
status: 200,
headers: { "content-type": "text/event-stream" },
...init,
});
}
describe("gateway client", () => {
const originalEnv = { ...process.env };
beforeEach(() => {
process.env = { ...originalEnv };
vi.unstubAllGlobals();
});
afterEach(() => {
process.env = { ...originalEnv };
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it("createGatewaySession includes a no-op dispose handler", () => {
const session = createGatewaySession({
gatewayUrl: "http://127.0.0.1:18789",
gatewayToken: "token",
agentId: "main",
systemPrompt: "system",
});
expect(typeof session.dispose).toBe("function");
expect(() => session.dispose?.()).not.toThrow();
});
it("resolves config from settings first, then env, then defaults", () => {
process.env.OPENCLAW_GATEWAY_URL = "http://env-gateway:18789";
process.env.OPENCLAW_GATEWAY_TOKEN = "env-token";
process.env.OPENCLAW_AGENT_ID = "env-agent";
expect(
resolveGatewayConfig({
gatewayUrl: "http://settings-gateway:18789",
gatewayToken: "settings-token",
agentId: "settings-agent",
}),
).toEqual({
gatewayUrl: "http://settings-gateway:18789",
gatewayToken: "settings-token",
agentId: "settings-agent",
});
expect(resolveGatewayConfig({})).toEqual({
gatewayUrl: "http://env-gateway:18789",
gatewayToken: "env-token",
agentId: "env-agent",
});
delete process.env.OPENCLAW_GATEWAY_URL;
delete process.env.OPENCLAW_GATEWAY_TOKEN;
delete process.env.OPENCLAW_AGENT_ID;
expect(resolveGatewayConfig({})).toEqual({
gatewayUrl: "http://127.0.0.1:18789",
gatewayToken: undefined,
agentId: "main",
});
});
it("probeGateway returns true for any reachable HTTP response and false on network failures", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("not found", { status: 404 })));
await expect(probeGateway("http://127.0.0.1:18789")).resolves.toBe(true);
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("ECONNREFUSED")));
await expect(probeGateway("http://127.0.0.1:18789")).resolves.toBe(false);
});
it("streams text, thinking, and tool-call events from SSE", async () => {
const onText = vi.fn();
const onThinking = vi.fn();
const onToolStart = vi.fn();
const onToolEnd = vi.fn();
const fetchMock = vi.fn().mockResolvedValue(
createSseResponse([
'data: {"choices":[{"delta":{"content":"Hello "}}]}\n\n',
'data: {"choices":[{"delta":{"reasoning_content":"internal "}}]}\n\n',
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"name":"lookup","arguments":"{\\"id\\":"}}]}}]}\n\n',
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"123}"}}]}}]}\n\n',
'data: {"choices":[{"delta":{"content":"world"}}]}\n\n',
"data: [DONE]\n\n",
]),
);
vi.stubGlobal("fetch", fetchMock);
const session = createGatewaySession({
gatewayUrl: "http://127.0.0.1:18789",
gatewayToken: "secret",
agentId: "main",
systemPrompt: "You are helpful",
});
session.messages.push({ role: "user", content: "Say hello" });
const result = await promptGateway(session, "Say hello", {
onText,
onThinking,
onToolStart,
onToolEnd,
});
expect(result).toBe("Hello world");
expect(onText).toHaveBeenCalledTimes(2);
expect(onText).toHaveBeenNthCalledWith(1, "Hello ");
expect(onText).toHaveBeenNthCalledWith(2, "world");
expect(onThinking).toHaveBeenCalledWith("internal ");
expect(onToolStart).toHaveBeenCalledWith("lookup");
expect(onToolEnd).toHaveBeenCalledWith("lookup", false, { id: 123 });
const [requestUrl, requestInit] = fetchMock.mock.calls[0] as [URL, RequestInit];
expect(requestUrl.toString()).toBe("http://127.0.0.1:18789/v1/chat/completions");
expect(requestInit.headers).toMatchObject({
"content-type": "application/json",
authorization: "Bearer secret",
"x-openclaw-agent-id": "main",
});
const parsedBody = JSON.parse(String(requestInit.body));
expect(parsedBody.model).toBe("openclaw:main");
expect(parsedBody.stream).toBe(true);
expect(parsedBody.user).toBe(session.sessionId);
expect(parsedBody.messages.at(-1)).toEqual({ role: "user", content: "Say hello" });
});
it("handles empty data lines, [DONE], and keeps conversation across calls", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
createSseResponse([
"data: \n\n",
'data: {"choices":[{"delta":{"content":"first"}}]}\n\n',
"data: [DONE]\n\n",
]),
)
.mockResolvedValueOnce(
createSseResponse(['data: {"choices":[{"delta":{"content":" second"}}]}\n\n', "data: [DONE]\n\n"]));
vi.stubGlobal("fetch", fetchMock);
const session = createGatewaySession({
gatewayUrl: "http://127.0.0.1:18789",
agentId: "main",
systemPrompt: "System",
});
session.messages.push({ role: "user", content: "one" });
await promptGateway(session, "one");
session.messages.push({ role: "user", content: "two" });
await promptGateway(session, "two");
expect(session.messages).toEqual([
{ role: "developer", content: "System" },
{ role: "user", content: "one" },
{ role: "assistant", content: "first" },
{ role: "user", content: "two" },
{ role: "assistant", content: " second" },
]);
const firstBody = JSON.parse(String((fetchMock.mock.calls[0] as [URL, RequestInit])[1].body));
const secondBody = JSON.parse(String((fetchMock.mock.calls[1] as [URL, RequestInit])[1].body));
expect(firstBody.messages).toHaveLength(2);
expect(secondBody.messages).toHaveLength(4);
});
it("throws descriptive errors for non-200 status, invalid SSE JSON, and connection errors", async () => {
const session = createGatewaySession({
gatewayUrl: "http://127.0.0.1:18789",
agentId: "main",
systemPrompt: "System",
});
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("bad", { status: 503, statusText: "Service Unavailable" })));
await expect(promptGateway(session, "test")).rejects.toThrow(
"OpenClaw gateway request failed (503 Service Unavailable): bad",
);
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(createSseResponse(["data: {not-json}\n\n"])));
await expect(promptGateway(session, "test")).rejects.toThrow("OpenClaw gateway returned invalid SSE JSON");
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("ETIMEDOUT")));
await expect(promptGateway(session, "test")).rejects.toThrow("ETIMEDOUT");
});
});

View File

@@ -1,15 +1,29 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
const { mockCreateFnAgent, mockPromptWithFallback, mockDescribeModel } = vi.hoisted(() => ({
mockCreateFnAgent: vi.fn(),
mockPromptWithFallback: vi.fn(),
mockDescribeModel: vi.fn().mockReturnValue("unknown model"),
const {
mockResolveGatewayConfig,
mockCreateGatewaySession,
mockPromptGateway,
mockDescribeGatewayModel,
mockProbeGateway,
} = vi.hoisted(() => ({
mockResolveGatewayConfig: vi.fn().mockReturnValue({
gatewayUrl: "http://127.0.0.1:18789",
gatewayToken: undefined,
agentId: "main",
}),
mockCreateGatewaySession: vi.fn(),
mockPromptGateway: vi.fn(),
mockDescribeGatewayModel: vi.fn().mockReturnValue("openclaw/main"),
mockProbeGateway: vi.fn().mockResolvedValue(true),
}));
vi.mock("../pi-module.js", () => ({
createFnAgent: mockCreateFnAgent,
promptWithFallback: mockPromptWithFallback,
describeModel: mockDescribeModel,
resolveGatewayConfig: mockResolveGatewayConfig,
createGatewaySession: mockCreateGatewaySession,
promptGateway: mockPromptGateway,
describeGatewayModel: mockDescribeGatewayModel,
probeGateway: mockProbeGateway,
}));
import plugin, { openclawRuntimeMetadata, openclawRuntimeFactory, OPENCLAW_RUNTIME_ID } from "../index.js";
@@ -53,6 +67,7 @@ function createMockContext(overrides: Partial<MockContext> = {}): MockContext {
describe("openclaw-runtime plugin", () => {
beforeEach(() => {
vi.clearAllMocks();
mockProbeGateway.mockResolvedValue(true);
});
afterEach(() => {
@@ -86,14 +101,26 @@ describe("openclaw-runtime plugin", () => {
});
describe("hooks", () => {
it("onLoad should log startup message and emit loaded event", async () => {
it("onLoad should probe gateway, log startup message, and emit loaded event", async () => {
const ctx = createMockContext();
mockResolveGatewayConfig.mockReturnValue({
gatewayUrl: "http://localhost:18789",
gatewayToken: "secret-token",
agentId: "main",
});
await plugin.hooks.onLoad?.(ctx as any);
expect(ctx.logger.info).toHaveBeenCalledWith("OpenClaw Runtime Plugin loaded");
expect(mockProbeGateway).toHaveBeenCalledWith("http://localhost:18789");
expect(ctx.logger.info).toHaveBeenCalledWith(
"OpenClaw Runtime Plugin loaded (gateway: http://localhost:18789, reachable: yes)",
);
expect(ctx.logger.info.mock.calls.join(" ")).not.toContain("secret-token");
expect(ctx.emitEvent).toHaveBeenCalledWith("openclaw-runtime:loaded", {
runtimeId: OPENCLAW_RUNTIME_ID,
version: "0.1.0",
gatewayUrl: "http://localhost:18789",
gatewayReachable: true,
});
});
@@ -110,8 +137,21 @@ describe("openclaw-runtime plugin", () => {
});
it("runtime factory should return executable runtime adapter", async () => {
const runtime = (await openclawRuntimeFactory(createMockContext() as any)) as OpenClawRuntimeAdapter;
const runtime = (await openclawRuntimeFactory(
createMockContext({
settings: {
gatewayUrl: "http://settings-gateway:18789",
gatewayToken: "plugin-token",
agentId: "ops",
},
}) as any,
)) as OpenClawRuntimeAdapter;
expect(mockResolveGatewayConfig).toHaveBeenCalledWith({
gatewayUrl: "http://settings-gateway:18789",
gatewayToken: "plugin-token",
agentId: "ops",
});
expect(runtime).toBeInstanceOf(OpenClawRuntimeAdapter);
expect(runtime.id).toBe("openclaw");
expect(runtime.name).toBe("OpenClaw Runtime");

View File

@@ -1,97 +1,116 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { OpenClawRuntimeAdapter } from "../runtime-adapter.js";
const { mockCreateFnAgent, mockPromptWithFallback, mockDescribeModel } = vi.hoisted(() => ({
mockCreateFnAgent: vi.fn(),
mockPromptWithFallback: vi.fn(),
mockDescribeModel: vi.fn(),
const {
mockResolveGatewayConfig,
mockCreateGatewaySession,
mockPromptGateway,
mockDescribeGatewayModel,
} = vi.hoisted(() => ({
mockResolveGatewayConfig: vi.fn(),
mockCreateGatewaySession: vi.fn(),
mockPromptGateway: vi.fn(),
mockDescribeGatewayModel: vi.fn(),
}));
vi.mock("../pi-module.js", () => ({
createFnAgent: mockCreateFnAgent,
promptWithFallback: mockPromptWithFallback,
describeModel: mockDescribeModel,
resolveGatewayConfig: mockResolveGatewayConfig,
createGatewaySession: mockCreateGatewaySession,
promptGateway: mockPromptGateway,
describeGatewayModel: mockDescribeGatewayModel,
}));
describe("OpenClawRuntimeAdapter", () => {
let adapter: OpenClawRuntimeAdapter;
beforeEach(() => {
vi.clearAllMocks();
mockDescribeModel.mockReturnValue("mock/anthropic-claude");
adapter = new OpenClawRuntimeAdapter();
});
afterEach(() => {
vi.restoreAllMocks();
mockResolveGatewayConfig.mockReturnValue({
gatewayUrl: "http://127.0.0.1:18789",
gatewayToken: "token",
agentId: "main",
});
mockDescribeGatewayModel.mockReturnValue("openclaw/main");
mockCreateGatewaySession.mockImplementation((options) => ({
gatewayUrl: options.gatewayUrl,
gatewayToken: options.gatewayToken,
agentId: options.agentId,
sessionId: "session-123",
messages: [{ role: "developer", content: options.systemPrompt }],
callbacks: options.callbacks,
}));
});
it("has stable runtime identity", () => {
const adapter = new OpenClawRuntimeAdapter();
expect(adapter.id).toBe("openclaw");
expect(adapter.name).toBe("OpenClaw Runtime");
});
it("delegates createSession to createFnAgent with mapped options", async () => {
const mockSession = { dispose: vi.fn() };
mockCreateFnAgent.mockResolvedValue({ session: mockSession, sessionFile: "/tmp/session.json" });
it("createSession returns gateway session with initial developer message", async () => {
const adapter = new OpenClawRuntimeAdapter({ gatewayUrl: "http://localhost:18789", agentId: "ops" });
const result = await adapter.createSession({
cwd: "/project",
systemPrompt: "You are helpful",
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
fallbackProvider: "openai",
fallbackModelId: "gpt-4o",
skills: ["bash"],
onText: vi.fn(),
onThinking: vi.fn(),
onToolStart: vi.fn(),
onToolEnd: vi.fn(),
});
expect(mockCreateFnAgent).toHaveBeenCalledWith({
cwd: "/project",
systemPrompt: "You are helpful",
tools: undefined,
customTools: undefined,
onText: undefined,
onThinking: undefined,
onToolStart: undefined,
onToolEnd: undefined,
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
fallbackProvider: "openai",
fallbackModelId: "gpt-4o",
defaultThinkingLevel: undefined,
sessionManager: undefined,
skillSelection: undefined,
skills: ["bash"],
expect(mockResolveGatewayConfig).toHaveBeenCalledWith({ gatewayUrl: "http://localhost:18789", agentId: "ops" });
expect(mockCreateGatewaySession).toHaveBeenCalledWith(
expect.objectContaining({
gatewayUrl: "http://127.0.0.1:18789",
gatewayToken: "token",
agentId: "main",
systemPrompt: "You are helpful",
}),
);
expect(result.session.messages).toEqual([{ role: "developer", content: "You are helpful" }]);
expect(result.sessionFile).toBeUndefined();
});
it("promptWithFallback appends user message and delegates assistant handling to gateway client", async () => {
const adapter = new OpenClawRuntimeAdapter();
const session = {
gatewayUrl: "http://127.0.0.1:18789",
gatewayToken: "token",
agentId: "main",
sessionId: "session-123",
messages: [{ role: "developer" as const, content: "System" }],
};
mockPromptGateway.mockImplementation(async (activeSession) => {
activeSession.messages.push({ role: "assistant", content: "Gateway response" });
return "Gateway response";
});
expect(result.session).toBe(mockSession);
expect(result.sessionFile).toBe("/tmp/session.json");
await adapter.promptWithFallback(session, "Hello", { onText: vi.fn() });
expect(mockPromptGateway).toHaveBeenCalledWith(session, "Hello", { onText: expect.any(Function) });
expect(session.messages).toEqual([
{ role: "developer", content: "System" },
{ role: "user", content: "Hello" },
{ role: "assistant", content: "Gateway response" },
]);
});
it("delegates promptWithFallback to pi seam", async () => {
const session = { id: "s-1" };
mockPromptWithFallback.mockResolvedValue(undefined);
await adapter.promptWithFallback(session as any, "Hello", { images: [] });
expect(mockPromptWithFallback).toHaveBeenCalledWith(session, "Hello", { images: [] });
});
it("delegates describeModel to pi seam", () => {
const session = { id: "s-2" };
mockDescribeModel.mockReturnValue("anthropic/claude-sonnet-4-5");
it("describeModel returns openclaw/<agentId>", () => {
const adapter = new OpenClawRuntimeAdapter();
const session = {
gatewayUrl: "http://127.0.0.1:18789",
agentId: "ops",
sessionId: "session-123",
messages: [],
};
const result = adapter.describeModel(session as any);
expect(mockDescribeModel).toHaveBeenCalledWith(session);
expect(result).toBe("anthropic/claude-sonnet-4-5");
expect(mockDescribeGatewayModel).toHaveBeenCalledWith(session);
expect(result).toBe("openclaw/main");
});
it("dispose calls session.dispose when present and no-ops otherwise", async () => {
const disposeMock = vi.fn().mockResolvedValue(undefined);
await adapter.dispose({ dispose: disposeMock });
await expect(adapter.dispose({ id: "no-dispose" } as any)).resolves.toBeUndefined();
expect(disposeMock).toHaveBeenCalledTimes(1);
it("dispose is a no-op", async () => {
const adapter = new OpenClawRuntimeAdapter();
await expect(adapter.dispose({} as any)).resolves.toBeUndefined();
});
});

View File

@@ -1,26 +0,0 @@
/**
* Engine import guard for plugin tests.
*
* Runtime plugin tests mock the seam module (../pi-module.js) to avoid loading
* the real @fusion/engine, which pulls in @fusion/core and triggers homedir()-
* based path resolution. This setup file installs a global vi.mock on
* @fusion/engine that throws if the real module is ever loaded without an
* explicit override.
*
* If you see the guard error in a test:
* 1. Add `vi.mock("../pi-module.js", ...)` at the top of the failing test
* 2. If you genuinely need to import @fusion/engine, you must also add HOME
* isolation setup (see setup-test-isolation.ts in packages/core) to this
* plugin's vitest config setupFiles before the guard.
*/
import { vi } from "vitest";
vi.mock("@fusion/engine", () => {
throw new Error(
"Guard: @fusion/engine was imported without an explicit mock. " +
"Runtime plugin tests must mock '../pi-module.js' to prevent loading " +
"the real engine. If you need the real engine, add HOME isolation " +
"setup (setup-test-isolation.ts) to vitest config setupFiles BEFORE " +
"this guard, and remove or override this mock.",
);
});

View File

@@ -7,8 +7,10 @@
import { definePlugin } from "@fusion/plugin-sdk";
import { OpenClawRuntimeAdapter } from "./runtime-adapter.js";
import { probeGateway, resolveGatewayConfig } from "./pi-module.js";
import type {
FusionPlugin,
PluginContext,
PluginRuntimeFactory,
PluginRuntimeManifestMetadata,
} from "@fusion/plugin-sdk";
@@ -19,12 +21,13 @@ const OPENCLAW_RUNTIME_VERSION = "0.1.0";
const openclawRuntimeMetadata: PluginRuntimeManifestMetadata = {
runtimeId: OPENCLAW_RUNTIME_ID,
name: "OpenClaw Runtime",
description: "OpenClaw-backed AI session using the user's configured pi provider and model",
description: "OpenClaw-backed AI session using the local OpenClaw gateway",
version: OPENCLAW_RUNTIME_VERSION,
};
const openclawRuntimeFactory: PluginRuntimeFactory = async () => {
return new OpenClawRuntimeAdapter();
const openclawRuntimeFactory: PluginRuntimeFactory = async (ctx?: PluginContext) => {
const config = resolveGatewayConfig(ctx?.settings);
return new OpenClawRuntimeAdapter(config);
};
const plugin: FusionPlugin = definePlugin({
@@ -39,11 +42,18 @@ const plugin: FusionPlugin = definePlugin({
},
state: "installed",
hooks: {
onLoad: (ctx) => {
ctx.logger.info("OpenClaw Runtime Plugin loaded");
onLoad: async (ctx) => {
const config = resolveGatewayConfig(ctx.settings);
const gatewayReachable = await probeGateway(config.gatewayUrl);
ctx.logger.info(
`OpenClaw Runtime Plugin loaded (gateway: ${config.gatewayUrl}, reachable: ${gatewayReachable ? "yes" : "no"})`,
);
ctx.emitEvent("openclaw-runtime:loaded", {
runtimeId: OPENCLAW_RUNTIME_ID,
version: OPENCLAW_RUNTIME_VERSION,
gatewayUrl: config.gatewayUrl,
gatewayReachable,
});
},
onUnload: () => {

View File

@@ -1,53 +1,211 @@
/**
* Pi Module Seam
*
* Provides a mockable import path for pi functions used by the OpenClawRuntimeAdapter.
* Tests intercept this module via `vi.mock("../pi-module.js", ...)`. The runtime
* implementations come from @fusion/engine; the local types provide a loose
* surface so the adapter doesn't have to depend on @fusion/engine's full types.
*/
import {
createFnAgent as _createFnAgent,
promptWithFallback as _promptWithFallback,
describeModel as _describeModel,
} from "@fusion/engine";
import { randomUUID } from "node:crypto";
import type { GatewayCallbacks, GatewayConfig, GatewaySession } from "./types.js";
export interface PiAgentSession {
dispose?: () => Promise<void> | void;
const DEFAULT_GATEWAY_URL = "http://127.0.0.1:18789";
const DEFAULT_AGENT_ID = "main";
interface ToolCallDelta {
index: number;
id?: string;
function?: {
name?: string;
arguments?: string;
};
}
export interface PiAgentResult {
session: PiAgentSession;
sessionFile?: string;
interface SseDeltaChunk {
choices?: Array<{
delta?: {
content?: string;
reasoning_content?: string;
tool_calls?: ToolCallDelta[];
};
}>;
}
export interface PiAgentOptions {
cwd: string;
export function resolveGatewayConfig(settings?: Record<string, unknown>): GatewayConfig {
const gatewayUrlSetting = typeof settings?.gatewayUrl === "string" ? settings.gatewayUrl : undefined;
const gatewayTokenSetting = typeof settings?.gatewayToken === "string" ? settings.gatewayToken : undefined;
const agentIdSetting = typeof settings?.agentId === "string" ? settings.agentId : undefined;
const gatewayUrl =
gatewayUrlSetting?.trim() || process.env.OPENCLAW_GATEWAY_URL?.trim() || DEFAULT_GATEWAY_URL;
const gatewayToken = gatewayTokenSetting?.trim() || process.env.OPENCLAW_GATEWAY_TOKEN?.trim() || undefined;
const agentId = agentIdSetting?.trim() || process.env.OPENCLAW_AGENT_ID?.trim() || DEFAULT_AGENT_ID;
return { gatewayUrl, gatewayToken, agentId };
}
export async function probeGateway(gatewayUrl: string): Promise<boolean> {
try {
await fetch(gatewayUrl, {
method: "HEAD",
signal: AbortSignal.timeout(2_000),
});
return true;
} catch {
return false;
}
}
export function createGatewaySession(options: {
systemPrompt: string;
tools?: unknown;
customTools?: unknown;
onText?: (text: string) => void;
onThinking?: (text: string) => void;
onToolStart?: (toolName: string, args?: unknown) => void;
onToolEnd?: (toolName: string, result?: unknown) => void;
defaultProvider?: string;
defaultModelId?: string;
fallbackProvider?: string;
fallbackModelId?: string;
defaultThinkingLevel?: string;
sessionManager?: unknown;
skillSelection?: unknown;
skills?: string[];
gatewayUrl: string;
gatewayToken?: string;
agentId: string;
callbacks?: GatewayCallbacks;
}): GatewaySession {
return {
gatewayUrl: options.gatewayUrl,
gatewayToken: options.gatewayToken,
agentId: options.agentId,
sessionId: randomUUID(),
messages: [{ role: "developer", content: options.systemPrompt }],
callbacks: options.callbacks,
dispose: () => undefined,
};
}
export const createFnAgent = _createFnAgent as unknown as (
options: PiAgentOptions,
) => Promise<PiAgentResult>;
export async function promptGateway(
session: GatewaySession,
_prompt: string,
options?: GatewayCallbacks,
): Promise<string> {
const callbacks = options ?? session.callbacks ?? {};
export const promptWithFallback = _promptWithFallback as unknown as (
session: PiAgentSession,
prompt: string,
options?: unknown,
) => Promise<void>;
const response = await fetch(new URL("/v1/chat/completions", session.gatewayUrl), {
method: "POST",
headers: {
"content-type": "application/json",
...(session.gatewayToken ? { authorization: `Bearer ${session.gatewayToken}` } : {}),
"x-openclaw-agent-id": session.agentId,
},
body: JSON.stringify({
model: `openclaw:${session.agentId}`,
messages: session.messages,
stream: true,
user: session.sessionId,
}),
});
if (!response.ok) {
const body = await response.text().catch(() => "");
throw new Error(
`OpenClaw gateway request failed (${response.status} ${response.statusText})${body ? `: ${body}` : ""}`,
);
}
if (!response.body) {
throw new Error("OpenClaw gateway returned an empty response body");
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let assistantResponse = "";
const toolArgBuffers = new Map<number, string>();
const toolNames = new Map<number, string>();
const toolStarted = new Set<number>();
const parsedToolArgs = new Map<number, unknown>();
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
let boundaryIndex = buffer.indexOf("\n\n");
while (boundaryIndex !== -1) {
const eventChunk = buffer.slice(0, boundaryIndex);
buffer = buffer.slice(boundaryIndex + 2);
const lines = eventChunk
.split("\n")
.map((line) => line.trim())
.filter((line) => line.startsWith("data:"));
for (const line of lines) {
const payload = line.slice(5).trim();
if (!payload || payload === "[DONE]") {
continue;
}
let parsed: SseDeltaChunk;
try {
parsed = JSON.parse(payload) as SseDeltaChunk;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`OpenClaw gateway returned invalid SSE JSON: ${message}`);
}
const delta = parsed.choices?.[0]?.delta;
if (!delta) {
continue;
}
if (typeof delta.content === "string" && delta.content.length > 0) {
assistantResponse += delta.content;
callbacks.onText?.(delta.content);
}
if (typeof delta.reasoning_content === "string" && delta.reasoning_content.length > 0) {
callbacks.onThinking?.(delta.reasoning_content);
}
if (Array.isArray(delta.tool_calls)) {
for (const toolCall of delta.tool_calls) {
const index = toolCall.index;
const toolName = toolCall.function?.name;
if (typeof toolName === "string" && toolName.length > 0) {
toolNames.set(index, toolName);
if (!toolStarted.has(index)) {
callbacks.onToolStart?.(toolName);
toolStarted.add(index);
}
}
const nextChunk = toolCall.function?.arguments ?? "";
const previous = toolArgBuffers.get(index) ?? "";
const combined = previous + nextChunk;
toolArgBuffers.set(index, combined);
try {
const parsedArgs = combined ? (JSON.parse(combined) as unknown) : {};
parsedToolArgs.set(index, parsedArgs);
} catch {
// Partial JSON; wait for more chunks.
}
}
}
}
boundaryIndex = buffer.indexOf("\n\n");
}
}
const remainder = decoder.decode();
if (remainder) {
buffer += remainder;
}
for (const [index, parsedArgs] of parsedToolArgs.entries()) {
const resolvedName = toolNames.get(index) ?? "unknown_tool";
if (!toolStarted.has(index)) {
callbacks.onToolStart?.(resolvedName);
toolStarted.add(index);
}
callbacks.onToolEnd?.(resolvedName, false, parsedArgs);
}
session.messages.push({ role: "assistant", content: assistantResponse });
return assistantResponse;
}
export function describeGatewayModel(session: GatewaySession): string {
return `openclaw/${session.agentId}`;
}
export const describeModel = _describeModel as unknown as (session: PiAgentSession) => string;

View File

@@ -1,49 +1,58 @@
import type {
AgentRuntime,
AgentRuntimeOptions,
AgentSession,
AgentSessionResult,
GatewayConfig,
GatewaySession,
} from "./types.js";
import { createFnAgent, describeModel, promptWithFallback } from "./pi-module.js";
const getModelDescription = describeModel;
import {
createGatewaySession,
describeGatewayModel,
promptGateway,
resolveGatewayConfig,
} from "./pi-module.js";
export class OpenClawRuntimeAdapter implements AgentRuntime {
readonly id = "openclaw";
readonly name = "OpenClaw Runtime";
private readonly config: GatewayConfig;
constructor(settings?: Partial<GatewayConfig>) {
this.config = resolveGatewayConfig(settings as Record<string, unknown> | undefined);
}
async createSession(options: AgentRuntimeOptions): Promise<AgentSessionResult> {
return createFnAgent({
cwd: options.cwd,
const session = createGatewaySession({
gatewayUrl: this.config.gatewayUrl,
gatewayToken: this.config.gatewayToken,
agentId: this.config.agentId,
systemPrompt: options.systemPrompt,
tools: options.tools,
customTools: options.customTools,
onText: options.onText,
onThinking: options.onThinking,
onToolStart: options.onToolStart,
onToolEnd: options.onToolEnd,
defaultProvider: options.defaultProvider,
defaultModelId: options.defaultModelId,
fallbackProvider: options.fallbackProvider,
fallbackModelId: options.fallbackModelId,
defaultThinkingLevel: options.defaultThinkingLevel,
sessionManager: options.sessionManager,
skillSelection: options.skillSelection,
skills: options.skills,
callbacks: {
onText: options.onText,
onThinking: options.onThinking,
onToolStart: options.onToolStart,
onToolEnd: options.onToolEnd,
},
});
return {
session,
sessionFile: undefined,
};
}
async promptWithFallback(session: AgentSession, prompt: string, options?: unknown): Promise<void> {
return promptWithFallback(session, prompt, options);
async promptWithFallback(session: GatewaySession, prompt: string, options?: unknown): Promise<void> {
session.messages.push({ role: "user", content: prompt });
await promptGateway(session, prompt, options as Parameters<typeof promptGateway>[2]);
}
describeModel(session: AgentSession): string {
return getModelDescription(session);
describeModel(session: GatewaySession): string {
return describeGatewayModel(session);
}
async dispose(session: AgentSession): Promise<void> {
if (typeof (session as { dispose?: () => Promise<void> }).dispose === "function") {
await (session as { dispose: () => Promise<void> }).dispose();
}
async dispose(_session: GatewaySession): Promise<void> {
// OpenClaw gateway sessions are managed remotely; no local cleanup required.
}
}

View File

@@ -1,25 +1,42 @@
/**
* OpenClaw Runtime Plugin - Type Definitions
* OpenClaw runtime adapter contracts.
*
* The runtime contract is defined locally to avoid compile-time coupling to
* internal engine exports.
* These mirror the engine runtime interface while keeping this plugin package
* decoupled from internal engine modules.
*/
/** Minimal session shape used by the runtime adapter. */
export interface AgentSession {
export type GatewayRole = "developer" | "user" | "assistant";
export interface GatewayMessage {
role: GatewayRole;
content: string;
}
export interface GatewayConfig {
gatewayUrl: string;
gatewayToken?: string;
agentId: string;
}
export interface GatewayCallbacks {
onText?: (text: string) => void;
onThinking?: (text: string) => void;
onToolStart?: (toolName: string, args?: unknown) => void;
onToolEnd?: (toolName: string, isError: boolean, result?: unknown) => void;
}
export interface GatewaySession extends GatewayConfig {
sessionId: string;
messages: GatewayMessage[];
callbacks?: GatewayCallbacks;
dispose?: () => Promise<void> | void;
}
/** Options for creating an agent session. Mirrors createFnAgent inputs used by the adapter. */
export interface AgentRuntimeOptions {
export interface AgentRuntimeOptions extends GatewayCallbacks {
cwd: string;
systemPrompt: string;
tools?: unknown;
customTools?: unknown;
onText?: (text: string) => void;
onThinking?: (text: string) => void;
onToolStart?: (toolName: string, args?: unknown) => void;
onToolEnd?: (toolName: string, result?: unknown) => void;
defaultProvider?: string;
defaultModelId?: string;
fallbackProvider?: string;
@@ -30,18 +47,16 @@ export interface AgentRuntimeOptions {
skills?: string[];
}
/** Result of creating a session. */
export interface AgentSessionResult {
session: AgentSession;
session: GatewaySession;
sessionFile?: string;
}
/** Agent runtime adapter interface. */
export interface AgentRuntime {
id: string;
name: string;
createSession(options: AgentRuntimeOptions): Promise<AgentSessionResult>;
promptWithFallback(session: AgentSession, prompt: string, options?: unknown): Promise<void>;
describeModel(session: AgentSession): string;
dispose?(session: AgentSession): Promise<void>;
promptWithFallback(session: GatewaySession, prompt: string, options?: unknown): Promise<void>;
describeModel(session: GatewaySession): string;
dispose?(session: GatewaySession): Promise<void>;
}