feat: add bridge-backed multi-agent OpenClaw tool
This commit is contained in:
parent
56845ab79f
commit
c6ddcfd22b
@ -184,6 +184,12 @@ Gateway clients can use:
|
||||
- Pass the prepared `artifactScope`/`artifactDirectory` to `chat.send` and, if
|
||||
`chat.send` returns a different OpenClaw `runId`, prepare/export with that
|
||||
actual `runId` instead of the bridge request id.
|
||||
- `openclaw_multi_session_agents` from an OpenClaw task to call XWorkmate Bridge
|
||||
`/acp/rpc` with `multiAgent=true`, while deriving `sessionKey`, `runId`, and
|
||||
`workspaceDir` from the host task context instead of model-controlled tool
|
||||
parameters.
|
||||
- `xworkmate.agents.run` for trusted gateway callers that need the same
|
||||
bridge-backed multi-agent run and artifact-scope export in one method.
|
||||
- `xworkmate.artifacts.list` for a metadata-only manifest and Markdown table.
|
||||
- `xworkmate.artifacts.read` with `artifactScope` and `relativePath` for one task file.
|
||||
- `xworkmate.artifacts.read` with `artifactRef` for a plugin-returned task file.
|
||||
|
||||
108
dist/index.js
vendored
108
dist/index.js
vendored
@ -1,4 +1,5 @@
|
||||
import { exportXWorkmateArtifacts, prepareXWorkmateArtifacts, readXWorkmateArtifact, } from "./src/exportArtifacts.js";
|
||||
import { runXWorkmateBridgeAgents } from "./src/bridgeAgents.js";
|
||||
const plugin = {
|
||||
id: "openclaw-multi-session-plugins",
|
||||
name: "openclaw-multi-session-plugins",
|
||||
@ -71,10 +72,30 @@ function register(api) {
|
||||
});
|
||||
}
|
||||
});
|
||||
api.registerGatewayMethod("xworkmate.agents.run", async (opts) => {
|
||||
try {
|
||||
const payload = await runXWorkmateBridgeAgents({
|
||||
params: opts.params,
|
||||
config: api.config,
|
||||
pluginConfig: api.pluginConfig,
|
||||
});
|
||||
opts.respond(true, payload, undefined);
|
||||
}
|
||||
catch (error) {
|
||||
opts.respond(false, undefined, {
|
||||
code: "INVALID_REQUEST",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
});
|
||||
api.registerTool((ctx) => createXWorkmateArtifactsTool(api, ctx), {
|
||||
names: ["openclaw_multi_session_artifacts"],
|
||||
optional: true,
|
||||
});
|
||||
api.registerTool((ctx) => createXWorkmateAgentsTool(api, ctx), {
|
||||
names: ["openclaw_multi_session_agents"],
|
||||
optional: true,
|
||||
});
|
||||
}
|
||||
function createXWorkmateArtifactsTool(api, ctx) {
|
||||
return {
|
||||
@ -165,3 +186,90 @@ function createXWorkmateArtifactsTool(api, ctx) {
|
||||
},
|
||||
};
|
||||
}
|
||||
function createXWorkmateAgentsTool(api, ctx) {
|
||||
return {
|
||||
name: "openclaw_multi_session_agents",
|
||||
label: "XWorkmate multi-agent bridge",
|
||||
description: "Ask XWorkmate Bridge to coordinate multiple configured agents, then save the result into the current task artifact scope.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
taskPrompt: {
|
||||
type: "string",
|
||||
description: "Overall multi-agent task prompt.",
|
||||
},
|
||||
mode: {
|
||||
type: "string",
|
||||
enum: ["sequence", "parallel", "race", "conversation"],
|
||||
description: "Multi-agent orchestration mode.",
|
||||
},
|
||||
steps: {
|
||||
type: "array",
|
||||
description: "Agent steps. Each item needs providerId and prompt.",
|
||||
items: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
providerId: { type: "string" },
|
||||
prompt: { type: "string" },
|
||||
outputAs: { type: "string" },
|
||||
timeoutMs: { type: "number" },
|
||||
},
|
||||
required: ["providerId", "prompt"],
|
||||
},
|
||||
},
|
||||
participants: {
|
||||
type: "array",
|
||||
description: "Conversation participants by providerId.",
|
||||
items: { type: "string" },
|
||||
},
|
||||
maxTurns: {
|
||||
type: "number",
|
||||
description: "Maximum turns for conversation mode.",
|
||||
},
|
||||
stopConditions: {
|
||||
type: "array",
|
||||
description: "Text markers that stop conversation mode.",
|
||||
items: { type: "string" },
|
||||
},
|
||||
timeoutMs: {
|
||||
type: "number",
|
||||
description: "Overall bridge request timeout.",
|
||||
},
|
||||
},
|
||||
required: ["taskPrompt"],
|
||||
},
|
||||
async execute(_id, params) {
|
||||
const sessionKey = ctx.sessionScope?.sessionKey || ctx.sessionKey;
|
||||
const runId = ctx.sessionScope?.runId || ctx.runId || "";
|
||||
const workspaceDir = ctx.sessionScope?.workspaceDir || ctx.workspaceDir;
|
||||
if (!sessionKey) {
|
||||
throw new Error("sessionKey required");
|
||||
}
|
||||
if (!runId) {
|
||||
throw new Error("runId required");
|
||||
}
|
||||
const { sessionKey: _ignoredSessionKey, runId: _ignoredRunId, workspaceDir: _ignoredWorkspaceDir, ...operationParams } = params;
|
||||
const payload = await runXWorkmateBridgeAgents({
|
||||
params: {
|
||||
...operationParams,
|
||||
sessionKey,
|
||||
runId,
|
||||
...(workspaceDir ? { workspaceDir } : {}),
|
||||
},
|
||||
config: ctx.config ?? api.config,
|
||||
pluginConfig: api.pluginConfig,
|
||||
});
|
||||
const summary = typeof payload.bridgeResult.summary === "string"
|
||||
? payload.bridgeResult.summary
|
||||
: typeof payload.bridgeResult.output === "string"
|
||||
? payload.bridgeResult.output
|
||||
: "Multi-agent run completed.";
|
||||
return {
|
||||
content: [{ type: "text", text: [summary, "", payload.manifestMarkdown].join("\n") }],
|
||||
details: { artifacts: payload.artifacts, bridgeResult: payload.bridgeResult },
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
11
dist/src/bridgeAgents.d.ts
vendored
Normal file
11
dist/src/bridgeAgents.d.ts
vendored
Normal file
@ -0,0 +1,11 @@
|
||||
import { type XWorkmateArtifactExport } from "./exportArtifacts.js";
|
||||
type BridgeAgentInput = {
|
||||
params: Record<string, unknown>;
|
||||
config?: unknown;
|
||||
pluginConfig?: Record<string, unknown>;
|
||||
};
|
||||
type BridgeAgentRun = XWorkmateArtifactExport & {
|
||||
bridgeResult: Record<string, unknown>;
|
||||
};
|
||||
export declare function runXWorkmateBridgeAgents(input: BridgeAgentInput): Promise<BridgeAgentRun>;
|
||||
export {};
|
||||
205
dist/src/bridgeAgents.js
vendored
Normal file
205
dist/src/bridgeAgents.js
vendored
Normal file
@ -0,0 +1,205 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { exportXWorkmateArtifacts, prepareXWorkmateArtifacts, } from "./exportArtifacts.js";
|
||||
export async function runXWorkmateBridgeAgents(input) {
|
||||
const params = input.params ?? {};
|
||||
const pluginConfig = input.pluginConfig ?? {};
|
||||
const sessionKey = requiredString(params.sessionKey, "sessionKey required");
|
||||
const runId = requiredString(params.runId, "runId required");
|
||||
const taskPrompt = requiredString(params.taskPrompt, "taskPrompt required");
|
||||
const bridgeUrl = bridgeRpcUrl(pluginConfig);
|
||||
const bridgeToken = bridgeAuthToken(pluginConfig);
|
||||
if (!bridgeToken) {
|
||||
throw new Error("bridgeToken required");
|
||||
}
|
||||
const prepared = await prepareXWorkmateArtifacts({
|
||||
params: { sessionKey, runId, workspaceDir: params.workspaceDir },
|
||||
config: input.config,
|
||||
pluginConfig,
|
||||
});
|
||||
const orchestrationMode = optionalString(params.mode) || optionalString(params.orchestrationMode) || "sequence";
|
||||
const participants = safeStringList(params.participants);
|
||||
const steps = safeSteps(params.steps, participants.length > 0);
|
||||
if (steps.length === 0 && participants.length === 0) {
|
||||
throw new Error("steps or participants required");
|
||||
}
|
||||
const routing = {
|
||||
orchestrationMode,
|
||||
steps,
|
||||
};
|
||||
if (participants.length > 0) {
|
||||
routing.participants = participants;
|
||||
}
|
||||
const maxTurns = positiveInteger(params.maxTurns, 0);
|
||||
if (maxTurns > 0) {
|
||||
routing.maxTurns = maxTurns;
|
||||
}
|
||||
const stopConditions = safeStringList(params.stopConditions);
|
||||
if (stopConditions.length > 0) {
|
||||
routing.stopConditions = stopConditions;
|
||||
}
|
||||
const bridgeResult = await callBridgeRPC({
|
||||
bridgeUrl,
|
||||
bridgeToken,
|
||||
timeoutMs: positiveInteger(params.timeoutMs, positiveInteger(pluginConfig.bridgeTimeoutMs, 600_000)),
|
||||
body: {
|
||||
jsonrpc: "2.0",
|
||||
id: `openclaw-${Date.now()}`,
|
||||
method: "session.start",
|
||||
params: {
|
||||
sessionId: `openclaw:${sessionKey}`,
|
||||
threadId: sessionKey,
|
||||
taskPrompt,
|
||||
workingDirectory: prepared.artifactDirectory,
|
||||
multiAgent: true,
|
||||
mode: "multi-agent",
|
||||
routing,
|
||||
},
|
||||
},
|
||||
});
|
||||
await fs.mkdir(prepared.artifactDirectory, { recursive: true });
|
||||
await fs.writeFile(path.join(prepared.artifactDirectory, "multi-agent-result.json"), `${JSON.stringify(bridgeResult, null, 2)}\n`);
|
||||
await fs.writeFile(path.join(prepared.artifactDirectory, "multi-agent-result.md"), formatBridgeResultMarkdown(bridgeResult));
|
||||
const exported = await exportXWorkmateArtifacts({
|
||||
params: {
|
||||
sessionKey,
|
||||
runId,
|
||||
workspaceDir: params.workspaceDir,
|
||||
artifactScope: prepared.artifactScope,
|
||||
includeContent: false,
|
||||
},
|
||||
config: input.config,
|
||||
pluginConfig,
|
||||
});
|
||||
return { ...exported, bridgeResult };
|
||||
}
|
||||
async function callBridgeRPC(input) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), input.timeoutMs);
|
||||
try {
|
||||
const response = await fetch(input.bridgeUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: bearer(input.bridgeToken),
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify(input.body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(`bridge request failed (${response.status}): ${text.trim()}`);
|
||||
}
|
||||
const decoded = JSON.parse(text);
|
||||
const error = asRecord(decoded.error);
|
||||
if (error) {
|
||||
throw new Error(optionalString(error.message) || "bridge rpc error");
|
||||
}
|
||||
const result = asRecord(decoded.result);
|
||||
if (!result) {
|
||||
throw new Error("bridge response missing result");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
function bridgeRpcUrl(pluginConfig) {
|
||||
const configured = optionalString(pluginConfig.bridgeUrl) || optionalString(process.env.XWORKMATE_BRIDGE_URL);
|
||||
if (!configured) {
|
||||
throw new Error("bridgeUrl required");
|
||||
}
|
||||
const trimmed = configured.replace(/\/+$/, "");
|
||||
if (trimmed.endsWith("/acp/rpc")) {
|
||||
return trimmed;
|
||||
}
|
||||
return `${trimmed}/acp/rpc`;
|
||||
}
|
||||
function bridgeAuthToken(pluginConfig) {
|
||||
return optionalString(pluginConfig.bridgeToken) || optionalString(process.env.XWORKMATE_BRIDGE_TOKEN);
|
||||
}
|
||||
function safeSteps(raw, allowEmpty) {
|
||||
if (!Array.isArray(raw)) {
|
||||
if (allowEmpty) {
|
||||
return [];
|
||||
}
|
||||
throw new Error("steps required");
|
||||
}
|
||||
return raw.map((item, index) => {
|
||||
const mapped = asRecord(item);
|
||||
if (!mapped) {
|
||||
throw new Error(`steps[${index}] must be an object`);
|
||||
}
|
||||
const providerId = optionalString(mapped.providerId) || optionalString(mapped.provider) || optionalString(mapped.agent);
|
||||
const prompt = optionalString(mapped.prompt) || optionalString(mapped.taskPrompt);
|
||||
if (!providerId) {
|
||||
throw new Error(`steps[${index}].providerId required`);
|
||||
}
|
||||
if (!prompt) {
|
||||
throw new Error(`steps[${index}].prompt required`);
|
||||
}
|
||||
return {
|
||||
providerId,
|
||||
prompt,
|
||||
...(optionalString(mapped.outputAs) ? { outputAs: optionalString(mapped.outputAs) } : {}),
|
||||
...(positiveInteger(mapped.timeoutMs, 0) > 0 ? { timeoutMs: positiveInteger(mapped.timeoutMs, 0) } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
function safeStringList(raw) {
|
||||
if (!Array.isArray(raw)) {
|
||||
return [];
|
||||
}
|
||||
return raw.map((value) => optionalString(value)).filter((value) => value.length > 0);
|
||||
}
|
||||
function formatBridgeResultMarkdown(result) {
|
||||
const lines = ["# Multi-Agent Result", ""];
|
||||
lines.push(`- Status: ${optionalString(result.status) || "unknown"}`);
|
||||
lines.push(`- Mode: ${optionalString(result.orchestrationMode) || optionalString(result.mode) || "multi-agent"}`);
|
||||
const summary = optionalString(result.summary) || optionalString(result.output) || optionalString(result.message);
|
||||
if (summary) {
|
||||
lines.push("", "## Summary", "", summary);
|
||||
}
|
||||
const steps = Array.isArray(result.steps) ? result.steps : [];
|
||||
if (steps.length > 0) {
|
||||
lines.push("", "## Steps", "");
|
||||
for (const item of steps) {
|
||||
const step = asRecord(item) ?? {};
|
||||
lines.push(`- ${optionalString(step.providerId) || "unknown"}: ${optionalString(step.status) || "unknown"}${optionalString(step.error) ? ` (${optionalString(step.error)})` : ""}`);
|
||||
}
|
||||
}
|
||||
lines.push("");
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
function bearer(token) {
|
||||
return token.toLowerCase().startsWith("bearer ") ? token : `Bearer ${token}`;
|
||||
}
|
||||
function requiredString(value, message) {
|
||||
const text = optionalString(value);
|
||||
if (!text) {
|
||||
throw new Error(message);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
function optionalString(value) {
|
||||
if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") {
|
||||
return "";
|
||||
}
|
||||
const text = String(value).trim();
|
||||
return text === "<nil>" ? "" : text;
|
||||
}
|
||||
function positiveInteger(value, fallback) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return fallback;
|
||||
}
|
||||
return Math.floor(parsed);
|
||||
}
|
||||
function asRecord(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
168
index.test.ts
168
index.test.ts
@ -1,4 +1,5 @@
|
||||
import fs from "node:fs";
|
||||
import http from "node:http";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
@ -21,11 +22,15 @@ describe("plugin registration", () => {
|
||||
};
|
||||
|
||||
expect(manifest.contracts?.tools).toContain("openclaw_multi_session_artifacts");
|
||||
expect(manifest.contracts?.tools).toContain("openclaw_multi_session_agents");
|
||||
expect(manifest.contracts?.sessionScopedTools).toContain("openclaw_multi_session_artifacts");
|
||||
expect(manifest.contracts?.sessionScopedTools).toContain("openclaw_multi_session_agents");
|
||||
expect(manifest.configSchema?.properties?.artifactRefSigningSecret).toBeTruthy();
|
||||
expect(manifest.configSchema?.properties?.bridgeUrl).toBeTruthy();
|
||||
expect(manifest.configSchema?.properties?.bridgeToken).toBeTruthy();
|
||||
});
|
||||
|
||||
it("registers the xworkmate artifact gateway methods and optional tool", () => {
|
||||
it("registers the xworkmate gateway methods and optional tools", () => {
|
||||
const methods: Array<{ method: string; handler: GatewayMethodHandler }> = [];
|
||||
const tools: Array<{ tool: unknown; options: unknown }> = [];
|
||||
const api = {
|
||||
@ -46,13 +51,18 @@ describe("plugin registration", () => {
|
||||
"xworkmate.artifacts.export",
|
||||
"xworkmate.artifacts.list",
|
||||
"xworkmate.artifacts.read",
|
||||
"xworkmate.agents.run",
|
||||
]);
|
||||
expect(methods.every((entry) => typeof entry.handler === "function")).toBe(true);
|
||||
expect(tools).toHaveLength(1);
|
||||
expect(tools).toHaveLength(2);
|
||||
expect(tools[0]?.options).toMatchObject({
|
||||
names: ["openclaw_multi_session_artifacts"],
|
||||
optional: true,
|
||||
});
|
||||
expect(tools[1]?.options).toMatchObject({
|
||||
names: ["openclaw_multi_session_agents"],
|
||||
optional: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("executes registered gateway methods against the current task scope", async () => {
|
||||
@ -146,6 +156,160 @@ describe("plugin registration", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("does not expose session scope controls on the bridge agents tool", async () => {
|
||||
const tools: Array<{ tool: unknown; options: { names?: string[] } }> = [];
|
||||
const api = {
|
||||
config: {},
|
||||
pluginConfig: {},
|
||||
registerGatewayMethod: () => undefined,
|
||||
registerTool: (tool: unknown, options: { names?: string[] }) => {
|
||||
tools.push({ tool, options });
|
||||
},
|
||||
} as unknown as OpenClawPluginApi;
|
||||
|
||||
plugin.register(api);
|
||||
|
||||
const entry = tools.find((item) => item.options.names?.includes("openclaw_multi_session_agents"));
|
||||
const factory = entry?.tool as (ctx: Record<string, unknown>) => {
|
||||
parameters: { properties?: Record<string, unknown> };
|
||||
execute: (id: string, params: Record<string, unknown>) => Promise<unknown>;
|
||||
};
|
||||
const tool = factory({});
|
||||
|
||||
expect(tool.parameters.properties?.sessionKey).toBeUndefined();
|
||||
expect(tool.parameters.properties?.runId).toBeUndefined();
|
||||
expect(tool.parameters.properties?.workspaceDir).toBeUndefined();
|
||||
await expect(tool.execute("call-1", { taskPrompt: "run", steps: [] })).rejects.toThrow("sessionKey required");
|
||||
await expect(factory({ sessionKey: "thread-main" }).execute("call-2", { taskPrompt: "run", steps: [] })).rejects.toThrow(
|
||||
"runId required",
|
||||
);
|
||||
});
|
||||
|
||||
it("fails closed when bridge token is missing", async () => {
|
||||
const tools: Array<{ tool: unknown; options: { names?: string[] } }> = [];
|
||||
const api = {
|
||||
config: {},
|
||||
pluginConfig: { workspaceDir: await fs.promises.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-agent-token-")), bridgeUrl: "http://127.0.0.1:1" },
|
||||
registerGatewayMethod: () => undefined,
|
||||
registerTool: (tool: unknown, options: { names?: string[] }) => {
|
||||
tools.push({ tool, options });
|
||||
},
|
||||
} as unknown as OpenClawPluginApi;
|
||||
|
||||
plugin.register(api);
|
||||
|
||||
const entry = tools.find((item) => item.options.names?.includes("openclaw_multi_session_agents"));
|
||||
const factory = entry?.tool as (ctx: Record<string, unknown>) => {
|
||||
execute: (id: string, params: Record<string, unknown>) => Promise<unknown>;
|
||||
};
|
||||
const tool = factory({ sessionKey: "thread-main", runId: "turn-1" });
|
||||
|
||||
await expect(
|
||||
tool.execute("call-1", {
|
||||
taskPrompt: "run",
|
||||
steps: [{ providerId: "codex", prompt: "hello" }],
|
||||
}),
|
||||
).rejects.toThrow("bridgeToken required");
|
||||
});
|
||||
|
||||
it("runs bridge-backed multi-agent work inside the current task artifact scope", async () => {
|
||||
const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-bridge-agents-"));
|
||||
const bridgeRequests: Array<Record<string, unknown>> = [];
|
||||
const bridgeServer = http.createServer((req, res) => {
|
||||
if (req.method !== "POST" || req.url !== "/acp/rpc") {
|
||||
res.statusCode = 404;
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
expect(req.headers.authorization).toBe("Bearer bridge-token");
|
||||
let body = "";
|
||||
req.on("data", (chunk: Buffer) => {
|
||||
body += chunk.toString("utf8");
|
||||
});
|
||||
req.on("end", () => {
|
||||
const decoded = JSON.parse(body) as Record<string, unknown>;
|
||||
bridgeRequests.push(decoded);
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: decoded.id,
|
||||
result: {
|
||||
success: true,
|
||||
status: "completed",
|
||||
mode: "multi-agent",
|
||||
orchestrationMode: "sequence",
|
||||
summary: "bridge agents done",
|
||||
steps: [{ providerId: "codex", status: "completed", output: "done" }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) => bridgeServer.listen(0, "127.0.0.1", resolve));
|
||||
try {
|
||||
const address = bridgeServer.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("missing bridge server address");
|
||||
}
|
||||
const tools: Array<{ tool: unknown; options: { names?: string[] } }> = [];
|
||||
const api = {
|
||||
config: {},
|
||||
pluginConfig: {
|
||||
workspaceDir: root,
|
||||
bridgeUrl: `http://127.0.0.1:${address.port}`,
|
||||
bridgeToken: "bridge-token",
|
||||
},
|
||||
registerGatewayMethod: () => undefined,
|
||||
registerTool: (tool: unknown, options: { names?: string[] }) => {
|
||||
tools.push({ tool, options });
|
||||
},
|
||||
} as unknown as OpenClawPluginApi;
|
||||
|
||||
plugin.register(api);
|
||||
|
||||
const entry = tools.find((item) => item.options.names?.includes("openclaw_multi_session_agents"));
|
||||
const factory = entry?.tool as (ctx: Record<string, unknown>) => {
|
||||
execute: (id: string, params: Record<string, unknown>) => Promise<{ content: Array<{ text: string }>; details: { artifacts: Array<{ relativePath: string }> } }>;
|
||||
};
|
||||
const tool = factory({ sessionKey: "thread-main", runId: "turn-1", workspaceDir: root });
|
||||
const result = await tool.execute("call-1", {
|
||||
taskPrompt: "coordinate",
|
||||
mode: "sequence",
|
||||
steps: [{ providerId: "codex", prompt: "hello" }],
|
||||
sessionKey: "evil",
|
||||
runId: "evil",
|
||||
workspaceDir: "/",
|
||||
});
|
||||
|
||||
expect(result.content[0]?.text).toContain("bridge agents done");
|
||||
expect(result.details.artifacts).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ relativePath: "multi-agent-result.json" }),
|
||||
expect.objectContaining({ relativePath: "multi-agent-result.md" }),
|
||||
]),
|
||||
);
|
||||
expect(await fs.promises.readFile(path.join(root, "tasks", "thread-main", "turn-1", "multi-agent-result.md"), "utf8")).toContain(
|
||||
"bridge agents done",
|
||||
);
|
||||
await expect(fs.promises.stat(path.join(root, "tasks", "evil", "evil", "multi-agent-result.md"))).rejects.toThrow();
|
||||
expect(bridgeRequests).toHaveLength(1);
|
||||
const params = bridgeRequests[0]?.params as Record<string, unknown>;
|
||||
expect(params.sessionId).toBe("openclaw:thread-main");
|
||||
expect(params.threadId).toBe("thread-main");
|
||||
expect(params.workingDirectory).toBe(await fs.promises.realpath(path.join(root, "tasks", "thread-main", "turn-1")));
|
||||
expect(params.multiAgent).toBe(true);
|
||||
expect(params.routing).toMatchObject({
|
||||
orchestrationMode: "sequence",
|
||||
steps: [{ providerId: "codex", prompt: "hello" }],
|
||||
});
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
bridgeServer.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("uses host context scope for the optional agent tool", async () => {
|
||||
const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-tool-"));
|
||||
const current = await prepareXWorkmateArtifacts({
|
||||
|
||||
117
index.ts
117
index.ts
@ -8,6 +8,7 @@ import {
|
||||
prepareXWorkmateArtifacts,
|
||||
readXWorkmateArtifact,
|
||||
} from "./src/exportArtifacts.js";
|
||||
import { runXWorkmateBridgeAgents } from "./src/bridgeAgents.js";
|
||||
|
||||
type XWorkmateToolContext = {
|
||||
config?: unknown;
|
||||
@ -91,10 +92,29 @@ function register(api: OpenClawPluginApi) {
|
||||
});
|
||||
}
|
||||
});
|
||||
api.registerGatewayMethod("xworkmate.agents.run", async (opts: GatewayRequestHandlerOptions) => {
|
||||
try {
|
||||
const payload = await runXWorkmateBridgeAgents({
|
||||
params: opts.params,
|
||||
config: api.config,
|
||||
pluginConfig: api.pluginConfig,
|
||||
});
|
||||
opts.respond(true, payload, undefined);
|
||||
} catch (error) {
|
||||
opts.respond(false, undefined, {
|
||||
code: "INVALID_REQUEST",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
});
|
||||
api.registerTool((ctx) => createXWorkmateArtifactsTool(api, ctx), {
|
||||
names: ["openclaw_multi_session_artifacts"],
|
||||
optional: true,
|
||||
});
|
||||
api.registerTool((ctx) => createXWorkmateAgentsTool(api, ctx), {
|
||||
names: ["openclaw_multi_session_agents"],
|
||||
optional: true,
|
||||
});
|
||||
}
|
||||
|
||||
function createXWorkmateArtifactsTool(
|
||||
@ -195,3 +215,100 @@ function createXWorkmateArtifactsTool(
|
||||
},
|
||||
} as unknown as AnyAgentTool;
|
||||
}
|
||||
|
||||
function createXWorkmateAgentsTool(
|
||||
api: OpenClawPluginApi,
|
||||
ctx: XWorkmateToolContext,
|
||||
): AnyAgentTool {
|
||||
return {
|
||||
name: "openclaw_multi_session_agents",
|
||||
label: "XWorkmate multi-agent bridge",
|
||||
description:
|
||||
"Ask XWorkmate Bridge to coordinate multiple configured agents, then save the result into the current task artifact scope.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
taskPrompt: {
|
||||
type: "string",
|
||||
description: "Overall multi-agent task prompt.",
|
||||
},
|
||||
mode: {
|
||||
type: "string",
|
||||
enum: ["sequence", "parallel", "race", "conversation"],
|
||||
description: "Multi-agent orchestration mode.",
|
||||
},
|
||||
steps: {
|
||||
type: "array",
|
||||
description: "Agent steps. Each item needs providerId and prompt.",
|
||||
items: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
providerId: { type: "string" },
|
||||
prompt: { type: "string" },
|
||||
outputAs: { type: "string" },
|
||||
timeoutMs: { type: "number" },
|
||||
},
|
||||
required: ["providerId", "prompt"],
|
||||
},
|
||||
},
|
||||
participants: {
|
||||
type: "array",
|
||||
description: "Conversation participants by providerId.",
|
||||
items: { type: "string" },
|
||||
},
|
||||
maxTurns: {
|
||||
type: "number",
|
||||
description: "Maximum turns for conversation mode.",
|
||||
},
|
||||
stopConditions: {
|
||||
type: "array",
|
||||
description: "Text markers that stop conversation mode.",
|
||||
items: { type: "string" },
|
||||
},
|
||||
timeoutMs: {
|
||||
type: "number",
|
||||
description: "Overall bridge request timeout.",
|
||||
},
|
||||
},
|
||||
required: ["taskPrompt"],
|
||||
},
|
||||
async execute(_id: string, params: Record<string, unknown>) {
|
||||
const sessionKey = ctx.sessionScope?.sessionKey || ctx.sessionKey;
|
||||
const runId = ctx.sessionScope?.runId || ctx.runId || "";
|
||||
const workspaceDir = ctx.sessionScope?.workspaceDir || ctx.workspaceDir;
|
||||
if (!sessionKey) {
|
||||
throw new Error("sessionKey required");
|
||||
}
|
||||
if (!runId) {
|
||||
throw new Error("runId required");
|
||||
}
|
||||
const {
|
||||
sessionKey: _ignoredSessionKey,
|
||||
runId: _ignoredRunId,
|
||||
workspaceDir: _ignoredWorkspaceDir,
|
||||
...operationParams
|
||||
} = params;
|
||||
const payload = await runXWorkmateBridgeAgents({
|
||||
params: {
|
||||
...operationParams,
|
||||
sessionKey,
|
||||
runId,
|
||||
...(workspaceDir ? { workspaceDir } : {}),
|
||||
},
|
||||
config: ctx.config ?? api.config,
|
||||
pluginConfig: api.pluginConfig,
|
||||
});
|
||||
const summary = typeof payload.bridgeResult.summary === "string"
|
||||
? payload.bridgeResult.summary
|
||||
: typeof payload.bridgeResult.output === "string"
|
||||
? payload.bridgeResult.output
|
||||
: "Multi-agent run completed.";
|
||||
return {
|
||||
content: [{ type: "text", text: [summary, "", payload.manifestMarkdown].join("\n") }],
|
||||
details: { artifacts: payload.artifacts, bridgeResult: payload.bridgeResult },
|
||||
};
|
||||
},
|
||||
} as unknown as AnyAgentTool;
|
||||
}
|
||||
|
||||
@ -6,8 +6,8 @@
|
||||
"onStartup": true
|
||||
},
|
||||
"contracts": {
|
||||
"tools": ["openclaw_multi_session_artifacts"],
|
||||
"sessionScopedTools": ["openclaw_multi_session_artifacts"]
|
||||
"tools": ["openclaw_multi_session_artifacts", "openclaw_multi_session_agents"],
|
||||
"sessionScopedTools": ["openclaw_multi_session_artifacts", "openclaw_multi_session_agents"]
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
@ -28,6 +28,18 @@
|
||||
"artifactRefSigningSecret": {
|
||||
"type": "string",
|
||||
"description": "Optional stable secret used to sign artifactRef values. Defaults to an in-process secret."
|
||||
},
|
||||
"bridgeUrl": {
|
||||
"type": "string",
|
||||
"description": "XWorkmate Bridge base URL or /acp/rpc URL used by openclaw_multi_session_agents."
|
||||
},
|
||||
"bridgeToken": {
|
||||
"type": "string",
|
||||
"description": "Bearer token used when openclaw_multi_session_agents calls XWorkmate Bridge."
|
||||
},
|
||||
"bridgeTimeoutMs": {
|
||||
"type": "number",
|
||||
"description": "Default timeout for XWorkmate Bridge multi-agent calls."
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -48,6 +60,19 @@
|
||||
"label": "Artifact Ref Signing Secret",
|
||||
"help": "Optional stable secret for plugin artifact references. Leave blank for process-local refs.",
|
||||
"sensitive": true
|
||||
},
|
||||
"bridgeUrl": {
|
||||
"label": "Bridge URL",
|
||||
"help": "XWorkmate Bridge base URL or /acp/rpc URL for multi-agent orchestration."
|
||||
},
|
||||
"bridgeToken": {
|
||||
"label": "Bridge Token",
|
||||
"help": "Bearer token for XWorkmate Bridge multi-agent orchestration.",
|
||||
"sensitive": true
|
||||
},
|
||||
"bridgeTimeoutMs": {
|
||||
"label": "Bridge Timeout",
|
||||
"help": "Timeout in milliseconds for multi-agent bridge calls."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
249
src/bridgeAgents.ts
Normal file
249
src/bridgeAgents.ts
Normal file
@ -0,0 +1,249 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import {
|
||||
exportXWorkmateArtifacts,
|
||||
prepareXWorkmateArtifacts,
|
||||
type XWorkmateArtifactExport,
|
||||
} from "./exportArtifacts.js";
|
||||
|
||||
type BridgeAgentInput = {
|
||||
params: Record<string, unknown>;
|
||||
config?: unknown;
|
||||
pluginConfig?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type BridgeAgentRun = XWorkmateArtifactExport & {
|
||||
bridgeResult: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export async function runXWorkmateBridgeAgents(input: BridgeAgentInput): Promise<BridgeAgentRun> {
|
||||
const params = input.params ?? {};
|
||||
const pluginConfig = input.pluginConfig ?? {};
|
||||
const sessionKey = requiredString(params.sessionKey, "sessionKey required");
|
||||
const runId = requiredString(params.runId, "runId required");
|
||||
const taskPrompt = requiredString(params.taskPrompt, "taskPrompt required");
|
||||
const bridgeUrl = bridgeRpcUrl(pluginConfig);
|
||||
const bridgeToken = bridgeAuthToken(pluginConfig);
|
||||
if (!bridgeToken) {
|
||||
throw new Error("bridgeToken required");
|
||||
}
|
||||
|
||||
const prepared = await prepareXWorkmateArtifacts({
|
||||
params: { sessionKey, runId, workspaceDir: params.workspaceDir },
|
||||
config: input.config,
|
||||
pluginConfig,
|
||||
});
|
||||
const orchestrationMode = optionalString(params.mode) || optionalString(params.orchestrationMode) || "sequence";
|
||||
const participants = safeStringList(params.participants);
|
||||
const steps = safeSteps(params.steps, participants.length > 0);
|
||||
if (steps.length === 0 && participants.length === 0) {
|
||||
throw new Error("steps or participants required");
|
||||
}
|
||||
const routing: Record<string, unknown> = {
|
||||
orchestrationMode,
|
||||
steps,
|
||||
};
|
||||
if (participants.length > 0) {
|
||||
routing.participants = participants;
|
||||
}
|
||||
const maxTurns = positiveInteger(params.maxTurns, 0);
|
||||
if (maxTurns > 0) {
|
||||
routing.maxTurns = maxTurns;
|
||||
}
|
||||
const stopConditions = safeStringList(params.stopConditions);
|
||||
if (stopConditions.length > 0) {
|
||||
routing.stopConditions = stopConditions;
|
||||
}
|
||||
|
||||
const bridgeResult = await callBridgeRPC({
|
||||
bridgeUrl,
|
||||
bridgeToken,
|
||||
timeoutMs: positiveInteger(params.timeoutMs, positiveInteger(pluginConfig.bridgeTimeoutMs, 600_000)),
|
||||
body: {
|
||||
jsonrpc: "2.0",
|
||||
id: `openclaw-${Date.now()}`,
|
||||
method: "session.start",
|
||||
params: {
|
||||
sessionId: `openclaw:${sessionKey}`,
|
||||
threadId: sessionKey,
|
||||
taskPrompt,
|
||||
workingDirectory: prepared.artifactDirectory,
|
||||
multiAgent: true,
|
||||
mode: "multi-agent",
|
||||
routing,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await fs.mkdir(prepared.artifactDirectory, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(prepared.artifactDirectory, "multi-agent-result.json"),
|
||||
`${JSON.stringify(bridgeResult, null, 2)}\n`,
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(prepared.artifactDirectory, "multi-agent-result.md"),
|
||||
formatBridgeResultMarkdown(bridgeResult),
|
||||
);
|
||||
|
||||
const exported = await exportXWorkmateArtifacts({
|
||||
params: {
|
||||
sessionKey,
|
||||
runId,
|
||||
workspaceDir: params.workspaceDir,
|
||||
artifactScope: prepared.artifactScope,
|
||||
includeContent: false,
|
||||
},
|
||||
config: input.config,
|
||||
pluginConfig,
|
||||
});
|
||||
return { ...exported, bridgeResult };
|
||||
}
|
||||
|
||||
async function callBridgeRPC(input: {
|
||||
bridgeUrl: string;
|
||||
bridgeToken: string;
|
||||
timeoutMs: number;
|
||||
body: Record<string, unknown>;
|
||||
}): Promise<Record<string, unknown>> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), input.timeoutMs);
|
||||
try {
|
||||
const response = await fetch(input.bridgeUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: bearer(input.bridgeToken),
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify(input.body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(`bridge request failed (${response.status}): ${text.trim()}`);
|
||||
}
|
||||
const decoded = JSON.parse(text) as Record<string, unknown>;
|
||||
const error = asRecord(decoded.error);
|
||||
if (error) {
|
||||
throw new Error(optionalString(error.message) || "bridge rpc error");
|
||||
}
|
||||
const result = asRecord(decoded.result);
|
||||
if (!result) {
|
||||
throw new Error("bridge response missing result");
|
||||
}
|
||||
return result;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function bridgeRpcUrl(pluginConfig: Record<string, unknown>): string {
|
||||
const configured = optionalString(pluginConfig.bridgeUrl) || optionalString(process.env.XWORKMATE_BRIDGE_URL);
|
||||
if (!configured) {
|
||||
throw new Error("bridgeUrl required");
|
||||
}
|
||||
const trimmed = configured.replace(/\/+$/, "");
|
||||
if (trimmed.endsWith("/acp/rpc")) {
|
||||
return trimmed;
|
||||
}
|
||||
return `${trimmed}/acp/rpc`;
|
||||
}
|
||||
|
||||
function bridgeAuthToken(pluginConfig: Record<string, unknown>): string {
|
||||
return optionalString(pluginConfig.bridgeToken) || optionalString(process.env.XWORKMATE_BRIDGE_TOKEN);
|
||||
}
|
||||
|
||||
function safeSteps(raw: unknown, allowEmpty: boolean): Array<Record<string, unknown>> {
|
||||
if (!Array.isArray(raw)) {
|
||||
if (allowEmpty) {
|
||||
return [];
|
||||
}
|
||||
throw new Error("steps required");
|
||||
}
|
||||
return raw.map((item, index) => {
|
||||
const mapped = asRecord(item);
|
||||
if (!mapped) {
|
||||
throw new Error(`steps[${index}] must be an object`);
|
||||
}
|
||||
const providerId = optionalString(mapped.providerId) || optionalString(mapped.provider) || optionalString(mapped.agent);
|
||||
const prompt = optionalString(mapped.prompt) || optionalString(mapped.taskPrompt);
|
||||
if (!providerId) {
|
||||
throw new Error(`steps[${index}].providerId required`);
|
||||
}
|
||||
if (!prompt) {
|
||||
throw new Error(`steps[${index}].prompt required`);
|
||||
}
|
||||
return {
|
||||
providerId,
|
||||
prompt,
|
||||
...(optionalString(mapped.outputAs) ? { outputAs: optionalString(mapped.outputAs) } : {}),
|
||||
...(positiveInteger(mapped.timeoutMs, 0) > 0 ? { timeoutMs: positiveInteger(mapped.timeoutMs, 0) } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function safeStringList(raw: unknown): string[] {
|
||||
if (!Array.isArray(raw)) {
|
||||
return [];
|
||||
}
|
||||
return raw.map((value) => optionalString(value)).filter((value) => value.length > 0);
|
||||
}
|
||||
|
||||
function formatBridgeResultMarkdown(result: Record<string, unknown>): string {
|
||||
const lines = ["# Multi-Agent Result", ""];
|
||||
lines.push(`- Status: ${optionalString(result.status) || "unknown"}`);
|
||||
lines.push(`- Mode: ${optionalString(result.orchestrationMode) || optionalString(result.mode) || "multi-agent"}`);
|
||||
const summary = optionalString(result.summary) || optionalString(result.output) || optionalString(result.message);
|
||||
if (summary) {
|
||||
lines.push("", "## Summary", "", summary);
|
||||
}
|
||||
const steps = Array.isArray(result.steps) ? result.steps : [];
|
||||
if (steps.length > 0) {
|
||||
lines.push("", "## Steps", "");
|
||||
for (const item of steps) {
|
||||
const step = asRecord(item) ?? {};
|
||||
lines.push(
|
||||
`- ${optionalString(step.providerId) || "unknown"}: ${optionalString(step.status) || "unknown"}${
|
||||
optionalString(step.error) ? ` (${optionalString(step.error)})` : ""
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
lines.push("");
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
function bearer(token: string): string {
|
||||
return token.toLowerCase().startsWith("bearer ") ? token : `Bearer ${token}`;
|
||||
}
|
||||
|
||||
function requiredString(value: unknown, message: string): string {
|
||||
const text = optionalString(value);
|
||||
if (!text) {
|
||||
throw new Error(message);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function optionalString(value: unknown): string {
|
||||
if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") {
|
||||
return "";
|
||||
}
|
||||
const text = String(value).trim();
|
||||
return text === "<nil>" ? "" : text;
|
||||
}
|
||||
|
||||
function positiveInteger(value: unknown, fallback: number): number {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return fallback;
|
||||
}
|
||||
return Math.floor(parsed);
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
@ -7,5 +7,5 @@
|
||||
"outDir": "dist",
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["index.ts", "src/exportArtifacts.ts"]
|
||||
"include": ["index.ts", "src/exportArtifacts.ts", "src/bridgeAgents.ts"]
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user