diff --git a/src/bridge/input-filter.ts b/src/bridge/input-filter.ts index 0020274..f9875ce 100644 --- a/src/bridge/input-filter.ts +++ b/src/bridge/input-filter.ts @@ -50,13 +50,13 @@ export type RequestContext = { */ export function extractRequestContext(req: BridgeInboundRequest): RequestContext { const systemMsg = req.messages.find((m) => m.role === "system"); - if (!systemMsg) return { agentId: "", workspace: "", skillsBlock: "" }; + if (!systemMsg) return { agentId: "", workspace: "", skillsBlock: "", workspaceContextFiles: [] }; const text = messageText(systemMsg); // Match "Runtime: agent= | ... | repo= | ..." const runtimeMatch = text.match(/Runtime:\s*([^\n]+)/); - if (!runtimeMatch) return { agentId: "", workspace: "", skillsBlock: "" }; + if (!runtimeMatch) return { agentId: "", workspace: "", skillsBlock: "", workspaceContextFiles: [] }; const runtimeLine = runtimeMatch[1]; const agentMatch = runtimeLine.match(/\bagent=([^|\s]+)/); diff --git a/src/bridge/server.ts b/src/bridge/server.ts index 7139b10..23aa7e2 100644 --- a/src/bridge/server.ts +++ b/src/bridge/server.ts @@ -4,12 +4,11 @@ import type { BridgeInboundRequest } from "../types/model.js"; import { extractLatestUserMessage, extractRequestContext } from "./input-filter.js"; import { buildBootstrap } from "./bootstrap.js"; import { dispatchToClaude } from "../claude/sdk-adapter.js"; -import { makeMemorySearchHandler, makeMemoryGetHandler } from "./memory-handlers.js"; +import { getGlobalPluginRegistry } from "openclaw/plugin-sdk/plugin-runtime"; import { getSession, putSession, markOrphaned, - updateActivity, } from "../contractor/session-map-store.js"; export type BridgeServerConfig = { @@ -21,11 +20,6 @@ export type BridgeServerConfig = { agentId: string, sessionKey: string, ) => { workspace: string } | null; - /** - * globalThis key that holds a Map Promise>. - * Plugins register their tool implementations here so the MCP proxy can call them. - */ - toolRegistryKey?: string; logger: { info: (msg: string) => void; warn: (msg: string) => void }; }; @@ -88,8 +82,10 @@ function parseBody(req: http.IncomingMessage): Promise { } +const _G = globalThis as Record; + export function createBridgeServer(config: BridgeServerConfig): http.Server { - const { port, apiKey, permissionMode, resolveAgent, toolRegistryKey = "_contractorToolHandlers", logger } = config; + const { port, apiKey, permissionMode, resolveAgent, logger } = config; async function handleChatCompletions( req: http.IncomingMessage, @@ -161,6 +157,7 @@ export function createBridgeServer(config: BridgeServerConfig): http.Server { for await (const event of dispatchToClaude({ prompt, workspace, + agentId, resumeSessionId: claudeSessionId ?? undefined, permissionMode, openclawTools, // pass every turn — MCP server exits with the claude process @@ -253,12 +250,28 @@ export function createBridgeServer(config: BridgeServerConfig): http.Server { return; } + // Debug: list registered tools in the global plugin registry + if (method === "GET" && url === "/debug/tools") { + const pluginRegistry = getGlobalPluginRegistry(); + const liveConfig = _G["_contractorOpenClawConfig"] ?? null; + if (!pluginRegistry) { + sendJson(res, 200, { error: "registry not available" }); + } else { + sendJson(res, 200, { + configAvailable: liveConfig !== null, + tools: pluginRegistry.tools.map((t) => ({ names: t.names, pluginId: t.pluginId })), + }); + } + return; + } + // MCP proxy tool execution callback. // Called by openclaw-mcp-server.mjs when Claude Code invokes an MCP tool. + // Relays the call to OpenClaw's actual tool implementation via the global plugin registry. if (method === "POST" && url === "/mcp/execute") { - let body: { tool?: string; args?: Record; workspace?: string }; + let body: { tool?: string; args?: Record; workspace?: string; agentId?: string }; try { - body = await parseBodyRaw(req) as { tool?: string; args?: Record; workspace?: string }; + body = await parseBodyRaw(req) as { tool?: string; args?: Record; workspace?: string; agentId?: string }; } catch { sendJson(res, 400, { error: "invalid_json" }); return; @@ -266,42 +279,65 @@ export function createBridgeServer(config: BridgeServerConfig): http.Server { const toolName = body.tool ?? ""; const toolArgs = body.args ?? {}; const execWorkspace = body.workspace ?? ""; - logger.info(`[contractor-bridge] mcp/execute tool=${toolName} workspace=${execWorkspace}`); + const execAgentId = body.agentId ?? ""; + logger.info(`[contractor-bridge] mcp/execute tool=${toolName} workspace=${execWorkspace} agentId=${execAgentId}`); - // Built-in workspace-aware tool implementations - if (execWorkspace) { - if (toolName === "memory_search") { - try { - const result = await makeMemorySearchHandler(execWorkspace)(toolArgs); - sendJson(res, 200, { result }); - } catch (err) { - sendJson(res, 200, { error: `memory_search failed: ${String(err)}` }); - } - return; - } - if (toolName === "memory_get") { - try { - const result = await makeMemoryGetHandler(execWorkspace)(toolArgs); - sendJson(res, 200, { result }); - } catch (err) { - sendJson(res, 200, { error: `memory_get failed: ${String(err)}` }); - } - return; - } - } - - // Plugin-registered tool handlers (globalThis registry) - const registry = (globalThis as Record)[toolRegistryKey]; - const handler = registry instanceof Map ? (registry as Map Promise>).get(toolName) : undefined; - - if (!handler) { - sendJson(res, 200, { error: `Tool '${toolName}' not found in contractor tool registry` }); - return; - } + // Relay to OpenClaw's actual tool implementation via the global plugin registry. + // This avoids reimplementing tool logic in the bridge — we call the real tool factory. try { - const result = await handler(toolArgs); - sendJson(res, 200, { result }); + const pluginRegistry = getGlobalPluginRegistry(); + if (!pluginRegistry) { + sendJson(res, 200, { error: `[mcp/execute] plugin registry not available (tool=${toolName})` }); + return; + } + + // Find the tool registration by name + const toolReg = pluginRegistry.tools.find((t) => t.names.includes(toolName)); + if (!toolReg) { + sendJson(res, 200, { error: `Tool '${toolName}' not registered in OpenClaw plugin registry` }); + return; + } + + // Build tool execution context. + // config is required by memory tools (resolveMemoryToolContext checks it). + // Read from globalThis where index.ts stores api.config on every registration. + // sessionKey must be in OpenClaw's canonical format "agent::" so that + // parseAgentSessionKey() can extract the agentId for memory tool context resolution. + const liveConfig = (_G["_contractorOpenClawConfig"] ?? null) as Record | null; + // Construct a canonical session key so memory tools can resolve the agentId from it. + // Format: "agent::direct:bridge" + const canonicalSessionKey = execAgentId ? `agent:${execAgentId}:direct:bridge` : undefined; + const toolCtx = { + config: liveConfig ?? undefined, + workspaceDir: execWorkspace || undefined, + agentDir: execWorkspace || undefined, + agentId: execAgentId || undefined, + sessionKey: canonicalSessionKey, + }; + + // Instantiate the tool via its factory + const toolOrTools = toolReg.factory(toolCtx); + const toolInstance = Array.isArray(toolOrTools) + ? toolOrTools.find((t) => (t as { name?: string }).name === toolName) + : toolOrTools; + + if (!toolInstance || typeof (toolInstance as { execute?: unknown }).execute !== "function") { + sendJson(res, 200, { error: `Tool '${toolName}' factory returned no executable tool` }); + return; + } + + const execFn = (toolInstance as { execute: (id: string, args: unknown) => Promise<{ content?: Array<{ type: string; text?: string }> }> }).execute; + const toolResult = await execFn(randomUUID(), toolArgs); + + // Extract text content from AgentToolResult + const text = (toolResult.content ?? []) + .filter((c) => c.type === "text" && c.text) + .map((c) => c.text as string) + .join("\n"); + + sendJson(res, 200, { result: text || "(no result)" }); } catch (err) { + logger.warn(`[contractor-bridge] mcp/execute error tool=${toolName}: ${String(err)}`); sendJson(res, 200, { error: `Tool execution failed: ${String(err)}` }); } return; diff --git a/src/claude/sdk-adapter.ts b/src/claude/sdk-adapter.ts index 4daf746..4e1f6a0 100644 --- a/src/claude/sdk-adapter.ts +++ b/src/claude/sdk-adapter.ts @@ -18,6 +18,7 @@ export type OpenAITool = { export type ClaudeDispatchOptions = { prompt: string; workspace: string; + agentId?: string; resumeSessionId?: string; permissionMode?: string; /** OpenClaw tool definitions to expose to Claude as MCP tools */ @@ -43,6 +44,7 @@ function setupMcpConfig( bridgePort: number, bridgeApiKey: string, workspace: string, + agentId: string, ): string | null { if (!tools.length) return null; if (!fs.existsSync(MCP_SERVER_SCRIPT)) return null; @@ -65,6 +67,7 @@ function setupMcpConfig( BRIDGE_EXECUTE_URL: `http://127.0.0.1:${bridgePort}/mcp/execute`, BRIDGE_API_KEY: bridgeApiKey, WORKSPACE: workspace, + AGENT_ID: agentId, }, }, }, @@ -87,6 +90,7 @@ export async function* dispatchToClaude( const { prompt, workspace, + agentId = "", resumeSessionId, permissionMode = "bypassPermissions", openclawTools, @@ -115,7 +119,7 @@ export async function* dispatchToClaude( // Put --mcp-config after the prompt so its variadic doesn't consume the prompt. let mcpConfigPath: string | null = null; if (openclawTools?.length) { - mcpConfigPath = setupMcpConfig(openclawTools, bridgePort, bridgeApiKey, workspace); + mcpConfigPath = setupMcpConfig(openclawTools, bridgePort, bridgeApiKey, workspace, agentId); if (mcpConfigPath) { args.push("--mcp-config", mcpConfigPath); } diff --git a/src/index.ts b/src/index.ts index cfb95fc..cedc96d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,6 +24,8 @@ function isPortFree(port: number): Promise { const _G = globalThis as Record; const LIFECYCLE_KEY = "_contractorAgentLifecycleRegistered"; const SERVER_KEY = "_contractorAgentBridgeServer"; +/** Key for the live OpenClaw config accessor (getter fn) shared via globalThis. */ +const OPENCLAW_CONFIG_KEY = "_contractorOpenClawConfig"; // ── Plugin entry ───────────────────────────────────────────────────────────── @@ -59,6 +61,10 @@ export default { // Guard with globalThis flag AND a port probe to handle the case where the // gateway is already running the server while a CLI subprocess is starting up. // (See LESSONS_LEARNED.md item 7 — lock file / port probe pattern) + // Always update the config accessor so hot-reloads get fresh config. + // server.ts reads this via globalThis to build tool execution context. + _G[OPENCLAW_CONFIG_KEY] = api.config; + if (!_G[LIFECYCLE_KEY]) { _G[LIFECYCLE_KEY] = true; diff --git a/src/mcp/openclaw-mcp-server.mjs b/src/mcp/openclaw-mcp-server.mjs index 0b38a60..b24a747 100644 --- a/src/mcp/openclaw-mcp-server.mjs +++ b/src/mcp/openclaw-mcp-server.mjs @@ -46,6 +46,7 @@ async function executeTool(name, args) { const url = process.env.BRIDGE_EXECUTE_URL; const apiKey = process.env.BRIDGE_API_KEY ?? ""; const workspace = process.env.WORKSPACE ?? ""; + const agentId = process.env.AGENT_ID ?? ""; if (!url) return `[mcp-proxy] BRIDGE_EXECUTE_URL not configured`; try { @@ -55,8 +56,8 @@ async function executeTool(name, args) { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}`, }, - // Include workspace so bridge can resolve workspace-relative operations (e.g. memory files) - body: JSON.stringify({ tool: name, args, workspace }), + // Include workspace and agentId so bridge can build the correct tool execution context + body: JSON.stringify({ tool: name, args, workspace, agentId }), }); const data = await resp.json(); if (data.error) return `[tool error] ${data.error}`;