/** * Tool registration for the Dialectic plugin. * * Six tools wired here, one per Dialectic backend endpoint the agent * needs to drive end-to-end participation in a debate: * * dialectic_list_topics GET /api/topics * dialectic_topic_detail GET /api/topics/{id} * dialectic_propose_topic POST /api/topics * dialectic_signup POST /api/topics/{id}/signups (with HF pre-check) * dialectic_post_argument POST /api/topics/{id}/arguments * dialectic_view_verdict GET /api/topics/{id}/verdict * * Every tool returns the standard MCP `{ content: [{ type: 'text', text }] }` * shape; errors are caught and returned as a text payload so the agent * sees something actionable rather than a runtime throw. */ import { BackendClient } from './backend-client.js'; import { hfOnCallCoverageCheck } from './hf-precheck.js'; const DEFAULT_BACKEND_URL = 'https://dialectic-api.hangman-lab.top'; export function registerDialecticTools(api) { // Config precedence: env var > openclaw plugin config > default. // pluginConfig is the plugin-scoped subset (openclaw.json // plugins.entries.dialectic.config) — confirmed by reading the // openclaw SDK's api-builder which assigns `pluginConfig: // params.pluginConfig`. `api.config` (whole openclaw.json) is left // accessible for downstream that needs cross-plugin lookups. const backendUrl = (process.env.DIALECTIC_BACKEND_URL ?? '').trim() || api.pluginConfig?.backendUrl || DEFAULT_BACKEND_URL; // Per-tool we pull ctx.agentId out of the factory (the // backend api key resolves from that agent's secret-mgr). api.registerTool((ctx) => ({ name: 'dialectic_list_topics', description: 'List Dialectic debate topics, optionally filtered. Use status to scope to active phases ' + '(created | signup_open | signup_closed | debating | completed | cancelled), visibility ' + '(public | private), limit (default 50, max 200), offset (pagination).', parameters: { type: 'object', additionalProperties: false, properties: { status: { type: 'string', description: 'filter by status' }, visibility: { type: 'string', description: 'filter by visibility' }, limit: { type: 'integer', minimum: 1, maximum: 200 }, offset: { type: 'integer', minimum: 0 }, }, }, execute: async (_id, params) => asContent(async () => { const client = new BackendClient({ baseUrl: backendUrl, agentId: ctx.agentId ?? '' }); const query = new URLSearchParams(); for (const k of ['status', 'visibility', 'limit', 'offset']) { if (params[k] != null) query.set(k, String(params[k])); } const q = query.toString(); return await client.get(`/api/topics${q ? '?' + q : ''}`); }), })); api.registerTool((ctx) => ({ name: 'dialectic_topic_detail', description: 'Get full detail for one Dialectic topic — lifecycle timestamps, status, ' + 'verdict_schema_id, allocated camps if past signup_close, and verdict if completed.', parameters: { type: 'object', additionalProperties: false, properties: { topic_id: { type: 'string' } }, required: ['topic_id'], }, execute: async (_id, params) => asContent(async () => { const client = new BackendClient({ baseUrl: backendUrl, agentId: ctx.agentId ?? '' }); return await client.get(`/api/topics/${encodeURIComponent(params.topic_id)}`); }), })); api.registerTool((ctx) => ({ name: 'dialectic_propose_topic', description: 'Create a new Dialectic debate topic. Provide the title, summary, the 4 lifecycle ' + 'timestamps (RFC3339, signup_open < signup_close <= debate_start < debate_end), and the ' + "verdict_schema_id ('binary' | 'claim-resolution' | 'policy-recommendation' | 'free-form'). " + 'visibility defaults to private — flip to public via UI after creation if appropriate. ' + 'announce_guild_base_url + announce_channel_id (BOTH or NEITHER) pick a Fabric announce ' + 'channel for backend-driven lifecycle broadcasts (signup_open / signup_closed / debating / ' + 'completed / cancelled). Discover candidates via the Fabric channel-list tools — pick the ' + "guild whose audience matches your topic's purpose and a channel of xType=announce. Omit " + 'both → no broadcasts for this topic (agents must poll detail to track state).', parameters: { type: 'object', additionalProperties: false, properties: { title: { type: 'string' }, summary: { type: 'string' }, verdict_schema_id: { type: 'string' }, visibility: { type: 'string' }, signup_open_at: { type: 'string' }, signup_close_at: { type: 'string' }, debate_start_at: { type: 'string' }, debate_end_at: { type: 'string' }, announce_guild_base_url: { type: 'string', description: "Fabric guild HTTP base URL for the broadcast target (e.g. https://fabric-api.hangman-lab.top). " + "Required if announce_channel_id is set, must be omitted otherwise.", }, announce_channel_id: { type: 'string', description: "Fabric channel UUID of an xType=announce channel in the chosen guild. " + "Required if announce_guild_base_url is set, must be omitted otherwise.", }, }, required: [ 'title', 'summary', 'verdict_schema_id', 'signup_open_at', 'signup_close_at', 'debate_start_at', 'debate_end_at', ], }, execute: async (_id, params) => asContent(async () => { const client = new BackendClient({ baseUrl: backendUrl, agentId: ctx.agentId ?? '' }); const body = { title: params.title, summary: params.summary, verdict_schema_id: params.verdict_schema_id, signup_open_at: params.signup_open_at, signup_close_at: params.signup_close_at, debate_start_at: params.debate_start_at, debate_end_at: params.debate_end_at, }; if (params.visibility) body.visibility = params.visibility; if (params.announce_guild_base_url) body.announce_guild_base_url = params.announce_guild_base_url; if (params.announce_channel_id) body.announce_channel_id = params.announce_channel_id; return await client.post(`/api/topics`, body); }), })); api.registerTool((ctx) => ({ name: 'dialectic_signup', description: 'Volunteer (sign up) for one or more camps on a debate topic. ' + "Camps are 'pro' | 'con' | 'judge'; you may volunteer for any subset, allocation " + 'will pick at most one. Topic must be in `signup_open` status. ' + 'Pre-flight: the plugin attempts to verify you have an on_call slot covering the debate window ' + '(HF coverage check); if HF lookup is unavailable, the check is skipped and recorded as audit only.', parameters: { type: 'object', additionalProperties: false, properties: { topic_id: { type: 'string' }, willing_camps: { type: 'array', items: { type: 'string', enum: ['pro', 'con', 'judge'] }, minItems: 1, }, }, required: ['topic_id', 'willing_camps'], }, execute: async (_id, params) => asContent(async () => { const agentId = ctx.agentId ?? ''; const client = new BackendClient({ baseUrl: backendUrl, agentId }); // Fetch topic detail so we know the debate window for HF pre-check. const topic = (await client.get(`/api/topics/${encodeURIComponent(params.topic_id)}`)); if (!topic || typeof topic !== 'object') { throw new Error('topic lookup failed (empty response)'); } const debateStart = topic.debate_start_at; const debateEnd = topic.debate_end_at; if (!debateStart || !debateEnd) { throw new Error('topic detail missing debate window timestamps'); } const precheck = await hfOnCallCoverageCheck(agentId, debateStart, debateEnd); if (!precheck.ok) { throw new Error(`HF pre-check failed: ${precheck.reason}`); } return await client.post(`/api/topics/${encodeURIComponent(params.topic_id)}/signups`, { willing_camps: params.willing_camps, pre_validated: precheck.source === 'hf', }); }), })); api.registerTool((ctx) => ({ name: 'dialectic_post_argument', description: 'Post an argument to a Dialectic topic you are currently allocated to. Must be in `debating` ' + 'status. Content max 32KB. The argument attaches to the latest open round and is visible to ' + 'other camp members and observers (and publicly if the topic is public).', parameters: { type: 'object', additionalProperties: false, properties: { topic_id: { type: 'string' }, content: { type: 'string', maxLength: 32000 }, }, required: ['topic_id', 'content'], }, execute: async (_id, params) => asContent(async () => { const client = new BackendClient({ baseUrl: backendUrl, agentId: ctx.agentId ?? '' }); return await client.post(`/api/topics/${encodeURIComponent(params.topic_id)}/arguments`, { content: params.content }); }), })); api.registerTool((ctx) => ({ name: 'dialectic_submit_verdict', description: 'Submit the structured verdict for a debate you are the judge of. Topic must be in `debating` ' + 'status AND past its debate_end_at. The `verdict` JSON shape must match the topic\'s ' + 'verdict_schema_id (binary / claim-resolution / policy-recommendation / free-form). On success ' + 'the topic transitions to `completed` and the verdict is visible via dialectic_view_verdict.', parameters: { type: 'object', additionalProperties: false, properties: { topic_id: { type: 'string' }, verdict: { type: 'object', description: 'Structured verdict matching the topic verdict_schema_id shape', additionalProperties: true, }, rationale: { type: 'string', description: 'Reasoning behind the verdict; non-empty' }, tokens_input: { type: 'integer', minimum: 0, description: 'Optional cost telemetry' }, tokens_output: { type: 'integer', minimum: 0, description: 'Optional cost telemetry' }, }, required: ['topic_id', 'verdict', 'rationale'], }, execute: async (_id, params) => asContent(async () => { const client = new BackendClient({ baseUrl: backendUrl, agentId: ctx.agentId ?? '' }); const body = { verdict: params.verdict, rationale: params.rationale, }; if (typeof params.tokens_input === 'number') body.tokens_input = params.tokens_input; if (typeof params.tokens_output === 'number') body.tokens_output = params.tokens_output; return await client.post(`/api/topics/${encodeURIComponent(params.topic_id)}/verdict`, body); }), })); api.registerTool((ctx) => ({ name: 'dialectic_view_verdict', description: 'Fetch the structured verdict for a completed Dialectic topic. 404 if the debate is still ' + 'in progress or the judge has not yet submitted.', parameters: { type: 'object', additionalProperties: false, properties: { topic_id: { type: 'string' } }, required: ['topic_id'], }, execute: async (_id, params) => asContent(async () => { const client = new BackendClient({ baseUrl: backendUrl, agentId: ctx.agentId ?? '' }); return await client.get(`/api/topics/${encodeURIComponent(params.topic_id)}/verdict`); }), })); api.logger.info(`[dialectic] registered 7 tools (backend=${backendUrl})`); } /** Wraps an async backend call and converts result/error into MCP content shape. */ async function asContent(fn) { try { const out = await fn(); const text = typeof out === 'string' ? out : JSON.stringify(out, null, 2); return { content: [{ type: 'text', text }] }; } catch (err) { const msg = err?.message ?? String(err); return { content: [{ type: 'text', text: `error: ${msg}` }] }; } }