/** * Remote Agent Tool — clawlink_call_remote_agent * * Calls a remote Agent via TIM C2C messaging and streams results back. * Accepts both OpenClaw execute signatures seen in the wild: * modern: tool.execute(toolCallId, params, signal, onUpdate) * legacy: tool.execute(toolCallId, params, onUpdate, ctx, signal) * * Flow: * 1. Validate runtime is connected * 2. Generate requestId (crypto.randomUUID) * 3. Send C2C request to remote agent * 4. Wait for C2C reply (progress → onUpdate, result → return) * * @see docs/products/orchestrator/specs/T2-local-tool.md * @see docs/products/orchestrator/clawlink-teams-architecture.md §3.1 * @see docs/reference/openclaw/v2026.3.23/dist/pi-embedded-CswW9luA.js L169823 */ import { registry } from '../runtime/registry.js'; import { logger } from '../util/logger.js'; import { getGatewayBaseUrl } from '../proxy/auth-proxy.js'; import { toolResult, type ToolDescriptor, type ToolParams, type ToolResult, type ToolUpdateCallback, } from './types.js'; import { sendC2CCustomMessage, waitForC2CReply } from '../tim/c2c.js'; import fs from 'node:fs'; import nodePath from 'node:path'; // ── Helpers ── function fail(error: string): ToolResult { return toolResult(JSON.stringify({ success: false, error })); } /** * Generate request UUID. * Aligned with OpenClaw subagent pattern (crypto.randomUUID). */ function generateRequestId(): string { return crypto.randomUUID(); } function isAbortSignalLike(value: unknown): value is AbortSignal { return Boolean( value && typeof value === 'object' && 'aborted' in value && typeof (value as { addEventListener?: unknown }).addEventListener === 'function', ); } function getObjectCallback(value: unknown, key: string): ToolUpdateCallback | undefined { if (!value || typeof value !== 'object') return undefined; const candidate = (value as Record)[key]; return typeof candidate === 'function' ? candidate as ToolUpdateCallback : undefined; } function getObjectSignal(value: unknown): AbortSignal | undefined { if (!value || typeof value !== 'object') return undefined; const candidate = (value as Record).signal; return isAbortSignalLike(candidate) ? candidate : undefined; } function describeExecuteArg(value: unknown): string { if (typeof value === 'function') return 'function'; if (isAbortSignalLike(value)) return 'AbortSignal'; if (value === null) return 'null'; if (typeof value !== 'object') return typeof value; const ctor = (value as { constructor?: { name?: string } }).constructor?.name; const keys = Object.keys(value as Record).slice(0, 8).join(','); return `${ctor || 'object'}{${keys}}`; } function normalizeRemoteToolExecuteArgs( signalOrUpdate?: AbortSignal | ToolUpdateCallback, onUpdateOrContext?: ToolUpdateCallback | unknown, legacySignal?: AbortSignal, ): { signal?: AbortSignal; onUpdate?: ToolUpdateCallback; signature: string } { if (typeof signalOrUpdate === 'function') { return { onUpdate: signalOrUpdate, signal: isAbortSignalLike(legacySignal) ? legacySignal : getObjectSignal(onUpdateOrContext), signature: 'legacy:update-third', }; } if (typeof onUpdateOrContext === 'function') { return { signal: isAbortSignalLike(signalOrUpdate) ? signalOrUpdate : undefined, onUpdate: onUpdateOrContext as ToolUpdateCallback, signature: 'modern:signal-update', }; } const nestedUpdate = getObjectCallback(onUpdateOrContext, 'onUpdate') || getObjectCallback(onUpdateOrContext, 'update') || getObjectCallback(onUpdateOrContext, 'onToolUpdate'); const nestedSignal = isAbortSignalLike(signalOrUpdate) ? signalOrUpdate : getObjectSignal(onUpdateOrContext) || (isAbortSignalLike(legacySignal) ? legacySignal : undefined); return { signal: nestedSignal, onUpdate: nestedUpdate, signature: nestedUpdate ? 'context:update-callback' : 'unknown:no-update', }; } // ── Tool Factory ── /** * Create the clawlink_call_remote_agent tool. * * Tool description instructs LLM to: * - Detect {clawlink:agent_id="...",agent_name="..."} markers in user messages * - Extract agent_id from the marker * - Pass user's natural language request as task parameter * - NOT display the marker to the user */ export function createCallRemoteAgentTool(): ToolDescriptor { return { name: 'clawlink_call_remote_agent', label: 'Call Remote Agent', description: 'Route a user request to a remote specialist Agent via the ClawLink network. ' + 'TRIGGER: When the user message contains a {clawlink_call_remote_agent:agent_id="...",agent_name="...",instruction="..."} marker, ' + 'call this tool to forward the request to the specified remote Agent. ' + 'Extract agent_id and agent_name from the marker; use the remaining natural-language text as the task parameter. ' + 'Do not display the raw marker to the user. ' + 'mode "session" (default) preserves remote conversation history; mode "run" starts fresh — include full context in task.', parameters: { type: 'object', properties: { agent_id: { type: 'string', description: 'Remote Agent UUID. Extract from the {clawlink_call_remote_agent:agent_id="..."} marker in the user message.', }, agent_name: { type: 'string', description: 'Display name of the remote Agent from the marker. Use this in your reply to the user.', }, task: { type: 'string', description: 'The user\'s natural-language request text, excluding the marker.', }, mode: { type: 'string', enum: ['session', 'run'], description: '"session" (default): preserves remote conversation history. ' + '"run": stateless — task must contain full context.', default: 'session', }, }, required: ['agent_id', 'agent_name', 'task'], }, async execute( _id: unknown, params: ToolParams, signalOrUpdate?: AbortSignal | ToolUpdateCallback, onUpdateOrContext?: ToolUpdateCallback | unknown, legacySignal?: AbortSignal, ): Promise { const { signal, onUpdate, signature } = normalizeRemoteToolExecuteArgs( signalOrUpdate, onUpdateOrContext, legacySignal, ); logger.info( `[remote-tool] Execute args: toolCallId=${String(_id)} signature=${signature} ` + `hasSignal=${Boolean(signal)} hasOnUpdate=${Boolean(onUpdate)} ` + `third=${describeExecuteArg(signalOrUpdate)} fourth=${describeExecuteArg(onUpdateOrContext)} ` + `fifth=${describeExecuteArg(legacySignal)}`, ); // ① Check runtime (same pattern as messaging-tools.ts) const rt = registry.getDefault(); if (!rt || !rt.isRunning) { logger.warn('[remote-tool] ClawLink runtime not running'); return fail('ClawLink not connected'); } // ② Ensure TIM connected (supports lazy connect from half-alive mode) try { await rt.ensureConnected(); } catch (connErr) { logger.error(`[remote-tool] ensureConnected failed: ${(connErr as Error).message}`); return fail(`ClawLink connection failed: ${(connErr as Error).message}`); } const agentId = String(params.agent_id || '').trim(); const task = String(params.task || '').trim(); const mode = String(params.mode || 'session').trim(); // ② Validate required params if (!agentId) { logger.warn('[remote-tool] Missing agent_id parameter'); return fail('agent_id is required'); } if (!task) { logger.warn('[remote-tool] Missing task parameter'); return fail('task is required'); } // ③ Generate requestId const requestId = generateRequestId(); logger.info( `[remote-tool] Calling remote agent: agentId=${agentId} requestId=${requestId} mode=${mode}`, ); // ④ Send C2C task request to remote agent try { await sendC2CCustomMessage(agentId, { type: 'clawlink_task_request', request_id: requestId, task, session_mode: mode, }); } catch (err) { logger.error(`[remote-tool] Failed to send C2C request: ${(err as Error).message}`); return fail(`Failed to reach remote agent: ${(err as Error).message}`); } // ⑤ Wait for C2C reply (progress → onUpdate, result → return) // Default timeout: 15 minutes (remote agent may run long tools) const TIMEOUT_MS = 15 * 60 * 1000; try { const reply = await waitForC2CReply(requestId, { timeoutMs: TIMEOUT_MS, signal, onProgress: (content: string) => { // Bridge C2C progress → OpenClaw onUpdate callback if (onUpdate) { try { onUpdate(toolResult(content)); } catch (err) { logger.warn(`[remote-tool] onUpdate callback failed: ${(err as Error).message}`); } } logger.debug( `[remote-tool] Progress: requestId=${requestId} hasOnUpdate=${Boolean(onUpdate)} ` + `content=${content.slice(0, 80)}`, ); }, }); logger.info(`[remote-tool] Reply received: requestId=${requestId} status=${reply.status}`); if (reply.status === 'error') { return fail(`Remote agent error: ${reply.content}`); } // T4: Include media files from remote file transfer. // Dual-path delivery for cross-version compatibility: // 1. MEDIA: lines in text → parsed by Gateway on ALL versions (v2026.3.x+) // 2. details.media → native rendering on v2026.4.11+ // // We use http:// proxy URLs (not local paths) because our tool is NOT in // Gateway's TRUSTED_TOOL_RESULT_MEDIA whitelist. filterToolResultMediaUrls // only passes http(s):// URLs for non-whitelisted tools. // Files are served via the plugin HTTP route: /plugins/clawlink/media/{filename} if (reply.mediaUrls && reply.mediaUrls.length > 0) { logger.info(`[remote-tool] Reply includes ${reply.mediaUrls.length} file(s)`); installTripCardDataIfPresent(reply.mediaUrls); // Build http:// proxy URLs using the gateway base URL detected from // the browser's Host header (handles Docker port mapping correctly). const baseUrl = getGatewayBaseUrl(); const httpMediaUrls = reply.mediaUrls.map((localPath: string) => { const fileName = localPath.split('/').pop() ?? 'file'; return `${baseUrl}/plugins/clawlink/media/${encodeURIComponent(fileName)}`; }); // Path 1: MEDIA: lines in text for Gateway MEDIA: parser + LLM echo const mediaLines = httpMediaUrls .map((url: string) => `MEDIA:${url}`) .join('\n'); const textWithMedia = reply.content ? `${reply.content}\n${mediaLines}` : mediaLines; return { content: [{ type: 'text' as const, text: textWithMedia }], // Path 2: details.media for v2026.4.11+ native attachment rendering details: { count: httpMediaUrls.length, media: { mediaUrls: httpMediaUrls }, paths: httpMediaUrls, }, }; } return toolResult(reply.content); } catch (err) { const msg = (err as Error).message; logger.error(`[remote-tool] waitForC2CReply failed: ${msg}`); if (msg.includes('timeout')) { return fail(`Remote agent did not respond within ${TIMEOUT_MS / 1000}s`); } if (msg.includes('aborted')) { return fail('Request was cancelled'); } return fail(`Remote agent communication error: ${msg}`); } }, }; } function installTripCardDataIfPresent(mediaUrls: string[]): void { const tripDataPath = mediaUrls.find((localPath) => { const fileName = nodePath.basename(localPath).toLowerCase(); return fileName.endsWith('trip-card-data.json') || fileName.endsWith('ctrip-card-data.json'); }); if (!tripDataPath) return; const controlUiDir = resolveControlUiDirForRuntime(); if (!controlUiDir) { logger.warn(`[remote-tool] Trip card data received but control-ui directory was not found`); return; } try { const dest = nodePath.join(controlUiDir, 'trip-card-data.json'); fs.mkdirSync(nodePath.dirname(dest), { recursive: true }); fs.copyFileSync(tripDataPath, dest); logger.info(`[remote-tool] Installed trip card data: ${tripDataPath} → ${dest}`); } catch (err) { logger.warn(`[remote-tool] Failed to install trip card data: ${(err as Error).message}`); } } function resolveControlUiDirForRuntime(): string | null { const candidates = new Set(); const argvEntry = process.argv[1] || ''; if (argvEntry) { candidates.add(nodePath.join(nodePath.dirname(argvEntry), 'control-ui')); } candidates.add(nodePath.join(process.cwd(), 'dist', 'control-ui')); candidates.add('/app/dist/control-ui'); for (const candidate of candidates) { try { if (fs.existsSync(nodePath.join(candidate, 'index.html'))) return candidate; } catch {} } return null; }