From c6ddcfd22b7638bdd275049cd2458d73c0cd3722 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Mon, 18 May 2026 16:55:40 +0800 Subject: [PATCH] feat: add bridge-backed multi-agent OpenClaw tool --- README.md | 6 + dist/index.js | 108 ++++++++++++++++ dist/src/bridgeAgents.d.ts | 11 ++ dist/src/bridgeAgents.js | 205 ++++++++++++++++++++++++++++++ index.test.ts | 168 ++++++++++++++++++++++++- index.ts | 117 +++++++++++++++++ openclaw.plugin.json | 29 ++++- src/bridgeAgents.ts | 249 +++++++++++++++++++++++++++++++++++++ tsconfig.build.json | 2 +- 9 files changed, 890 insertions(+), 5 deletions(-) create mode 100644 dist/src/bridgeAgents.d.ts create mode 100644 dist/src/bridgeAgents.js create mode 100644 src/bridgeAgents.ts diff --git a/README.md b/README.md index ad3bdca..5877812 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/dist/index.js b/dist/index.js index c8fa87c..6cf01d5 100644 --- a/dist/index.js +++ b/dist/index.js @@ -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 }, + }; + }, + }; +} diff --git a/dist/src/bridgeAgents.d.ts b/dist/src/bridgeAgents.d.ts new file mode 100644 index 0000000..2c3a72f --- /dev/null +++ b/dist/src/bridgeAgents.d.ts @@ -0,0 +1,11 @@ +import { type XWorkmateArtifactExport } from "./exportArtifacts.js"; +type BridgeAgentInput = { + params: Record; + config?: unknown; + pluginConfig?: Record; +}; +type BridgeAgentRun = XWorkmateArtifactExport & { + bridgeResult: Record; +}; +export declare function runXWorkmateBridgeAgents(input: BridgeAgentInput): Promise; +export {}; diff --git a/dist/src/bridgeAgents.js b/dist/src/bridgeAgents.js new file mode 100644 index 0000000..5c898ad --- /dev/null +++ b/dist/src/bridgeAgents.js @@ -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 === "" ? "" : 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; +} diff --git a/index.test.ts b/index.test.ts index ac686dc..1f5c31e 100644 --- a/index.test.ts +++ b/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) => { + parameters: { properties?: Record }; + execute: (id: string, params: Record) => Promise; + }; + 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) => { + execute: (id: string, params: Record) => Promise; + }; + 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> = []; + 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; + 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((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) => { + execute: (id: string, params: Record) => 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; + 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((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({ diff --git a/index.ts b/index.ts index 91a3095..e525cd7 100644 --- a/index.ts +++ b/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) { + 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; +} diff --git a/openclaw.plugin.json b/openclaw.plugin.json index a9aeec5..291c804 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -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." } } } diff --git a/src/bridgeAgents.ts b/src/bridgeAgents.ts new file mode 100644 index 0000000..d2b3e31 --- /dev/null +++ b/src/bridgeAgents.ts @@ -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; + config?: unknown; + pluginConfig?: Record; +}; + +type BridgeAgentRun = XWorkmateArtifactExport & { + bridgeResult: Record; +}; + +export async function runXWorkmateBridgeAgents(input: BridgeAgentInput): Promise { + 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 = { + 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; +}): Promise> { + 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; + 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 { + 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 { + return optionalString(pluginConfig.bridgeToken) || optionalString(process.env.XWORKMATE_BRIDGE_TOKEN); +} + +function safeSteps(raw: unknown, allowEmpty: boolean): Array> { + 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 { + 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 === "" ? "" : 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 | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return undefined; + } + return value as Record; +} diff --git a/tsconfig.build.json b/tsconfig.build.json index 131364a..ea97252 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -7,5 +7,5 @@ "outDir": "dist", "rootDir": "." }, - "include": ["index.ts", "src/exportArtifacts.ts"] + "include": ["index.ts", "src/exportArtifacts.ts", "src/bridgeAgents.ts"] }