Skip to main content

agentd/registry/
internal.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//! The **internal tool contracts**: name, description, input and output JSON
3//! Schemas, whether a built-in implementation exists (mapping-only contracts
4//! are `code.run`, `knowledge.*`, `search.*`), and the default grants.
5//!
6//! The contract is what callers see, and an override swaps only the
7//! implementation behind it. That separation is what lets an operator move a
8//! tool onto an MCP server without any caller — model, workflow or subagent —
9//! having to be told, and it is why the schemas here are the authority on a
10//! tool's shape rather than whatever a mapped server happens to advertise.
11
12use serde_json::{Value, json};
13
14/// Who may call a tool by default, before configuration widens or narrows it.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub struct DefaultGrant {
17    pub root: bool,
18    pub workflows: bool,
19    pub subagents: bool,
20    /// Granted to A2A `user` principals by default.
21    pub user: bool,
22    /// Granted to A2A `agent` principals by default.
23    pub agent: bool,
24}
25
26const ALL: DefaultGrant = DefaultGrant {
27    root: true,
28    workflows: true,
29    subagents: true,
30    user: false,
31    agent: false,
32};
33const ROOT_WF: DefaultGrant = DefaultGrant {
34    root: true,
35    workflows: true,
36    subagents: false,
37    user: false,
38    agent: false,
39};
40const ROOT_ONLY: DefaultGrant = DefaultGrant {
41    root: true,
42    workflows: false,
43    subagents: false,
44    user: false,
45    agent: false,
46};
47
48/// One contract.
49#[derive(Debug, Clone)]
50pub struct Contract {
51    pub name: &'static str,
52    pub description: &'static str,
53    pub input: Value,
54    pub output: Value,
55    pub builtin: bool,
56    pub grant: DefaultGrant,
57    /// The tool's family (`memory`, `plan`, …) for `agent.tools.internal` lists.
58    pub family: &'static str,
59}
60
61fn obj(props: Value, required: &[&str]) -> Value {
62    json!({"type": "object", "properties": props, "required": required, "additionalProperties": false})
63}
64fn open_obj(props: Value, required: &[&str]) -> Value {
65    json!({"type": "object", "properties": props, "required": required})
66}
67fn any() -> Value {
68    json!({})
69}
70fn s(desc: &str) -> Value {
71    json!({"type": "string", "description": desc})
72}
73fn arr(items: Value) -> Value {
74    json!({"type": "array", "items": items})
75}
76
77/// Every internal contract (deterministic order).
78pub fn contracts() -> Vec<Contract> {
79    let mut v = Vec::new();
80    let mut c = |name: &'static str,
81                 family: &'static str,
82                 description: &'static str,
83                 input: Value,
84                 output: Value,
85                 builtin: bool,
86                 grant: DefaultGrant| {
87        v.push(Contract {
88            name,
89            description,
90            input,
91            output,
92            builtin,
93            grant,
94            family,
95        });
96    };
97
98    // ---- instruction ----
99    c(
100        "instruction.read",
101        "instruction",
102        "Read the agent's current instruction (the brief it operates under).",
103        obj(json!({}), &[]),
104        open_obj(
105            json!({"text": {"type": "string"}, "source": {"enum": ["static", "resource"]}, "uri": {"type": "string"}, "version": {"type": "string"}}),
106            &["text", "source"],
107        ),
108        true,
109        ALL,
110    );
111    c(
112        "instruction.subscribe",
113        "instruction",
114        "(Re)subscribe to the instruction resource, or switch to another URI; an update re-reads it and wakes the agent.",
115        obj(
116            json!({"uri": s("The resource URI (omit to re-subscribe to the current one)")}),
117            &[],
118        ),
119        open_obj(
120            json!({"subscribed": {"type": "boolean"}, "uri": {"type": "string"}}),
121            &["subscribed"],
122        ),
123        true,
124        ROOT_ONLY,
125    );
126
127    // ---- subagents ----
128    c(
129        "subagent.run",
130        "subagent",
131        "Spawn a subagent: freeform `instruction`, or `template` naming a declared subagents.templates entry (fill its declared `params` only — an instance-tier template brings its own workflows and runs as a peer daemon). mode: sync (wait for the result), async (get a handle), detached (fire and forget), warm (stays alive; send it messages).",
132        obj(
133            json!({
134                "instruction": s("The subagent's brief (freeform; mutually exclusive with template)"),
135                "template": s("A subagents.templates entry to instantiate"),
136                "params": {"type": "object", "description": "Values for the template's declared params (schema-validated)"},
137                "mode": {"enum": ["sync", "async", "detached", "warm"], "default": "sync"},
138                "tools": arr(json!({"type": "string"})),
139                "servers": arr(json!({"type": "string"})),
140                "limits": open_obj(json!({"steps": {"type": "integer"}, "tokens": {"type": "integer"}, "deadline": {"type": "string"}, "memory": s("OS memory cap for the child process, e.g. \"512MB\" (RLIMIT_AS)"), "cpu": s("OS CPU-time cap, e.g. \"5m\" (RLIMIT_CPU)")}), &[]),
141                "priority": {"enum": ["low", "normal", "high"], "default": "normal", "description": "Contention priority: low sheds first under pressure and runs nicer; high schedules first (and asks the OS for more, best-effort)."},
142                "context": arr(open_obj(json!({"role": {"type": "string"}, "content": {"type": "string"}}), &["role", "content"])),
143                "output_contract": s("What the result must look like"),
144                "output_schema": {"type": "object"},
145                "skills": arr(json!({"type": "string"})),
146                "durable": {"type": "boolean", "description": "false = a memory-only record: never persisted, never restore-respawned (the fast path for throwaway workers); absent = the store.durability.work default"}
147            }),
148            &[],
149        ),
150        open_obj(
151            json!({"handle": {"type": "string"}, "status": {"type": "string"}, "result": any()}),
152            &["handle", "status"],
153        ),
154        true,
155        ROOT_WF,
156    );
157    c(
158        "subagent.retire",
159        "subagent",
160        "Begin graceful retirement of an instance-tier child: it drains its own runs and exits cleanly; escalation to SIGKILL only after the drain window.",
161        obj(
162            json!({"handle": s("The instance child's handle")}),
163            &["handle"],
164        ),
165        open_obj(
166            json!({"ok": {"type": "boolean"}, "handle": {"type": "string"}, "status": {"type": "string"}}),
167            &["ok"],
168        ),
169        true,
170        ROOT_WF,
171    );
172    c(
173        "subagent.send",
174        "subagent",
175        "Send a message into a warm subagent (steer it).",
176        obj(
177            json!({"handle": s("The subagent handle"), "message": s("The message")}),
178            &["handle", "message"],
179        ),
180        open_obj(
181            json!({"ok": {"type": "boolean"}, "handle": {"type": "string"}}),
182            &["ok"],
183        ),
184        true,
185        ROOT_WF,
186    );
187    c(
188        "subagent.kill",
189        "subagent",
190        "Cancel and stop a subagent.",
191        obj(
192            json!({"handle": s("The subagent handle"), "reason": s("Why")}),
193            &["handle"],
194        ),
195        open_obj(
196            json!({"ok": {"type": "boolean"}, "handle": {"type": "string"}}),
197            &["ok"],
198        ),
199        true,
200        ROOT_WF,
201    );
202    c(
203        "subagent.status",
204        "subagent",
205        "The status (and result, when finished) of a subagent.",
206        obj(json!({"handle": s("The subagent handle")}), &["handle"]),
207        open_obj(
208            json!({"handle": {"type": "string"}, "status": {"type": "string"}, "mode": {"type": "string"}, "result": any(), "error": {"type": "string"}}),
209            &["handle", "status"],
210        ),
211        true,
212        ROOT_WF,
213    );
214    c(
215        "subagent.await",
216        "subagent",
217        "Wait for an async subagent to finish (bounded by timeout) and return its result.",
218        obj(
219            json!({"handle": s("The subagent handle"), "timeout": s("Duration, e.g. 30s")}),
220            &["handle"],
221        ),
222        open_obj(
223            json!({"handle": {"type": "string"}, "status": {"type": "string"}, "result": any(), "error": {"type": "string"}}),
224            &["handle", "status"],
225        ),
226        true,
227        ROOT_WF,
228    );
229    c(
230        "subagent.list",
231        "subagent",
232        "List the subagents of this instance.",
233        obj(json!({}), &[]),
234        open_obj(
235            json!({"subagents": arr(json!({"type": "object"}))}),
236            &["subagents"],
237        ),
238        true,
239        ROOT_WF,
240    );
241
242    // ---- code (mapping-only) ----
243    c(
244        "code.run",
245        "code",
246        "Run code in a sandbox (only available when mapped to a sandbox MCP server).",
247        obj(
248            json!({"language": s("e.g. python, bash"), "code": s("The program"), "files": {"type": "object"}, "timeout": s("Duration")}),
249            &["language", "code"],
250        ),
251        open_obj(
252            json!({"stdout": {"type": "string"}, "stderr": {"type": "string"}, "exit_code": {"type": "integer"}, "files": {"type": "object"}}),
253            &[],
254        ),
255        false,
256        ROOT_WF,
257    );
258
259    // ---- memory ----
260    c(
261        "memory.get",
262        "memory",
263        "Read a value from the agent's durable memory.",
264        obj(json!({"key": s("The key")}), &["key"]),
265        open_obj(
266            json!({"found": {"type": "boolean"}, "key": {"type": "string"}, "value": any(), "meta": {"type": "object"}}),
267            &["found"],
268        ),
269        true,
270        ALL,
271    );
272    c(
273        "memory.set",
274        "memory",
275        "Write a JSON value to the agent's durable memory (optional TTL).",
276        obj(
277            json!({"key": s("The key"), "value": any(), "ttl": s("Duration after which the value expires")}),
278            &["key", "value"],
279        ),
280        open_obj(
281            json!({"ok": {"type": "boolean"}, "key": {"type": "string"}, "meta": {"type": "object"}}),
282            &["ok"],
283        ),
284        true,
285        ALL,
286    );
287    c(
288        "memory.list",
289        "memory",
290        "List memory keys (optionally by prefix).",
291        obj(
292            json!({"prefix": s("Key prefix"), "limit": {"type": "integer", "minimum": 1}}),
293            &[],
294        ),
295        open_obj(
296            json!({"keys": arr(json!({"type": "object"})), "truncated": {"type": "boolean"}}),
297            &["keys"],
298        ),
299        true,
300        ALL,
301    );
302    c(
303        "memory.push",
304        "memory",
305        "Append a value to the ARRAY at a memory key (created if absent) — the durable queue primitive.",
306        obj(
307            json!({"key": s("The key"), "value": any()}),
308            &["key", "value"],
309        ),
310        open_obj(
311            json!({"ok": {"type": "boolean"}, "key": {"type": "string"}, "length": {"type": "integer"}}),
312            &["ok"],
313        ),
314        true,
315        ALL,
316    );
317    c(
318        "memory.shift",
319        "memory",
320        "Remove and return the FIRST element of the array at a memory key ({found: false} on empty).",
321        obj(json!({"key": s("The key")}), &["key"]),
322        open_obj(
323            json!({"found": {"type": "boolean"}, "value": any(), "remaining": {"type": "integer"}}),
324            &["found"],
325        ),
326        true,
327        ALL,
328    );
329    c(
330        "memory.pop",
331        "memory",
332        "Remove and return the LAST element of the array at a memory key ({found: false} on empty).",
333        obj(json!({"key": s("The key")}), &["key"]),
334        open_obj(
335            json!({"found": {"type": "boolean"}, "value": any(), "remaining": {"type": "integer"}}),
336            &["found"],
337        ),
338        true,
339        ALL,
340    );
341    c(
342        "memory.delete",
343        "memory",
344        "Delete a memory key.",
345        obj(json!({"key": s("The key")}), &["key"]),
346        open_obj(
347            json!({"ok": {"type": "boolean"}, "key": {"type": "string"}}),
348            &["ok"],
349        ),
350        true,
351        ALL,
352    );
353
354    // ---- artifacts ----
355    c(
356        "artifact.create",
357        "artifact",
358        "Create an artifact (a named piece of content delivered with the task).",
359        obj(
360            json!({"name": s("File-like name"), "mime": s("MIME type, default text/plain"), "content": any(), "from_step": s("Take the content from a workflow step output"), "sensitive": {"type": "boolean"}}),
361            &["name"],
362        ),
363        open_obj(
364            json!({"id": {"type": "string"}, "name": {"type": "string"}, "size": {"type": "integer"}, "sha256": {"type": "string"}}),
365            &["id"],
366        ),
367        true,
368        ALL,
369    );
370    c(
371        "artifact.get",
372        "artifact",
373        "Read an artifact by id.",
374        obj(json!({"id": s("Artifact id")}), &["id"]),
375        open_obj(
376            json!({"id": {"type": "string"}, "name": {"type": "string"}, "mime": {"type": "string"}, "content": any(), "size": {"type": "integer"}, "sha256": {"type": "string"}}),
377            &["id"],
378        ),
379        true,
380        ALL,
381    );
382    c(
383        "artifact.delete",
384        "artifact",
385        "Delete an artifact.",
386        obj(json!({"id": s("Artifact id")}), &["id"]),
387        open_obj(json!({"ok": {"type": "boolean"}}), &["ok"]),
388        true,
389        ALL,
390    );
391    c(
392        "artifact.list",
393        "artifact",
394        "List artifacts.",
395        obj(
396            json!({"prefix": s("Name prefix"), "limit": {"type": "integer"}}),
397            &[],
398        ),
399        open_obj(
400            json!({"artifacts": arr(json!({"type": "object"}))}),
401            &["artifacts"],
402        ),
403        true,
404        ALL,
405    );
406
407    // ---- conversations ----
408    // Delivering into a context is how a subagent or a workflow hands work UP
409    // to the agent, rather than only receiving it. Granted to workflows and
410    // subagents as well as root: a child reporting something worth thinking
411    // about is the ordinary case, and the hop cap — not the grant — is what
412    // keeps it from looping.
413    c(
414        "message.send",
415        "message",
416        "Deliver a message into one of this agent's own conversations, starting a turn there. `to` is a context id, \"root\", or \"new\". Returns once the delivery is durable — the turn runs on its own schedule. To wait for the answer, use the `message` workflow node with `wait: reply`.",
417        obj(
418            json!({"to": s("Context id, \"root\", or \"new\" (default: root)"), "text": s("The message")}),
419            &["text"],
420        ),
421        open_obj(
422            json!({"delivered": {"type": "boolean"}, "conversation": {"type": "string"}, "depth": {"type": "integer"}}),
423            &["delivered", "conversation"],
424        ),
425        true,
426        ALL,
427    );
428
429    // ---- workflows ----
430    c(
431        "workflow.run",
432        "workflow",
433        "Start a run of a named workflow (with inputs).",
434        obj(
435            json!({"name": s("Workflow name"), "inputs": {"type": "object"}, "start": s("Which start node to fire (default: manual/once)"), "wait": {"type": "boolean", "description": "Wait for the run to finish and return its output"}, "timeout": s("Duration when waiting")}),
436            &["name"],
437        ),
438        open_obj(
439            json!({"run": {"type": "string"}, "status": {"type": "string"}, "output": any(), "task": {"type": "string"}}),
440            &["run", "status"],
441        ),
442        true,
443        ROOT_WF,
444    );
445    c(
446        "workflow.create",
447        "workflow",
448        "Define a new workflow at runtime.",
449        obj(
450            json!({"definition": {"type": "object"}, "arm": {"type": "boolean"}}),
451            &["definition"],
452        ),
453        open_obj(
454            json!({"name": {"type": "string"}, "hash": {"type": "string"}, "armed": {"type": "boolean"}}),
455            &["name"],
456        ),
457        true,
458        ROOT_ONLY,
459    );
460    c(
461        "workflow.update",
462        "workflow",
463        "Replace a workflow definition (live runs keep their pinned hash).",
464        obj(
465            json!({"name": s("Workflow name"), "definition": {"type": "object"}}),
466            &["name", "definition"],
467        ),
468        open_obj(
469            json!({"name": {"type": "string"}, "hash": {"type": "string"}}),
470            &["name"],
471        ),
472        true,
473        ROOT_ONLY,
474    );
475    c(
476        "workflow.delete",
477        "workflow",
478        "Delete a workflow definition (disarms it; live runs finish).",
479        obj(json!({"name": s("Workflow name")}), &["name"]),
480        open_obj(json!({"ok": {"type": "boolean"}}), &["ok"]),
481        true,
482        ROOT_ONLY,
483    );
484    c(
485        "workflow.list",
486        "workflow",
487        "List workflows and their runs.",
488        obj(json!({}), &[]),
489        open_obj(
490            json!({"workflows": arr(json!({"type": "object"}))}),
491            &["workflows"],
492        ),
493        true,
494        ALL,
495    );
496    c(
497        "workflow.status",
498        "workflow",
499        "The status of a run (or of every run of a workflow).",
500        obj(json!({"run": s("Run id"), "name": s("Workflow name")}), &[]),
501        open_obj(json!({"runs": arr(json!({"type": "object"}))}), &["runs"]),
502        true,
503        ALL,
504    );
505    c(
506        "workflow.cancel",
507        "workflow",
508        "Cancel a run.",
509        obj(json!({"run": s("Run id"), "reason": s("Why")}), &["run"]),
510        open_obj(
511            json!({"ok": {"type": "boolean"}, "status": {"type": "string"}}),
512            &["ok"],
513        ),
514        true,
515        ROOT_WF,
516    );
517    c(
518        "workflow.pause",
519        "workflow",
520        "Pause a run (or disarm a workflow's start nodes). With `before_step`, \
521         set a BREAKPOINT instead: the run keeps going and pauses just before \
522         that step starts, so it can be inspected in the state it is in rather \
523         than one effect later. Durable — it survives a restart.",
524        obj(
525            json!({"run": s("Run id"), "name": s("Workflow name"),
526                   "before_step": s("Pause just before this step starts (a breakpoint)")}),
527            &[],
528        ),
529        open_obj(
530            json!({"ok": {"type": "boolean"}, "break_before": {"type": "string"}}),
531            &[],
532        ),
533        true,
534        ROOT_ONLY,
535    );
536    c(
537        "workflow.resume",
538        "workflow",
539        "Resume a paused run (or re-arm a workflow).",
540        obj(json!({"run": s("Run id"), "name": s("Workflow name")}), &[]),
541        open_obj(json!({"ok": {"type": "boolean"}}), &["ok"]),
542        true,
543        ROOT_ONLY,
544    );
545    c(
546        "workflow.signal",
547        "workflow",
548        "Send a named signal (with a payload) into a run, or start a workflow whose start node listens for it.",
549        obj(
550            json!({"name": s("Signal name"), "payload": any(), "run": s("Target run id (optional)")}),
551            &["name"],
552        ),
553        open_obj(json!({"delivered": {"type": "integer"}}), &["delivered"]),
554        true,
555        ALL,
556    );
557    c(
558        "workflow.wait",
559        "workflow",
560        "Wait for a run to finish and return its output.",
561        obj(
562            json!({"run": s("Run id"), "timeout": s("Duration")}),
563            &["run"],
564        ),
565        open_obj(
566            json!({"run": {"type": "string"}, "status": {"type": "string"}, "output": any()}),
567            &["run", "status"],
568        ),
569        true,
570        ROOT_WF,
571    );
572
573    // ---- plan ----
574    c(
575        "plan.create",
576        "plan",
577        "Create (or replace) this conversation's working plan: a goal and an ordered list of items.",
578        obj(
579            json!({"goal": s("The goal"), "items": arr(json!({"oneOf": [{"type": "string"}, open_obj(json!({"title": {"type": "string"}, "detail": {"type": "string"}}), &["title"])]}))}),
580            &["goal", "items"],
581        ),
582        open_obj(
583            json!({"goal": {"type": "string"}, "items": arr(json!({"type": "object"}))}),
584            &["goal", "items"],
585        ),
586        true,
587        ALL,
588    );
589    c(
590        "plan.get",
591        "plan",
592        "Read this conversation's plan.",
593        obj(json!({}), &[]),
594        open_obj(json!({"plan": any(), "progress": {"type": "string"}}), &[]),
595        true,
596        ALL,
597    );
598    c(
599        "plan.update",
600        "plan",
601        "Advance the plan: set an item's status/note, bind it to a run/subagent, insert an item, or reorder.",
602        obj(
603            json!({
604                "item": {"description": "Item id (number) or exact title", "oneOf": [{"type": "integer"}, {"type": "string"}]},
605                "status": {"enum": ["pending", "in_progress", "done", "blocked", "skipped"]},
606                "note": s("A short note"), "title": s("New title"), "detail": s("New detail"),
607                "bind": open_obj(json!({"run": {"type": "string"}, "subagent": {"type": "string"}, "task": {"type": "string"}}), &[]),
608                "insert": open_obj(json!({"title": {"type": "string"}, "detail": {"type": "string"}, "after": {"type": "integer"}}), &["title"]),
609                "reorder": arr(json!({"type": "integer"}))
610            }),
611            &[],
612        ),
613        open_obj(
614            json!({"goal": {"type": "string"}, "items": arr(json!({"type": "object"})), "progress": {"type": "string"}}),
615            &["goal", "items"],
616        ),
617        true,
618        ALL,
619    );
620    c(
621        "plan.clear",
622        "plan",
623        "Clear the plan (the goal is met or abandoned).",
624        obj(json!({}), &[]),
625        open_obj(json!({"ok": {"type": "boolean"}}), &["ok"]),
626        true,
627        ALL,
628    );
629
630    // ---- misc ----
631    // The contract states exactly what the implementation does, in both
632    // directions. It previously advertised `to` — which nothing read, so a
633    // model could believe it had addressed a question that went to whoever was
634    // watching — and omitted `recommend`, which the `approval: accept` path
635    // reads: with `additionalProperties: false` a model that tried to supply
636    // one was refused, leaving that mode reachable only through a `default`
637    // buried in the schema.
638    //
639    // There is deliberately no addressee. A gate is answered by whoever holds
640    // the task, and routing to a named person is a different feature (an
641    // addressee, a quorum, a decider) rather than an argument.
642    c(
643        "ask_human",
644        "human",
645        "Ask a person a question and wait for the answer. The answer is CHECKED against `schema`, so a reply that does not match is rejected and the person is asked again with the reason. By default the question reaches whoever is watching this agent's tasks; `to` names who must answer, and a reply from anyone else is refused.",
646        obj(
647            json!({
648                "question": s("The question"),
649                "to": {"description": "Who must answer: a principal-id glob (\"*@finance.example\"), or {id, role, labels} — all conditions must hold. Anyone else is refused and the gate stays open. Use it when the DECISION belongs to a particular person; omit it when any watcher will do."},
650                "schema": {"type": "object", "description": "The answer's shape, as a JSON Schema. Enforced on the reply, not merely advertised: build it for the decision you actually need. A single-property schema also lets a person answer in plain language (\"yes\" for {approved: boolean})."},
651                "recommend": {"description": "The answer you would choose if nobody replies. Used only when the operator set `agent.approval: accept`; otherwise a person still decides."},
652                "timeout": s("Duration"),
653            }),
654            &["question"],
655        ),
656        open_obj(
657            json!({"reply": any(), "timed_out": {"type": "boolean"}}),
658            &[],
659        ),
660        true,
661        ALL,
662    );
663    c(
664        "sleep",
665        "time",
666        "Wait for a duration (durable: survives restarts).",
667        obj(
668            json!({"duration": s("Duration, e.g. 30s, 5m")}),
669            &["duration"],
670        ),
671        open_obj(json!({"slept_ms": {"type": "integer"}}), &["slept_ms"]),
672        true,
673        ALL,
674    );
675    c(
676        "await",
677        "time",
678        "Wait until a condition holds (CEL over memory/resources/steps/signals) or a timeout elapses.",
679        obj(
680            json!({"condition": s("CEL expression"), "on": arr(json!({"type": "string"})), "timeout": s("Duration")}),
681            &["condition"],
682        ),
683        open_obj(
684            json!({"satisfied": {"type": "boolean"}, "value": any()}),
685            &["satisfied"],
686        ),
687        true,
688        ALL,
689    );
690    c(
691        "context.compact",
692        "context",
693        "Compact this context: summarize older messages, keep the recent ones verbatim.",
694        obj(
695            json!({"target_tokens": {"type": "integer"}, "keep_last": {"type": "integer"}}),
696            &[],
697        ),
698        open_obj(
699            json!({"version": {"type": "integer"}, "est_tokens": {"type": "integer"}, "folded": {"type": "integer"}}),
700            &["version"],
701        ),
702        true,
703        ALL,
704    );
705    c(
706        "think",
707        "intelligence",
708        "One structured reasoning call (no tools): give a prompt and optionally an output schema; get the object back.",
709        obj(
710            json!({"prompt": s("What to think about"), "output_schema": {"type": "object"}, "reads": arr(json!({"type": "string"})), "skills": arr(json!({"type": "string"}))}),
711            &["prompt"],
712        ),
713        any(),
714        true,
715        ALL,
716    );
717    c(
718        "finish",
719        "lifecycle",
720        "Finish the current unit of work with a status and an optional output.",
721        obj(
722            json!({"status": {"enum": ["completed", "failed", "refused", "cancelled"]}, "output": any(), "reason": s("Why"), "exit": {"type": "boolean", "description": "Root only: exit the daemon"}}),
723            &["status"],
724        ),
725        open_obj(json!({"ok": {"type": "boolean"}}), &["ok"]),
726        true,
727        DefaultGrant {
728            root: true,
729            workflows: false,
730            subagents: true,
731            user: false,
732            agent: false,
733        },
734    );
735    c(
736        "status",
737        "status",
738        "The instance status: runs, subagents, conversations, budget, store.",
739        obj(json!({}), &[]),
740        json!({"type": "object"}),
741        true,
742        DefaultGrant {
743            root: true,
744            workflows: true,
745            subagents: true,
746            user: true,
747            agent: true,
748        },
749    );
750
751    // ---- knowledge / search (profiles, mapping-only) ----
752    c(
753        "knowledge.search",
754        "knowledge",
755        "Search the knowledge base (RAG over documents).",
756        obj(
757            json!({"query": s("The query"), "top_k": {"type": "integer", "minimum": 1}, "filters": {"type": "object"}}),
758            &["query"],
759        ),
760        open_obj(
761            json!({"hits": arr(open_obj(json!({"id": {"type": "string"}, "uri": {"type": "string"}, "title": {"type": "string"}, "score": {"type": "number"}, "snippet": {"type": "string"}, "metadata": {"type": "object"}}), &[]))}),
762            &["hits"],
763        ),
764        false,
765        ALL,
766    );
767    c(
768        "knowledge.get",
769        "knowledge",
770        "Fetch a knowledge document by id or URI.",
771        obj(
772            json!({"id": s("Document id"), "uri": s("Document URI")}),
773            &[],
774        ),
775        open_obj(
776            json!({"content": {"type": "string"}, "mime": {"type": "string"}, "metadata": {"type": "object"}}),
777            &["content"],
778        ),
779        false,
780        ALL,
781    );
782    c(
783        "knowledge.list",
784        "knowledge",
785        "List knowledge documents.",
786        obj(json!({"prefix": s("Prefix")}), &[]),
787        open_obj(json!({"docs": arr(json!({"type": "object"}))}), &["docs"]),
788        false,
789        ALL,
790    );
791    c(
792        "search.query",
793        "search",
794        "Web/docs/code search through the search server.",
795        obj(
796            json!({"query": s("The query"), "kind": {"enum": ["web", "docs", "code"]}, "limit": {"type": "integer", "minimum": 1}, "freshness": s("e.g. day, week")}),
797            &["query"],
798        ),
799        open_obj(
800            json!({"results": arr(open_obj(json!({"title": {"type": "string"}, "url": {"type": "string"}, "snippet": {"type": "string"}, "source": {"type": "string"}, "published": {"type": "string"}}), &[]))}),
801            &["results"],
802        ),
803        false,
804        ALL,
805    );
806    c(
807        "search.fetch",
808        "search",
809        "Fetch a page's content through the search server.",
810        obj(
811            json!({"url": s("The URL"), "max_bytes": {"type": "integer"}}),
812            &["url"],
813        ),
814        open_obj(
815            json!({"content": {"type": "string"}, "mime": {"type": "string"}, "final_url": {"type": "string"}}),
816            &["content"],
817        ),
818        false,
819        ALL,
820    );
821
822    // ---- skills ----
823    c(
824        "skills.list",
825        "skills",
826        "List the available skills (name, description, when to use).",
827        obj(json!({}), &[]),
828        open_obj(
829            json!({"skills": arr(json!({"type": "object"}))}),
830            &["skills"],
831        ),
832        true,
833        ALL,
834    );
835    c(
836        "skills.load",
837        "skills",
838        "Load a skill's full instructions into this context.",
839        obj(
840            json!({"name": s("Skill name"), "version": s("Version/hash (optional)"), "arguments": {"type": "object"}}),
841            &["name"],
842        ),
843        open_obj(
844            json!({"loaded": {"type": "boolean"}, "name": {"type": "string"}, "hash": {"type": "string"}, "body": {"type": "string"}}),
845            &["loaded"],
846        ),
847        true,
848        ALL,
849    );
850    c(
851        "skills.unload",
852        "skills",
853        "Drop a loaded skill from this context.",
854        obj(json!({"name": s("Skill name")}), &["name"]),
855        open_obj(json!({"ok": {"type": "boolean"}}), &["ok"]),
856        true,
857        ALL,
858    );
859
860    // ---- exec (guarded local command runner; DEFAULT-OFF) -------------------
861    // A mapping-only contract by default: agentd runs no local code unless an
862    // operator both builds `--features exec` AND sets `security.exec`. Two
863    // independent switches, because arbitrary local execution is the one
864    // capability that turns a prompt-injection into host compromise. Failing
865    // either, `exec` is delegated off-box via `tools.overrides`. It always
866    // carries the `sensitive` + `egress` trifecta tags (attached in
867    // `Registry::build`), so the Rule-of-Two gate refuses to combine it with
868    // untrusted input.
869    c(
870        "exec",
871        "exec",
872        "Run a local command (argv — NO shell interpretation) and return {stdout, stderr, exit_code, timed_out}. GUARDED and default-OFF: runs only allow-listed commands, confined to a working directory, with a timeout, an output cap, and a minimal environment. Enable a local runner via `security.exec` in a build with `--features exec`, or map it onto an MCP server with `tools.overrides` to delegate execution off-box.",
873        obj(
874            json!({
875                "cmd": s("The command to run (argv[0]) — must be in security.exec.allow"),
876                "args": arr(s("Arguments (argv[1..]); passed directly, never through a shell")),
877                "cwd": s("Working directory, relative to and confined within security.exec.workdir"),
878                "stdin": s("Optional standard input for the command"),
879                "timeout": s("Max wall-clock (e.g. `10s`); clamped to the configured maximum")
880            }),
881            &["cmd"],
882        ),
883        open_obj(
884            json!({
885                "stdout": {"type": "string"}, "stderr": {"type": "string"},
886                "exit_code": {"type": "integer"}, "timed_out": {"type": "boolean"}
887            }),
888            &["stdout", "stderr", "exit_code"],
889        ),
890        false,
891        ALL,
892    );
893    v
894}
895
896/// The contract names, in table order.
897pub fn names() -> Vec<&'static str> {
898    contracts().into_iter().map(|c| c.name).collect()
899}
900
901#[cfg(test)]
902mod tests {
903    use super::*;
904
905    /// `ask_human`'s contract has to say what the implementation does, in both
906    /// directions. It once drifted apart in both at the same time: `to` was
907    /// advertised and read by nothing, so a model could believe it had
908    /// addressed a question that in fact went to whoever was watching; and
909    /// `recommend` was read by the `approval: accept` path but not advertised,
910    /// so — under `additionalProperties: false` — a model supplying one was
911    /// REFUSED, leaving that mode reachable only through a `default` buried in
912    /// the schema.
913    ///
914    /// A field that silently does nothing and a field that silently cannot be
915    /// used are the same defect pointing in opposite directions. Both are now
916    /// advertised AND read; `to` is enforced when the answer lands.
917    #[test]
918    fn ask_human_advertises_exactly_what_it_reads() {
919        let c = contracts()
920            .into_iter()
921            .find(|c| c.name == "ask_human")
922            .expect("ask_human exists");
923        let props = c.input["properties"].as_object().expect("properties");
924        for f in ["question", "schema", "to", "recommend", "timeout"] {
925            assert!(props.contains_key(f), "ask_human must advertise {f:?}");
926        }
927        // The schema really is strict, which is what makes an unadvertised
928        // field unusable rather than merely undocumented.
929        assert_eq!(c.input["additionalProperties"], serde_json::json!(false));
930        for args in [
931            json!({"question": "ship it?", "recommend": {"approved": true}}),
932            json!({"question": "ship it?", "to": "*@finance.example"}),
933            json!({"question": "ship it?", "to": {"role": "user", "labels": {"team": "finance"}}}),
934        ] {
935            assert!(
936                crate::jsonschema::validate(&c.input, &args).is_ok(),
937                "must validate: {args}"
938            );
939        }
940        assert!(
941            crate::jsonschema::validate(&c.input, &json!({"question": "x", "nonsense": 1}))
942                .is_err(),
943            "an unknown field is still refused"
944        );
945    }
946
947    #[test]
948    fn contracts_are_unique_well_formed_and_cover_the_catalogue() {
949        let all = contracts();
950        let mut seen = std::collections::BTreeSet::new();
951        for c in &all {
952            assert!(seen.insert(c.name), "duplicate contract {}", c.name);
953            crate::jsonschema::check_schema(&c.input)
954                .unwrap_or_else(|e| panic!("{}: bad input schema: {e:?}", c.name));
955            crate::jsonschema::check_schema(&c.output)
956                .unwrap_or_else(|e| panic!("{}: bad output schema: {e:?}", c.name));
957            assert!(
958                c.name
959                    .chars()
960                    .all(|ch| ch.is_ascii_alphanumeric() || ch == '.' || ch == '_'),
961                "{}",
962                c.name
963            );
964        }
965        for must in [
966            "instruction.read",
967            "instruction.subscribe",
968            "subagent.run",
969            "subagent.send",
970            "subagent.kill",
971            "subagent.status",
972            "subagent.await",
973            "subagent.list",
974            "subagent.retire",
975            "code.run",
976            "memory.get",
977            "memory.set",
978            "memory.list",
979            "memory.push",
980            "memory.shift",
981            "memory.pop",
982            "memory.delete",
983            "artifact.create",
984            "artifact.get",
985            "artifact.delete",
986            "artifact.list",
987            "workflow.run",
988            "workflow.create",
989            "workflow.update",
990            "workflow.delete",
991            "workflow.list",
992            "workflow.status",
993            "workflow.cancel",
994            "workflow.pause",
995            "workflow.resume",
996            "workflow.signal",
997            "workflow.wait",
998            "plan.create",
999            "plan.get",
1000            "plan.update",
1001            "plan.clear",
1002            "ask_human",
1003            "sleep",
1004            "await",
1005            "context.compact",
1006            "think",
1007            "finish",
1008            "status",
1009            "knowledge.search",
1010            "knowledge.get",
1011            "knowledge.list",
1012            "search.query",
1013            "search.fetch",
1014            "skills.list",
1015            "skills.load",
1016            "skills.unload",
1017        ] {
1018            assert!(seen.contains(must), "missing contract {must}");
1019        }
1020        // Mapping-only contracts have no built-in. (`exec` is mapping-only in the
1021        // catalogue; a local runner is turned on in `Registry::build` under the
1022        // `exec` feature + `security.exec`.)
1023        for c in &all {
1024            let mapping_only = c.name == "code.run"
1025                || c.name == "exec"
1026                || c.name.starts_with("knowledge.")
1027                || c.name.starts_with("search.");
1028            assert_eq!(!c.builtin, mapping_only, "{}", c.name);
1029        }
1030        // finish is not granted to workflows (they use the finish step).
1031        assert!(
1032            !all.iter()
1033                .find(|c| c.name == "finish")
1034                .unwrap()
1035                .grant
1036                .workflows
1037        );
1038        assert!(all.iter().find(|c| c.name == "status").unwrap().grant.user);
1039    }
1040}