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:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
/node_modules/
|
||||||
|
/.idea/
|
||||||
|
/.vscode/
|
||||||
|
*.swp
|
||||||
|
.DS_Store
|
||||||
85
README.md
Normal file
85
README.md
Normal file
@@ -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
|
||||||
47
package-lock.json
generated
Normal file
47
package-lock.json
generated
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
package.json
Normal file
12
package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
26
plugin/index.js
Normal file
26
plugin/index.js
Normal 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
40
plugin/index.ts
Normal 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');
|
||||||
|
},
|
||||||
|
};
|
||||||
31
plugin/openclaw.plugin.json
Normal file
31
plugin/openclaw.plugin.json
Normal 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
6
plugin/package.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "dialectic-openclaw-plugin",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"main": "index.js"
|
||||||
|
}
|
||||||
90
plugin/src/backend-client.js
Normal file
90
plugin/src/backend-client.js
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
99
plugin/src/backend-client.ts
Normal file
99
plugin/src/backend-client.ts
Normal 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
50
plugin/src/hf-precheck.js
Normal 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
64
plugin/src/hf-precheck.ts
Normal 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
196
plugin/src/tools.js
Normal 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
228
plugin/src/tools.ts
Normal 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}` }] };
|
||||||
|
}
|
||||||
|
}
|
||||||
17
tsconfig.plugin.json
Normal file
17
tsconfig.plugin.json
Normal file
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user