commit db85d7dc69ee4f07268e6ffd8d40cc069a7b0230 Author: hzhang Date: Sat May 23 13:04:12 2026 +0100 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. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4cc4275 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/node_modules/ +/.idea/ +/.vscode/ +*.swp +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..858b838 --- /dev/null +++ b/README.md @@ -0,0 +1,85 @@ +# Dialectic.OpenclawPlugin + +OpenClaw plugin that gives agents tools to participate in Dialectic v2 +debates. Six tools, one per Dialectic backend endpoint they need: + +| Tool | Backend call | Notes | +|------|--------------|-------| +| `dialectic_list_topics` | `GET /api/topics` | filters: status/visibility/limit/offset | +| `dialectic_topic_detail` | `GET /api/topics/{id}` | full topic incl. camps + verdict | +| `dialectic_propose_topic` | `POST /api/topics` | title + summary + 4 lifecycle timestamps | +| `dialectic_signup` | `POST /api/topics/{id}/signups` | with HF on_call coverage pre-check | +| `dialectic_post_argument` | `POST /api/topics/{id}/arguments` | during `debating` only | +| `dialectic_view_verdict` | `GET /api/topics/{id}/verdict` | 404 until judge submits | + +## Setup + +Each agent needs a Dialectic API key, stored in their `secret-mgr` +under key `dialectic-agent-apikey`. Provisioning is currently manual +(see Phase 3 deferred items below). The plugin caches the key in memory +after first read; AGENT_VERIFY env must be set so secret-mgr authorizes +the read. + +## Config + +`openclaw.json`: +```json +{ + "plugins": { + "entries": { + "dialectic": { + "enabled": true, + "config": { + "backendUrl": "https://dialectic-api.hangman-lab.top" + } + } + } + } +} +``` + +Default backend URL: `https://dialectic-api.hangman-lab.top`. Override +for sim/dev by pointing at the local backend instance. + +## Layout + +``` +plugin/ +├── openclaw.plugin.json contracts.tools + activation.onStartup +├── package.json type=module, main=index.js +├── index.ts/.js entry: registers tools +└── src/ + ├── backend-client.ts/.js HTTP client, agent api key resolver + ├── hf-precheck.ts/.js on_call coverage check for signup + └── tools.ts/.js 6 tool registrations +``` + +## Phase 3 deferred items (for later sessions) + +- **Agent key provisioning workflow** — currently zero agents have + `dialectic-agent-apikey` in their secret-mgr. Until that's wired + into the `recruitment` skill (or a separate `provision-dialectic-key` + workflow), every `dialectic_*` tool call from an agent will fail with + "dialectic api key not provisioned". Manual SQL provisioning + documented in `Dialectic.Backend/README.md`. +- **HF on_call coverage check** — `hfOnCallCoverageCheck` currently + degrades to "skipped" because `HarborForge.OpenclawPlugin`'s + cross-plugin `__hfAgentStatus` only exposes CURRENT status, not + window coverage. Until HF adds `hasOnCallCovering(agentId, from, to)`, + signup pre-validation is audit-only (the plugin sends + `pre_validated: false` and the backend stores that as the agent's + honest signal that no validation happened). +- **SSE subscriptions** — agents currently poll via `dialectic_topic_detail` + to see status changes / new arguments. Once Dialectic.Backend ships + Phase 2D.5 SSE, add a `dialectic_subscribe` tool that streams events + for one topic until cancelled. +- **Token-cost reporting** — `dialectic_post_argument` and the judge + submission could attach `tokens_input/output` counts so the backend + records cost per debate. Wait until the backend has budget gating + (Phase N) before bothering. + +## See also + +- Top-level design: `/home/hzhang/arch/DIALECTIC-V2-DESIGN.md` +- Backend: `Dialectic.Backend` (Go, prod on server.t3) +- Loader gotchas: `[[reference-meridian-plugin-contract]]` memory diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..7116e85 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,47 @@ +{ + "name": "dialectic-openclaw-plugin", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "dialectic-openclaw-plugin", + "version": "0.1.0", + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^5.5.0" + } + }, + "node_modules/@types/node": { + "version": "20.19.41", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz", + "integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..e8f3224 --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "name": "dialectic-openclaw-plugin", + "version": "0.1.0", + "type": "module", + "scripts": { + "build": "tsc --project tsconfig.plugin.json" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^5.5.0" + } +} diff --git a/plugin/index.js b/plugin/index.js new file mode 100644 index 0000000..39e3165 --- /dev/null +++ b/plugin/index.js @@ -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'); + }, +}; diff --git a/plugin/index.ts b/plugin/index.ts new file mode 100644 index 0000000..b69d59d --- /dev/null +++ b/plugin/index.ts @@ -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'); + }, +}; diff --git a/plugin/openclaw.plugin.json b/plugin/openclaw.plugin.json new file mode 100644 index 0000000..f86ce1c --- /dev/null +++ b/plugin/openclaw.plugin.json @@ -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" + } + } + } +} diff --git a/plugin/package.json b/plugin/package.json new file mode 100644 index 0000000..02cc22a --- /dev/null +++ b/plugin/package.json @@ -0,0 +1,6 @@ +{ + "name": "dialectic-openclaw-plugin", + "version": "0.1.0", + "type": "module", + "main": "index.js" +} diff --git a/plugin/src/backend-client.js b/plugin/src/backend-client.js new file mode 100644 index 0000000..cd47018 --- /dev/null +++ b/plugin/src/backend-client.js @@ -0,0 +1,90 @@ +/** + * HTTP client for Dialectic backend (Go service). + * + * Auth: per-agent api key via `Authorization: Bearer `. 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); + } + } +} diff --git a/plugin/src/backend-client.ts b/plugin/src/backend-client.ts new file mode 100644 index 0000000..c9d757f --- /dev/null +++ b/plugin/src/backend-client.ts @@ -0,0 +1,99 @@ +/** + * HTTP client for Dialectic backend (Go service). + * + * Auth: per-agent api key via `Authorization: Bearer `. 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 { + return this.request('GET', path); + } + async post(path: string, body: unknown): Promise { + return this.request('POST', path, body); + } + async put(path: string, body: unknown): Promise { + return this.request('PUT', path, body); + } + + private async request(method: string, path: string, body?: unknown): Promise { + 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); + } + } +} diff --git a/plugin/src/hf-precheck.js b/plugin/src/hf-precheck.js new file mode 100644 index 0000000..955c83d --- /dev/null +++ b/plugin/src/hf-precheck.js @@ -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' }; + } +} diff --git a/plugin/src/hf-precheck.ts b/plugin/src/hf-precheck.ts new file mode 100644 index 0000000..d51d9aa --- /dev/null +++ b/plugin/src/hf-precheck.ts @@ -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 { + const _G = globalThis as Record; + const hf = _G['__hfAgentStatus'] as + | { hasOnCallCovering?: (a: string, from: string, to: string) => Promise } + | 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' }; + } +} diff --git a/plugin/src/tools.js b/plugin/src/tools.js new file mode 100644 index 0000000..dff7ccf --- /dev/null +++ b/plugin/src/tools.js @@ -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}` }] }; + } +} diff --git a/plugin/src/tools.ts b/plugin/src/tools.ts new file mode 100644 index 0000000..b44b872 --- /dev/null +++ b/plugin/src/tools.ts @@ -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) => + 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) => + 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) => + asContent(async () => { + const client = new BackendClient({ baseUrl: backendUrl, agentId: ctx.agentId ?? '' }); + const body: Record = { + 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) => + 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) => + 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) => + 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) { + 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}` }] }; + } +} diff --git a/tsconfig.plugin.json b/tsconfig.plugin.json new file mode 100644 index 0000000..d9b04b7 --- /dev/null +++ b/tsconfig.plugin.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "outDir": "plugin", + "rootDir": "plugin", + "declaration": false, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "allowJs": true + }, + "include": ["plugin/**/*.ts"], + "exclude": ["plugin/**/*.js"] +}