Operator decision: backend env hard-coding a single guild/channel was wrong. Center can host multiple guilds, each can have multiple announce channels for different purposes. The proposing agent picks which one this topic broadcasts to. dialectic_propose_topic tool schema adds: - announce_guild_base_url (string, optional) - announce_channel_id (string, optional) - both or neither - one-of-two rejected at backend POST time - omit both - topic has no broadcasts (agents must poll detail) Tool description points agents at "Fabric channel-list tools" for candidate discovery. Workflow docs spell out the discovery pattern.
259 lines
13 KiB
JavaScript
259 lines
13 KiB
JavaScript
/**
|
|
* 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}` }] };
|
|
}
|
|
}
|