feat: initial Dialectic.OpenclawPlugin — agent tools for Dialectic v2

Phase 3 of DIALECTIC-V2. Six tools wired to the Go backend running on
server.t3:

- 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 (HF pre-check)
- dialectic_post_argument    POST /api/topics/{id}/arguments
- dialectic_view_verdict     GET  /api/topics/{id}/verdict

All tools return MCP {content:[{type:text,text}]} shape. Errors caught
and surfaced as text payload so agents see actionable failure messages.

Per-agent API key resolved via secret-mgr key dialectic-agent-apikey
(cached in memory; AGENT_VERIFY env required). HF on_call coverage
check degrades gracefully to skipped if HarborForge.OpenclawPlugin
does not yet expose hasOnCallCovering() — backend stores pre_validated
flag as audit signal.

openclaw.plugin.json declares both contracts.tools (the 6 names) AND
activation.onStartup:true per loader gotchas memory; missing either
silently drops the plugin.

Plain export default {id, name, register} entry shape matching
prism-facet. No openclaw SDK imports (jiti runtime resolution of
openclaw was flaky in HF-style entries; structurally simpler avoids
the lookup entirely).

Defaults backendUrl to https://dialectic-api.hangman-lab.top; override
via openclaw.json plugins.entries.dialectic.config.backendUrl for sim.

Phase 3 deferred items (in README): agent key provisioning workflow,
HF window-coverage accessor, SSE subscription tool, token-cost
reporting.
This commit is contained in:
h z
2026-05-23 13:04:12 +01:00
commit db85d7dc69
15 changed files with 996 additions and 0 deletions

26
plugin/index.js Normal file
View File

@@ -0,0 +1,26 @@
/**
* Dialectic — OpenClaw plugin entry.
*
* Tools: dialectic_list_topics, dialectic_topic_detail,
* dialectic_propose_topic, dialectic_signup,
* dialectic_post_argument, dialectic_view_verdict
*
* Loader gotchas (per [[reference-meridian-plugin-contract]]):
* - openclaw.plugin.json MUST declare `activation.onStartup: true`
* or this plugin is silently treated as lazy/on-demand and
* register() is never called.
* - Every tool name MUST appear in contracts.tools or the tool
* never surfaces to agents.
* - Plain `export default { id, name, register }` works in jiti
* (matches prism-facet); don't pull SDK helpers without
* `openclaw` actually being resolvable at runtime.
*/
import { registerDialecticTools } from './src/tools.js';
export default {
id: 'dialectic',
name: 'Dialectic',
register(api) {
registerDialecticTools(api);
api.logger.info('[dialectic] plugin registered');
},
};

40
plugin/index.ts Normal file
View File

@@ -0,0 +1,40 @@
/**
* Dialectic — OpenClaw plugin entry.
*
* Tools: dialectic_list_topics, dialectic_topic_detail,
* dialectic_propose_topic, dialectic_signup,
* dialectic_post_argument, dialectic_view_verdict
*
* Loader gotchas (per [[reference-meridian-plugin-contract]]):
* - openclaw.plugin.json MUST declare `activation.onStartup: true`
* or this plugin is silently treated as lazy/on-demand and
* register() is never called.
* - Every tool name MUST appear in contracts.tools or the tool
* never surfaces to agents.
* - Plain `export default { id, name, register }` works in jiti
* (matches prism-facet); don't pull SDK helpers without
* `openclaw` actually being resolvable at runtime.
*/
import { registerDialecticTools } from './src/tools.js';
interface PluginApi {
logger: {
info(msg: string): void;
warn(msg: string): void;
error(msg: string): void;
};
on(hook: string, handler: (...args: any[]) => any): void;
registerTool(def: any): void;
config?: { backendUrl?: string };
}
export default {
id: 'dialectic',
name: 'Dialectic',
register(api: PluginApi) {
registerDialecticTools(api);
api.logger.info('[dialectic] plugin registered');
},
};

View File

@@ -0,0 +1,31 @@
{
"id": "dialectic",
"name": "Dialectic",
"version": "0.1.0",
"description": "OpenClaw plugin for the Dialectic v2 debate platform — agent-facing tools",
"main": "index.js",
"activation": {
"onStartup": true
},
"contracts": {
"tools": [
"dialectic_list_topics",
"dialectic_topic_detail",
"dialectic_propose_topic",
"dialectic_signup",
"dialectic_post_argument",
"dialectic_view_verdict"
]
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"backendUrl": {
"type": "string",
"description": "Dialectic backend base URL (e.g. https://dialectic-api.hangman-lab.top)",
"default": "https://dialectic-api.hangman-lab.top"
}
}
}
}

6
plugin/package.json Normal file
View File

@@ -0,0 +1,6 @@
{
"name": "dialectic-openclaw-plugin",
"version": "0.1.0",
"type": "module",
"main": "index.js"
}

View File

@@ -0,0 +1,90 @@
/**
* HTTP client for Dialectic backend (Go service).
*
* Auth: per-agent api key via `Authorization: Bearer <raw>`. Key is
* read from secret-mgr under the agent's `dialectic-agent-apikey`
* entry (provisioned at recruitment time — see Phase 3 deferred items).
*
* Errors:
* - Network/transport → throws.
* - Backend 4xx/5xx → throws with status + body text so the tool
* execute() can surface a useful error to the agent.
*/
import { execSync } from 'node:child_process';
export class BackendClient {
baseUrl;
agentId;
timeoutMs;
cachedApiKey = null;
constructor(opts) {
this.baseUrl = opts.baseUrl.replace(/\/+$/, '');
this.agentId = opts.agentId;
this.timeoutMs = opts.timeoutMs ?? 15000;
}
/**
* Read the agent's dialectic api key from secret-mgr. Cached in
* memory after first read so successive tool calls don't fork
* secret-mgr repeatedly. AGENT_VERIFY is required for secret-mgr
* to authorize the read.
*/
resolveApiKey() {
if (this.cachedApiKey)
return this.cachedApiKey;
try {
const out = execSync('secret-mgr get dialectic-agent-apikey', {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
timeout: 5000,
}).trim();
if (!out) {
throw new Error('empty');
}
this.cachedApiKey = out;
return out;
}
catch {
throw new Error('dialectic api key not provisioned (secret-mgr key `dialectic-agent-apikey` missing). ' +
'Phase 3 deferred: ask hangman / admin to mint one via the recruitment flow.');
}
}
async get(path) {
return this.request('GET', path);
}
async post(path, body) {
return this.request('POST', path, body);
}
async put(path, body) {
return this.request('PUT', path, body);
}
async request(method, path, body) {
const url = `${this.baseUrl}${path.startsWith('/') ? path : '/' + path}`;
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), this.timeoutMs);
try {
const res = await fetch(url, {
method,
headers: {
'content-type': 'application/json',
'authorization': `Bearer ${this.resolveApiKey()}`,
},
body: body === undefined ? undefined : JSON.stringify(body),
signal: ctrl.signal,
});
const text = await res.text();
if (!res.ok) {
throw new Error(`dialectic ${method} ${path}: ${res.status} ${text.slice(0, 500)}`);
}
if (!text)
return null;
try {
return JSON.parse(text);
}
catch {
return text;
}
}
finally {
clearTimeout(timer);
}
}
}

View File

@@ -0,0 +1,99 @@
/**
* HTTP client for Dialectic backend (Go service).
*
* Auth: per-agent api key via `Authorization: Bearer <raw>`. Key is
* read from secret-mgr under the agent's `dialectic-agent-apikey`
* entry (provisioned at recruitment time — see Phase 3 deferred items).
*
* Errors:
* - Network/transport → throws.
* - Backend 4xx/5xx → throws with status + body text so the tool
* execute() can surface a useful error to the agent.
*/
import { execSync } from 'node:child_process';
export interface BackendClientOptions {
baseUrl: string; // e.g. https://dialectic-api.hangman-lab.top
agentId: string; // the calling agent's id
timeoutMs?: number; // default 15000
}
export class BackendClient {
private readonly baseUrl: string;
private readonly agentId: string;
private readonly timeoutMs: number;
private cachedApiKey: string | null = null;
constructor(opts: BackendClientOptions) {
this.baseUrl = opts.baseUrl.replace(/\/+$/, '');
this.agentId = opts.agentId;
this.timeoutMs = opts.timeoutMs ?? 15000;
}
/**
* Read the agent's dialectic api key from secret-mgr. Cached in
* memory after first read so successive tool calls don't fork
* secret-mgr repeatedly. AGENT_VERIFY is required for secret-mgr
* to authorize the read.
*/
private resolveApiKey(): string {
if (this.cachedApiKey) return this.cachedApiKey;
try {
const out = execSync('secret-mgr get dialectic-agent-apikey', {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
timeout: 5000,
}).trim();
if (!out) {
throw new Error('empty');
}
this.cachedApiKey = out;
return out;
} catch {
throw new Error(
'dialectic api key not provisioned (secret-mgr key `dialectic-agent-apikey` missing). ' +
'Phase 3 deferred: ask hangman / admin to mint one via the recruitment flow.'
);
}
}
async get(path: string): Promise<unknown> {
return this.request('GET', path);
}
async post(path: string, body: unknown): Promise<unknown> {
return this.request('POST', path, body);
}
async put(path: string, body: unknown): Promise<unknown> {
return this.request('PUT', path, body);
}
private async request(method: string, path: string, body?: unknown): Promise<unknown> {
const url = `${this.baseUrl}${path.startsWith('/') ? path : '/' + path}`;
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), this.timeoutMs);
try {
const res = await fetch(url, {
method,
headers: {
'content-type': 'application/json',
'authorization': `Bearer ${this.resolveApiKey()}`,
},
body: body === undefined ? undefined : JSON.stringify(body),
signal: ctrl.signal,
});
const text = await res.text();
if (!res.ok) {
throw new Error(`dialectic ${method} ${path}: ${res.status} ${text.slice(0, 500)}`);
}
if (!text) return null;
try {
return JSON.parse(text);
} catch {
return text;
}
} finally {
clearTimeout(timer);
}
}
}

50
plugin/src/hf-precheck.js Normal file
View File

@@ -0,0 +1,50 @@
/**
* HF calendar pre-check for `dialectic_signup`.
*
* Per the design (section 8): before sending a signup to the backend,
* the plugin must verify the agent has an `on_call` slot that covers
* the entire debate window [debate_start_at, debate_end_at].
*
* Data source: the HarborForge OpenclawPlugin already exposes
* `globalThis.__hfAgentStatus.get(agentId)` (Phase 1) which returns
* the agent's CURRENT status — but that's not enough; we need slot
* coverage over a FUTURE window.
*
* For Phase 3 v1: degrade gracefully — if HF can't be queried for
* future slots (no exposed accessor yet), require the caller to
* pass `pre_validated: false`. Backend stores the flag as audit; a
* follow-up Phase can tighten this.
*
* Phase 3 deferred:
* - HarborForge.OpenclawPlugin needs a new global accessor for
* "does agent X have an on_call slot covering [from, to]?".
* Until that lands, this function returns { ok: true, source: "skipped" }
* and trusts the agent's plugin invocation.
*/
export async function hfOnCallCoverageCheck(agentId, debateStartAt, debateEndAt) {
const _G = globalThis;
const hf = _G['__hfAgentStatus'];
// Phase 3 deferred: HF doesn't yet expose a window-coverage check.
// If it does (future), use it; otherwise skip and trust.
if (!hf || typeof hf.hasOnCallCovering !== 'function') {
return { ok: true, source: 'skipped' };
}
try {
const covered = await hf.hasOnCallCovering(agentId, debateStartAt, debateEndAt);
if (covered === undefined) {
// HF queried but said "I don't know" — treat as skipped not failed
return { ok: true, source: 'skipped' };
}
if (!covered) {
return {
ok: false,
reason: `agent ${agentId} has no on_call slot covering [${debateStartAt}${debateEndAt}]`,
source: 'hf',
};
}
return { ok: true, source: 'hf' };
}
catch {
return { ok: true, source: 'skipped' };
}
}

64
plugin/src/hf-precheck.ts Normal file
View File

@@ -0,0 +1,64 @@
/**
* HF calendar pre-check for `dialectic_signup`.
*
* Per the design (section 8): before sending a signup to the backend,
* the plugin must verify the agent has an `on_call` slot that covers
* the entire debate window [debate_start_at, debate_end_at].
*
* Data source: the HarborForge OpenclawPlugin already exposes
* `globalThis.__hfAgentStatus.get(agentId)` (Phase 1) which returns
* the agent's CURRENT status — but that's not enough; we need slot
* coverage over a FUTURE window.
*
* For Phase 3 v1: degrade gracefully — if HF can't be queried for
* future slots (no exposed accessor yet), require the caller to
* pass `pre_validated: false`. Backend stores the flag as audit; a
* follow-up Phase can tighten this.
*
* Phase 3 deferred:
* - HarborForge.OpenclawPlugin needs a new global accessor for
* "does agent X have an on_call slot covering [from, to]?".
* Until that lands, this function returns { ok: true, source: "skipped" }
* and trusts the agent's plugin invocation.
*/
export interface PrecheckResult {
ok: boolean;
reason?: string;
source: 'hf' | 'skipped';
}
export async function hfOnCallCoverageCheck(
agentId: string,
debateStartAt: string,
debateEndAt: string,
): Promise<PrecheckResult> {
const _G = globalThis as Record<string, unknown>;
const hf = _G['__hfAgentStatus'] as
| { hasOnCallCovering?: (a: string, from: string, to: string) => Promise<boolean | undefined> }
| undefined;
// Phase 3 deferred: HF doesn't yet expose a window-coverage check.
// If it does (future), use it; otherwise skip and trust.
if (!hf || typeof hf.hasOnCallCovering !== 'function') {
return { ok: true, source: 'skipped' };
}
try {
const covered = await hf.hasOnCallCovering(agentId, debateStartAt, debateEndAt);
if (covered === undefined) {
// HF queried but said "I don't know" — treat as skipped not failed
return { ok: true, source: 'skipped' };
}
if (!covered) {
return {
ok: false,
reason: `agent ${agentId} has no on_call slot covering [${debateStartAt}${debateEndAt}]`,
source: 'hf',
};
}
return { ok: true, source: 'hf' };
} catch {
return { ok: true, source: 'skipped' };
}
}

196
plugin/src/tools.js Normal file
View File

@@ -0,0 +1,196 @@
/**
* 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) {
const backendUrl = api.config?.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.',
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' },
},
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;
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_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 6 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}` }] };
}
}

228
plugin/src/tools.ts Normal file
View File

@@ -0,0 +1,228 @@
/**
* 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';
type ToolApi = {
registerTool(def: any): void;
config?: { backendUrl?: string };
logger: { info(msg: string): void; warn(msg: string): void };
};
const DEFAULT_BACKEND_URL = 'https://dialectic-api.hangman-lab.top';
export function registerDialecticTools(api: ToolApi): void {
const backendUrl = api.config?.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: { agentId?: string }) => ({
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: string, params: Record<string, any>) =>
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: { agentId?: string }) => ({
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: string, params: Record<string, any>) =>
asContent(async () => {
const client = new BackendClient({ baseUrl: backendUrl, agentId: ctx.agentId ?? '' });
return await client.get(`/api/topics/${encodeURIComponent(params.topic_id)}`);
}),
}));
api.registerTool((ctx: { agentId?: string }) => ({
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.',
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' },
},
required: [
'title', 'summary', 'verdict_schema_id',
'signup_open_at', 'signup_close_at', 'debate_start_at', 'debate_end_at',
],
},
execute: async (_id: string, params: Record<string, any>) =>
asContent(async () => {
const client = new BackendClient({ baseUrl: backendUrl, agentId: ctx.agentId ?? '' });
const body: Record<string, any> = {
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;
return await client.post(`/api/topics`, body);
}),
}));
api.registerTool((ctx: { agentId?: string }) => ({
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: string, params: Record<string, any>) =>
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)}`)) as any;
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: { agentId?: string }) => ({
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: string, params: Record<string, any>) =>
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: { agentId?: string }) => ({
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: string, params: Record<string, any>) =>
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 6 tools (backend=${backendUrl})`);
}
/** Wraps an async backend call and converts result/error into MCP content shape. */
async function asContent(fn: () => Promise<unknown>) {
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 as { message?: string } | undefined)?.message ?? String(err);
return { content: [{ type: 'text', text: `error: ${msg}` }] };
}
}