refactor: restructure to plugin/ + services/ layout and add per-turn bootstrap injection

- Migrate src/ → plugin/ (plugin/core/, plugin/web/, plugin/commands/)
  and src/mcp/ → services/ per OpenClaw plugin dev spec
- Add Gemini CLI backend (plugin/core/gemini/sdk-adapter.ts) with GEMINI.md
  system-prompt injection
- Inject bootstrap as stateless system prompt on every turn instead of
  first turn only: Claude via --system-prompt, Gemini via workspace/GEMINI.md;
  eliminates isFirstTurn branch, keeps skills in sync with OpenClaw snapshots
- Fix session-map-store defensive parsing (sessions ?? []) to handle bare {}
  reset files without crashing on .find()
- Add docs/TEST_FLOW.md with E2E test scenarios and expected outcomes
- Add docs/claude/BRIDGE_MODEL_FINDINGS.md with contractor-probe results

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
h z
2026-04-11 21:21:32 +01:00
parent eee62efbf1
commit 07a0f06e2e
30 changed files with 1239 additions and 172 deletions

117
plugin/web/bootstrap.ts Normal file
View File

@@ -0,0 +1,117 @@
import fs from "node:fs";
import path from "node:path";
export type BootstrapInput = {
agentId: string;
openclawSessionKey: string;
workspace: string;
/** Skills XML block extracted from the OpenClaw system prompt, if any */
skillsBlock?: string;
/** Subset of OpenClaw context files present in the workspace (for persona/identity) */
workspaceContextFiles?: string[];
};
/** Read a workspace file. Returns the content, or null if missing/unreadable. */
function readWorkspaceFile(workspace: string, filename: string): string | null {
try {
const content = fs.readFileSync(path.join(workspace, filename), "utf8").trim();
return content || null;
} catch {
return null;
}
}
/**
* Build the one-time bootstrap message injected at the start of a new session.
* Workspace context files (SOUL.md, IDENTITY.md, MEMORY.md, USER.md) are read
* here and embedded inline — the agent must not need to fetch them separately.
* Must NOT be re-injected on every turn.
*/
export function buildBootstrap(input: BootstrapInput): string {
const contextFiles = input.workspaceContextFiles ?? [];
const lines = [
`You are operating as a contractor agent inside OpenClaw.`,
``,
`## Context`,
`- Agent ID: ${input.agentId}`,
`- Session key: ${input.openclawSessionKey}`,
`- Workspace: ${input.workspace}`,
``,
`## Role`,
`You receive tasks from OpenClaw users and complete them using your tools.`,
`You do not need to manage your own session context — OpenClaw handles session routing.`,
`Your responses go directly back to the user through OpenClaw.`,
``,
`## Guidelines`,
`- Work in the specified workspace directory.`,
`- Be concise and action-oriented. Use tools to accomplish tasks rather than describing what you would do.`,
`- Each message you receive contains the latest user request. Previous context is in your session memory.`,
`- If a task is unclear, ask one focused clarifying question.`,
];
// ── Persona (SOUL.md) ─────────────────────────────────────────────────────
// Injected directly so the agent embodies the persona immediately without
// needing to read files first.
if (contextFiles.includes("SOUL.md")) {
const soul = readWorkspaceFile(input.workspace, "SOUL.md");
if (soul) {
lines.push(``, `## Soul`, soul);
}
}
// ── Identity (IDENTITY.md) ────────────────────────────────────────────────
if (contextFiles.includes("IDENTITY.md")) {
const identity = readWorkspaceFile(input.workspace, "IDENTITY.md");
if (identity) {
lines.push(``, `## Identity`, identity);
// If the file still looks like the default template, encourage the agent
// to fill it in.
if (identity.includes("Fill this in during your first conversation")) {
lines.push(
``,
`_IDENTITY.md is still a template. Pick a name, creature type, and vibe for yourself`,
`and update the file at ${input.workspace}/IDENTITY.md._`,
);
}
}
}
// ── User profile (USER.md) ────────────────────────────────────────────────
if (contextFiles.includes("USER.md")) {
const user = readWorkspaceFile(input.workspace, "USER.md");
if (user) {
lines.push(``, `## User Profile`, user);
}
}
// ── Memory index (MEMORY.md) ──────────────────────────────────────────────
if (contextFiles.includes("MEMORY.md") || contextFiles.some(f => f.startsWith("memory/"))) {
const memoryIndex = readWorkspaceFile(input.workspace, "MEMORY.md");
lines.push(``, `## Memory`);
if (memoryIndex) {
lines.push(memoryIndex);
}
lines.push(
``,
`Memory files live in ${input.workspace}/memory/. Use the Read tool to fetch individual`,
`memories and write new ones to ${input.workspace}/memory/<topic>.md.`,
);
}
// ── Skills ────────────────────────────────────────────────────────────────
if (input.skillsBlock) {
lines.push(
``,
`## Skills`,
`The following skills are available. When a task matches a skill's description:`,
`1. Read the skill's SKILL.md using the Read tool (the <location> field is the absolute path).`,
`2. Follow the instructions in SKILL.md. Replace \`{baseDir}\` with the directory containing SKILL.md.`,
`3. Run scripts using the Bash tool, NOT the \`exec\` tool (you have Bash, not exec).`,
``,
input.skillsBlock,
);
}
return lines.join("\n");
}

View File

@@ -0,0 +1,95 @@
import fs from "node:fs";
import path from "node:path";
import type { BridgeInboundRequest, OpenAIMessage } from "../core/types/model.js";
function messageText(m: OpenAIMessage): string {
if (typeof m.content === "string") return m.content;
return m.content
.filter((c) => c.type === "text")
.map((c) => c.text ?? "")
.join("");
}
/**
* Extract the latest user message from the OpenClaw request.
*
* OpenClaw accumulates all user messages and sends the full array every turn,
* but assistant messages may be missing if the previous response wasn't streamed
* correctly. The bridge model only needs the latest user message to forward to
* Claude — Claude maintains its own session context.
*
* OpenClaw prefixes user messages with a timestamp: "[Day YYYY-MM-DD HH:MM TZ] text"
* We strip the timestamp prefix before forwarding.
*/
export function extractLatestUserMessage(req: BridgeInboundRequest): string {
const userMessages = req.messages.filter((m) => m.role === "user");
if (userMessages.length === 0) return "";
const raw = messageText(userMessages[userMessages.length - 1]);
// Strip OpenClaw timestamp prefix: "[Sat 2026-04-11 08:32 GMT+1] "
return raw.replace(/^\[[^\]]+\]\s*/, "").trim();
}
export type RequestContext = {
agentId: string;
workspace: string;
/** Raw <available_skills>...</available_skills> XML block from the system prompt */
skillsBlock: string;
/** OpenClaw context files present in the workspace (SOUL.md, IDENTITY.md, etc.) */
workspaceContextFiles: string[];
};
/**
* Parse agent ID and workspace path from the OpenClaw system prompt.
*
* OpenClaw does NOT send agent ID / session key as HTTP headers — it's embedded
* in the system prompt as a "## Runtime" line:
* Runtime: agent=contractor-e2e | host=... | repo=/tmp/contractor-e2e-workspace | ...
*
* We parse this line to extract `agent` (agent ID) and `repo` (workspace path).
*/
export function extractRequestContext(req: BridgeInboundRequest): RequestContext {
const systemMsg = req.messages.find((m) => m.role === "system");
if (!systemMsg) return { agentId: "", workspace: "", skillsBlock: "", workspaceContextFiles: [] };
const text = messageText(systemMsg);
// Match "Runtime: agent=<id> | ... | repo=<path> | ..."
const runtimeMatch = text.match(/Runtime:\s*([^\n]+)/);
if (!runtimeMatch) return { agentId: "", workspace: "", skillsBlock: "", workspaceContextFiles: [] };
const runtimeLine = runtimeMatch[1];
const agentMatch = runtimeLine.match(/\bagent=([^|\s]+)/);
const repoMatch = runtimeLine.match(/\brepo=([^|\s]+)/);
// Extract <available_skills>...</available_skills> XML block.
// Expand leading "~/" in <location> paths to the actual home dir so Claude doesn't
// try /root/.openclaw/... (which fails with EACCES).
const skillsMatch = text.match(/<available_skills>[\s\S]*?<\/available_skills>/);
const home = process.env.HOME ?? "/root";
const skillsBlock = skillsMatch
? skillsMatch[0].replace(/~\//g, `${home}/`)
: "";
// Detect which OpenClaw context files are present in the workspace.
// These tell us what persona/memory files to surface to Claude.
const workspace = repoMatch?.[1] ?? "";
const CONTEXT_FILES = ["SOUL.md", "IDENTITY.md", "MEMORY.md", "AGENTS.md", "USER.md"];
const workspaceContextFiles: string[] = [];
if (workspace) {
for (const f of CONTEXT_FILES) {
if (fs.existsSync(path.join(workspace, f))) workspaceContextFiles.push(f);
}
// Also check for memory/ directory
if (fs.existsSync(path.join(workspace, "memory"))) {
workspaceContextFiles.push("memory/");
}
}
return {
agentId: agentMatch?.[1] ?? "",
workspace,
skillsBlock,
workspaceContextFiles,
};
}

View File

@@ -0,0 +1,162 @@
import fs from "node:fs";
import path from "node:path";
/**
* Implementations of OpenClaw memory tools for the contractor bridge.
*
* OpenClaw's real memory_search uses vector/semantic search; we approximate
* with keyword matching across workspace memory files. memory_get is a
* line-range file read and is implemented exactly.
*
* These handlers are registered in globalThis._contractorToolHandlers so the
* MCP proxy can call them via POST /mcp/execute.
*/
type MemorySearchArgs = {
query?: string;
maxResults?: number;
minScore?: number;
corpus?: "memory" | "wiki" | "all";
};
type MemoryGetArgs = {
path?: string;
from?: number;
lines?: number;
corpus?: "memory" | "wiki" | "all";
};
/** Collect all memory files in a workspace. */
function collectMemoryFiles(workspace: string): string[] {
const files: string[] = [];
const memoryMd = path.join(workspace, "MEMORY.md");
if (fs.existsSync(memoryMd)) files.push(memoryMd);
const memoryDir = path.join(workspace, "memory");
if (fs.existsSync(memoryDir)) {
for (const f of fs.readdirSync(memoryDir)) {
if (f.endsWith(".md")) files.push(path.join(memoryDir, f));
}
}
return files;
}
/** Score a file's relevance to a query by counting keyword occurrences. */
function scoreFile(content: string, query: string): { score: number; snippets: string[] } {
const words = query.toLowerCase().split(/\s+/).filter(Boolean);
const lines = content.split("\n");
const snippets: string[] = [];
let score = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i].toLowerCase();
const matches = words.filter((w) => line.includes(w)).length;
if (matches > 0) {
score += matches;
// Include surrounding context (1 line before/after)
const start = Math.max(0, i - 1);
const end = Math.min(lines.length - 1, i + 1);
const snippet = lines.slice(start, end + 1).join("\n").trim();
if (snippet && !snippets.includes(snippet)) {
snippets.push(snippet);
}
}
}
return { score, snippets };
}
export function makeMemorySearchHandler(workspace: string) {
return async (args: unknown): Promise<string> => {
const { query = "", maxResults = 5, corpus = "memory" } = args as MemorySearchArgs;
if (corpus === "wiki") {
return JSON.stringify({ disabled: true, reason: "wiki corpus not available in contractor bridge" });
}
const files = collectMemoryFiles(workspace);
if (files.length === 0) {
return JSON.stringify({
results: [],
note: "No memory files found in workspace. Memory is empty.",
});
}
const results: Array<{ file: string; score: number; snippets: string[] }> = [];
for (const filePath of files) {
try {
const content = fs.readFileSync(filePath, "utf8");
const { score, snippets } = scoreFile(content, query);
if (score > 0) {
results.push({
file: path.relative(workspace, filePath),
score,
snippets,
});
}
} catch {
// skip unreadable files
}
}
results.sort((a, b) => b.score - a.score);
const top = results.slice(0, maxResults);
if (top.length === 0) {
return JSON.stringify({
results: [],
note: `No memory matches found for query: "${query}"`,
});
}
return JSON.stringify({ results: top });
};
}
export function makeMemoryGetHandler(workspace: string) {
return async (args: unknown): Promise<string> => {
const { path: filePath = "", from, lines, corpus = "memory" } = args as MemoryGetArgs;
if (corpus === "wiki") {
return "[memory_get] wiki corpus not available in contractor bridge";
}
if (!filePath) return "[memory_get] path is required";
// Resolve path relative to workspace (prevent path traversal)
const resolved = path.resolve(workspace, filePath);
if (!resolved.startsWith(workspace + path.sep) && resolved !== workspace) {
return `[memory_get] path outside workspace: ${filePath}`;
}
if (!fs.existsSync(resolved)) {
return `[memory_get] file not found: ${filePath}`;
}
try {
const content = fs.readFileSync(resolved, "utf8");
const allLines = content.split("\n");
const startLine = from != null ? Math.max(0, from - 1) : 0; // convert 1-based to 0-based
const endLine = lines != null ? startLine + lines : allLines.length;
return allLines.slice(startLine, endLine).join("\n");
} catch (err) {
return `[memory_get] error reading file: ${String(err)}`;
}
};
}
/**
* Register memory tool handlers into the shared globalThis tool registry.
* Called by the bridge server on startup.
*/
export function registerMemoryHandlers(workspace: string): void {
const _G = globalThis as Record<string, unknown>;
if (!(_G["_contractorToolHandlers"] instanceof Map)) {
_G["_contractorToolHandlers"] = new Map<string, (args: unknown) => Promise<string>>();
}
const registry = _G["_contractorToolHandlers"] as Map<string, (args: unknown) => Promise<string>>;
registry.set("memory_search", makeMemorySearchHandler(workspace));
registry.set("memory_get", makeMemoryGetHandler(workspace));
}

384
plugin/web/server.ts Normal file
View File

@@ -0,0 +1,384 @@
import http from "node:http";
import { randomUUID } from "node:crypto";
import type { BridgeInboundRequest } from "../core/types/model.js";
import { extractLatestUserMessage, extractRequestContext } from "./input-filter.js";
import { buildBootstrap } from "./bootstrap.js";
import { dispatchToClaude } from "../core/claude/sdk-adapter.js";
import { dispatchToGemini } from "../core/gemini/sdk-adapter.js";
import { getGlobalPluginRegistry } from "openclaw/plugin-sdk/plugin-runtime";
import {
getSession,
putSession,
markOrphaned,
} from "../core/contractor/session-map-store.js";
export type BridgeServerConfig = {
port: number;
apiKey: string;
permissionMode: string;
/** Fallback: resolve workspace from agent id if not parseable from system prompt */
resolveAgent?: (
agentId: string,
sessionKey: string,
) => { workspace: string } | null;
logger: { info: (msg: string) => void; warn: (msg: string) => void };
};
function sendJson(res: http.ServerResponse, status: number, body: unknown): void {
const json = JSON.stringify(body);
res.writeHead(status, {
"Content-Type": "application/json; charset=utf-8",
"Content-Length": Buffer.byteLength(json),
});
res.end(json);
}
/** Write a single SSE data line. */
function sseWrite(res: http.ServerResponse, data: string): void {
res.write(`data: ${data}\n\n`);
}
/** Build an OpenAI streaming chunk for a text delta. */
function buildChunk(id: string, text: string): string {
return JSON.stringify({
id,
object: "chat.completion.chunk",
created: Math.floor(Date.now() / 1000),
model: "contractor-claude-bridge",
choices: [{ index: 0, delta: { content: text }, finish_reason: null }],
});
}
/** Build the final stop chunk. */
function buildStopChunk(id: string): string {
return JSON.stringify({
id,
object: "chat.completion.chunk",
created: Math.floor(Date.now() / 1000),
model: "contractor-claude-bridge",
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
});
}
function parseBodyRaw(req: http.IncomingMessage): Promise<unknown> {
return new Promise((resolve, reject) => {
let body = "";
req.on("data", (chunk: Buffer) => {
body += chunk.toString("utf8");
if (body.length > 4_000_000) req.destroy(new Error("body too large"));
});
req.on("end", () => {
try {
resolve(JSON.parse(body));
} catch (e) {
reject(e);
}
});
req.on("error", reject);
});
}
function parseBody(req: http.IncomingMessage): Promise<BridgeInboundRequest> {
return parseBodyRaw(req) as Promise<BridgeInboundRequest>;
}
const _G = globalThis as Record<string, unknown>;
export function createBridgeServer(config: BridgeServerConfig): http.Server {
const { port, apiKey, permissionMode, resolveAgent, logger } = config;
async function handleChatCompletions(
req: http.IncomingMessage,
res: http.ServerResponse,
): Promise<void> {
let body: BridgeInboundRequest;
try {
body = await parseBody(req);
} catch {
sendJson(res, 400, { error: "invalid_json" });
return;
}
// Extract agent ID and workspace from the system prompt's Runtime line.
// OpenClaw does NOT send agent/session info as HTTP headers — it's in the system prompt.
const { agentId: parsedAgentId, workspace: parsedWorkspace, skillsBlock, workspaceContextFiles } = extractRequestContext(body);
const latestMessage = extractLatestUserMessage(body);
if (!latestMessage) {
sendJson(res, 400, { error: "no user message found" });
return;
}
// Use agentId as session key — one persistent Claude session per agent (v1).
const agentId = parsedAgentId;
const sessionKey = agentId; // stable per-agent key
logger.info(
`[contractor-bridge] turn agentId=${agentId} workspace=${parsedWorkspace} msg=${latestMessage.substring(0, 80)}`,
);
// Resolve workspace: prefer what we parsed from the system prompt (most accurate);
// fall back to openclaw.json lookup for validation.
let workspace = parsedWorkspace;
if (!workspace && agentId) {
const agentMeta = resolveAgent?.(agentId, sessionKey);
if (agentMeta) workspace = agentMeta.workspace;
}
if (!workspace) {
logger.warn(`[contractor-bridge] could not resolve workspace agentId=${agentId}`);
workspace = "/tmp";
}
// Detect backend from body.model: "contractor-gemini-bridge" → Gemini, else → Claude
const isGemini = typeof body.model === "string" && body.model.includes("gemini");
// Look up existing session (shared structure for both Claude and Gemini)
let existingEntry = sessionKey ? getSession(workspace, sessionKey) : null;
let resumeSessionId = existingEntry?.state === "active" ? existingEntry.claudeSessionId : null;
// Bootstrap is passed as the system prompt on every turn (stateless — not persisted in session files).
// For Claude: --system-prompt fully replaces any prior system prompt each invocation.
// For Gemini: written to workspace/GEMINI.md, read dynamically by Gemini CLI each turn.
// This keeps persona and skills current without needing to track first-turn state.
const systemPrompt = buildBootstrap({
agentId,
openclawSessionKey: sessionKey,
workspace,
skillsBlock: skillsBlock || undefined,
workspaceContextFiles,
});
// Start SSE response
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"Transfer-Encoding": "chunked",
});
const completionId = `chatcmpl-bridge-${randomUUID().slice(0, 8)}`;
let newSessionId = "";
let hasError = false;
const openclawTools = body.tools ?? [];
try {
const dispatchIter = isGemini
? dispatchToGemini({
prompt: latestMessage,
systemPrompt,
workspace,
agentId,
resumeSessionId: resumeSessionId ?? undefined,
openclawTools,
bridgePort: port,
bridgeApiKey: apiKey,
})
: dispatchToClaude({
prompt: latestMessage,
systemPrompt,
workspace,
agentId,
resumeSessionId: resumeSessionId ?? undefined,
permissionMode,
openclawTools,
bridgePort: port,
bridgeApiKey: apiKey,
});
for await (const event of dispatchIter) {
if (event.type === "text") {
sseWrite(res, buildChunk(completionId, event.text));
} else if (event.type === "done") {
newSessionId = event.sessionId;
} else if (event.type === "error") {
logger.warn(`[contractor-bridge] ${isGemini ? "gemini" : "claude"} error: ${event.message}`);
hasError = true;
sseWrite(res, buildChunk(completionId, `[contractor-bridge error: ${event.message}]`));
}
}
} catch (err) {
logger.warn(`[contractor-bridge] dispatch error: ${String(err)}`);
hasError = true;
sseWrite(res, buildChunk(completionId, `[contractor-bridge dispatch failed: ${String(err)}]`));
}
sseWrite(res, buildStopChunk(completionId));
sseWrite(res, "[DONE]");
res.end();
// Persist session mapping (shared for both Claude and Gemini)
if (newSessionId && sessionKey && !hasError) {
const now = new Date().toISOString();
putSession(workspace, {
openclawSessionKey: sessionKey,
agentId,
contractor: isGemini ? "gemini" : "claude",
claudeSessionId: newSessionId,
workspace,
createdAt: existingEntry?.createdAt ?? now,
lastActivityAt: now,
state: "active",
});
logger.info(
`[contractor-bridge] session mapped sessionKey=${sessionKey} contractor=${isGemini ? "gemini" : "claude"} sessionId=${newSessionId}`,
);
} else if (hasError && sessionKey && existingEntry) {
markOrphaned(workspace, sessionKey);
}
}
const server = http.createServer(async (req, res) => {
const url = req.url ?? "/";
const method = req.method ?? "GET";
// Auth check
if (apiKey) {
const auth = req.headers.authorization ?? "";
if (auth !== `Bearer ${apiKey}`) {
sendJson(res, 401, { error: "unauthorized" });
return;
}
}
if (method === "GET" && url === "/health") {
sendJson(res, 200, { ok: true, service: "contractor-bridge" });
return;
}
if (method === "GET" && url === "/v1/models") {
sendJson(res, 200, {
object: "list",
data: [
{
id: "contractor-claude-bridge",
object: "model",
created: Math.floor(Date.now() / 1000),
owned_by: "contractor-agent",
},
{
id: "contractor-gemini-bridge",
object: "model",
created: Math.floor(Date.now() / 1000),
owned_by: "contractor-agent",
},
],
});
return;
}
if (method === "POST" && url === "/v1/chat/completions") {
try {
await handleChatCompletions(req, res);
} catch (err) {
logger.warn(`[contractor-bridge] unhandled error: ${String(err)}`);
if (!res.headersSent) {
sendJson(res, 500, { error: "internal server error" });
}
}
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 services/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<string, unknown>; workspace?: string; agentId?: string };
try {
body = await parseBodyRaw(req) as { tool?: string; args?: Record<string, unknown>; workspace?: string; agentId?: string };
} catch {
sendJson(res, 400, { error: "invalid_json" });
return;
}
const toolName = body.tool ?? "";
const toolArgs = body.args ?? {};
const execWorkspace = body.workspace ?? "";
const execAgentId = body.agentId ?? "";
logger.info(`[contractor-bridge] mcp/execute tool=${toolName} workspace=${execWorkspace} agentId=${execAgentId}`);
// 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 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:<agentId>:<rest>" so that
// parseAgentSessionKey() can extract the agentId for memory tool context resolution.
const liveConfig = (_G["_contractorOpenClawConfig"] ?? null) as Record<string, unknown> | null;
// Construct a canonical session key so memory tools can resolve the agentId from it.
// Format: "agent:<agentId>: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;
}
sendJson(res, 404, { error: "not_found" });
});
server.listen(port, "127.0.0.1", () => {
logger.info(`[contractor-bridge] sidecar listening on 127.0.0.1:${port}`);
});
return server;
}