Skip to main content

vela_protocol/
tool_registry.rs

1//! Tool registry — tools defined as data, separate from execution.
2//! Borrowed from Codex (MIT) tool-as-data pattern.
3
4use crate::permission::PermissionLevel;
5use serde::Serialize;
6use serde_json::{Value, json};
7
8#[derive(Debug, Clone, Serialize)]
9pub struct ToolDefinition {
10    pub name: String,
11    pub description: String,
12    pub parameters: Value,
13    pub permission_level: PermissionLevel,
14    pub mutating: bool,
15    pub caveats: Vec<String>,
16}
17
18/// All MCP tools registered in Vela
19pub fn all_tools() -> Vec<ToolDefinition> {
20    vec![
21        tool(
22            "frontier_stats",
23            "Return frontier metadata and statistics: finding count, links, confidence distribution, gaps, categories, and review state.",
24            json!({"type": "object", "properties": {}}),
25            PermissionLevel::ReadOnly,
26            false,
27            vec![],
28        ),
29        tool(
30            "search_findings",
31            "Search findings by text content, entity name, entity type, or assertion type. Returns matching findings.",
32            json!({"type": "object", "properties": {
33                "query": {"type": "string"}, "entity": {"type": "string"},
34                "entity_type": {"type": "string"}, "assertion_type": {"type": "string"},
35                "limit": {"type": "integer"}
36            }}),
37            PermissionLevel::ReadOnly,
38            false,
39            vec![],
40        ),
41        tool(
42            "get_finding",
43            "Get a single finding by ID, including evidence, conditions, links, confidence, and provenance.",
44            json!({"type": "object", "properties": {"id": {"type": "string"}}, "required": ["id"]}),
45            PermissionLevel::ReadOnly,
46            false,
47            vec![],
48        ),
49        tool(
50            "get_finding_history",
51            "v0.17: Return the chronological event log for one finding (asserted, reviewed, caveated, noted, confidence-revised, superseded, retracted). Use this to walk the supersedes chain, audit corrections, or detect that a target has been refined since you last linked to it.",
52            json!({"type": "object", "properties": {"id": {"type": "string"}}, "required": ["id"]}),
53            PermissionLevel::ReadOnly,
54            false,
55            vec![
56                "Event order reflects timestamps as recorded; sort client-side if you need a different ordering.",
57            ],
58        ),
59        tool(
60            "list_gaps",
61            "List findings flagged as candidate gap review leads.",
62            json!({"type": "object", "properties": {}}),
63            PermissionLevel::ReadOnly,
64            false,
65            vec![
66                "Candidate gap rankings are review leads, not guaranteed underexplored areas or experiment targets.",
67            ],
68        ),
69        tool(
70            "list_contradictions",
71            "List contradiction and dispute links between findings.",
72            json!({"type": "object", "properties": {}}),
73            PermissionLevel::ReadOnly,
74            false,
75            vec![
76                "Automated contradiction links are candidates for review, not definitive disagreements.",
77            ],
78        ),
79        tool(
80            "find_bridges",
81            "Find entities spanning multiple assertion categories, suggesting candidate cross-domain connections.",
82            json!({"type": "object", "properties": {
83                "min_categories": {"type": "integer"}, "limit": {"type": "integer"}
84            }}),
85            PermissionLevel::ReadOnly,
86            false,
87            vec!["Candidate bridges require review before being treated as domain knowledge."],
88        ),
89        tool(
90            "check_pubmed",
91            "Run a rough PubMed prior-art check for a hypothesis.",
92            json!({"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]}),
93            PermissionLevel::ReadOnly,
94            false,
95            vec!["PubMed counts are rough prior-art signals, not proof of novelty."],
96        ),
97        tool(
98            "apply_observer",
99            "Rerank findings under an observer policy such as pharma, academic, regulatory, clinical, or exploration.",
100            json!({"type": "object", "properties": {
101                "policy": {"type": "string"}, "limit": {"type": "integer"}
102            }, "required": ["policy"]}),
103            PermissionLevel::ReadOnly,
104            false,
105            vec!["Observer policy output is a weighted view, not definitive disagreement."],
106        ),
107        tool(
108            "propagate_retraction",
109            "Simulate retraction cascade impact over declared dependency/support links.",
110            json!({"type": "object", "properties": {"finding_id": {"type": "string"}}, "required": ["finding_id"]}),
111            PermissionLevel::Dangerous,
112            false,
113            vec!["Retraction impact is simulated over declared links only."],
114        ),
115        tool(
116            "trace_evidence_chain",
117            "Trace evidence lineage for a finding, including support, dependency, contradiction, and chain strength.",
118            json!({"type": "object", "properties": {
119                "finding_id": {"type": "string"}, "depth": {"type": "integer"}
120            }, "required": ["finding_id"]}),
121            PermissionLevel::ReadOnly,
122            false,
123            vec!["Evidence-chain strength is heuristic and depends on declared links."],
124        ),
125        // Phase Q-r (v0.5): cursor-paginated read over the canonical
126        // event log. Agent loops use this to learn when their proposals
127        // were accepted, rejected, or had cascade events emitted on
128        // their behalf. Public consumers use it to track frontier state
129        // changes without re-reading the full log.
130        tool(
131            "list_events_since",
132            "List canonical events from the event log strictly after `cursor` (a `vev_…` id), ordered chronologically. Returns events plus a `next_cursor` for further pagination, or null when the tail is reached. Omit `cursor` to start from the genesis event.",
133            json!({"type": "object", "properties": {
134                "cursor": {"type": "string"},
135                "limit": {"type": "integer"}
136            }}),
137            PermissionLevel::ReadOnly,
138            false,
139            vec![
140                "Cursor must reference an event currently in the log; out-of-sync clients should restart from the beginning.",
141            ],
142        ),
143        // Phase Q-w (v0.5): write surface — propose-* and decision tools.
144        // Each requires a registered actor and a verifying Ed25519 signature
145        // over the canonical preimage. Idempotent under Phase P:
146        // identical logical proposals produce the same `vpr_…` and a retry
147        // returns the existing record without duplicating state.
148        tool(
149            "propose_review",
150            "Propose a `finding.review` decision on a finding (status: accepted/approved/contested/needs_revision/rejected). Requires the actor's Ed25519 signature over the canonical proposal preimage. Idempotent: identical logical proposals return the same `vpr_…`.",
151            json!({"type": "object", "properties": {
152                "actor_id": {"type": "string"},
153                "target_finding_id": {"type": "string"},
154                "status": {"type": "string"},
155                "reason": {"type": "string"},
156                "created_at": {"type": "string"},
157                "signature": {"type": "string"}
158            }, "required": ["actor_id", "target_finding_id", "status", "reason", "signature"]}),
159            PermissionLevel::Write,
160            true,
161            vec![
162                "actor_id must be registered in `frontier.actors` via `vela actor add` before writes verify.",
163            ],
164        ),
165        tool(
166            "propose_note",
167            "Propose attaching a `finding.note` annotation to a finding. Requires a registered actor and signature. Optional structured `provenance` (Phase β, v0.6): `{doi?, pmid?, title?, span?}` with at least one identifier. Stays `pending_review` until accepted.",
168            json!({"type": "object", "properties": {
169                "actor_id": {"type": "string"},
170                "target_finding_id": {"type": "string"},
171                "text": {"type": "string"},
172                "reason": {"type": "string"},
173                "created_at": {"type": "string"},
174                "signature": {"type": "string"},
175                "provenance": {
176                    "type": "object",
177                    "properties": {
178                        "doi": {"type": "string"},
179                        "pmid": {"type": "string"},
180                        "title": {"type": "string"},
181                        "span": {"type": "string"}
182                    }
183                }
184            }, "required": ["actor_id", "target_finding_id", "text", "reason", "signature"]}),
185            PermissionLevel::Write,
186            true,
187            vec!["Notes do not change finding state; they accrete review context."],
188        ),
189        // Phase α (v0.6): one-call propose-and-apply for `finding.note`,
190        // gated on actor `tier="auto-notes"`. Halves the signing surface
191        // for trusted bulk-note extractors. Identical signing preimage and
192        // arguments as `propose_note`; idempotent under Phase P.
193        tool(
194            "propose_and_apply_note",
195            "Propose AND apply a `finding.note` annotation in one signed call. Requires the actor to have `tier=\"auto-notes\"` registered (`vela actor add --tier auto-notes`). Optional structured `provenance` (Phase β). Idempotent: a retry with identical content returns the same `applied_event_id`.",
196            json!({"type": "object", "properties": {
197                "actor_id": {"type": "string"},
198                "target_finding_id": {"type": "string"},
199                "text": {"type": "string"},
200                "reason": {"type": "string"},
201                "created_at": {"type": "string"},
202                "signature": {"type": "string"},
203                "provenance": {
204                    "type": "object",
205                    "properties": {
206                        "doi": {"type": "string"},
207                        "pmid": {"type": "string"},
208                        "title": {"type": "string"},
209                        "span": {"type": "string"}
210                    }
211                }
212            }, "required": ["actor_id", "target_finding_id", "text", "reason", "signature"]}),
213            PermissionLevel::Write,
214            true,
215            vec![
216                "Requires actor.tier=auto-notes; calls from non-tiered actors are rejected.",
217                "Notes still do not change finding state — they accrete review context.",
218            ],
219        ),
220        tool(
221            "propose_revise_confidence",
222            "Propose a confidence revision (`finding.confidence_revise`) on a finding. `new_score` must be in [0.0, 1.0]. Requires a registered actor and signature.",
223            json!({"type": "object", "properties": {
224                "actor_id": {"type": "string"},
225                "target_finding_id": {"type": "string"},
226                "new_score": {"type": "number"},
227                "reason": {"type": "string"},
228                "created_at": {"type": "string"},
229                "signature": {"type": "string"}
230            }, "required": ["actor_id", "target_finding_id", "new_score", "reason", "signature"]}),
231            PermissionLevel::Write,
232            true,
233            vec![
234                "Confidence revisions update score and basis; they do not change scope or evidence.",
235            ],
236        ),
237        tool(
238            "propose_retract",
239            "Propose retracting a finding (`finding.retract`). Applying triggers per-dependent `finding.dependency_invalidated` events through the propagation graph. Requires a registered actor and signature.",
240            json!({"type": "object", "properties": {
241                "actor_id": {"type": "string"},
242                "target_finding_id": {"type": "string"},
243                "reason": {"type": "string"},
244                "created_at": {"type": "string"},
245                "signature": {"type": "string"}
246            }, "required": ["actor_id", "target_finding_id", "reason", "signature"]}),
247            PermissionLevel::Write,
248            true,
249            vec![
250                "Retraction propagates through declared dependency/support links; review impact before applying.",
251            ],
252        ),
253        tool(
254            "accept_proposal",
255            "Apply a pending proposal as the named reviewer. The reviewer must be registered. Signature is over `{action: \"accept\", proposal_id, reviewer_id, reason, timestamp}` canonicalized. Idempotent: re-applying returns the same `applied_event_id`.",
256            json!({"type": "object", "properties": {
257                "proposal_id": {"type": "string"},
258                "reviewer_id": {"type": "string"},
259                "reason": {"type": "string"},
260                "timestamp": {"type": "string"},
261                "signature": {"type": "string"}
262            }, "required": ["proposal_id", "reviewer_id", "reason", "signature"]}),
263            PermissionLevel::Write,
264            true,
265            vec![
266                "Accepting an applied proposal returns its existing event_id; no duplicate event is emitted.",
267            ],
268        ),
269        tool(
270            "reject_proposal",
271            "Reject a pending proposal as the named reviewer. The reviewer must be registered. Signature is over `{action: \"reject\", proposal_id, reviewer_id, reason, timestamp}` canonicalized.",
272            json!({"type": "object", "properties": {
273                "proposal_id": {"type": "string"},
274                "reviewer_id": {"type": "string"},
275                "reason": {"type": "string"},
276                "timestamp": {"type": "string"},
277                "signature": {"type": "string"}
278            }, "required": ["proposal_id", "reviewer_id", "reason", "signature"]}),
279            PermissionLevel::Write,
280            true,
281            vec![
282                "Rejection records the decision but emits no canonical event; rejected proposals stay on the proposal log.",
283            ],
284        ),
285    ]
286}
287
288pub fn get_tool(name: &str) -> Option<ToolDefinition> {
289    all_tools().into_iter().find(|tool| tool.name == name)
290}
291
292pub fn tool_caveats(name: &str) -> Vec<String> {
293    get_tool(name).map(|tool| tool.caveats).unwrap_or_default()
294}
295
296pub fn mcp_tools_json() -> Value {
297    Value::Array(
298        all_tools()
299            .into_iter()
300            .map(|tool| {
301                json!({
302                    "name": tool.name,
303                    "description": tool.description,
304                    "inputSchema": tool.parameters,
305                    "metadata": {
306                        "permission_level": tool.permission_level,
307                        "mutating": tool.mutating,
308                        "caveats": tool.caveats,
309                    }
310                })
311            })
312            .collect(),
313    )
314}
315
316fn tool(
317    name: &str,
318    description: &str,
319    parameters: Value,
320    permission_level: PermissionLevel,
321    mutating: bool,
322    caveats: Vec<&str>,
323) -> ToolDefinition {
324    ToolDefinition {
325        name: name.to_string(),
326        description: description.to_string(),
327        parameters,
328        permission_level,
329        mutating,
330        caveats: caveats.into_iter().map(str::to_string).collect(),
331    }
332}