aontu

09. An AI agent platform's tool registry as ground truth

An agent platform's tool registry; runtime call guardrail; the MCP server itself

Exercises per-tool vet --at, the real aontu-mcp over JSON-RPC, agentsmd, generation

Rendered from use-cases/09-agent-tools/README.md in the engine repository. The models, the expected output and the check.sh that drives the CLI over all of it are in use-cases/09-agent-tools/.

$ Role "admin"|"operator"|"analyst"|... SideEffect "readonly"|"write"|"destructive" ToolSpec allowed_roles [&:$.Role] description string&length(integer&min(24)... owner re("^[a-z][a-z0-9-]*@corp[.]e... rate_limit (2) requires_approval boolean side_effect "readonly"|"write"|"destructive" timeout_ms integer&min(100)&max(600000) argschemas create_ticket (5) delete_records (3) http_request (5) read_file (2) search_docs (3) send_email (3) docs header "| tool | effect | rpm | appr... table (6) guard create_ticket (2) delete_records (2) http_request (2) read_file (2) search_docs (2) send_email (2) registry owner "platform-tools@corp.example" platform "orion" version "1.4.0" tools create_ticket (7) delete_records (7) http_request (7) read_file (7) search_docs (7) send_email (7)
The model tree: the tool registry, the argument schemas, and the per-call guard generated from them

The scenario

An agent platform (“Orion”) runs a fleet of LLM agents that call tools. The platform needs ONE document that answers, for every tool: what exists, what arguments a call may carry (and must refuse), what a call can cost (side-effect class, rate limit, timeout), and who may call it. Today this truth is usually scattered across a TypeScript tool file, a JSON Schema, a wiki page and the dispatcher’s if statements, and those copies drift. This is aontu’s home turf: the MCP ecosystem’s tool-definition problem.

The model exercises the full loop:

  1. registry.aon: the registry itself: six tools, closed schemas, constraint atoms, enums, generated call schemas, derived fields, a derived docs table.
  2. guard.aon: the dispatcher’s vet entrypoint: one wire schema per tool, generated from the registry’s argument schemas.
  3. data/call-*.json: agent-emitted calls {tool, arguments}, vetted with aontu vet --at $.guard.<tool>: the runtime guardrail, asserted by exit code and error code in check.sh.
  4. The real aontu-mcp server driven over stdio JSON-RPC (initialize, tools/list, tools/call of vet) from check.sh.
  5. The aontu agentsmd stanza and its aontu hash pin.

./check.sh runs everything and asserts every outcome (19 checks).

The model tree

guard.aon is the registry plus the runtime guardrail. tools is what exists and argschemas what each call may carry; guard is generated from them by a pack() over the schema map, so a call is vetted at $.guard.<tool> and the two can never drift. ToolSpec, Role and SideEffect are the vocabulary the entries are written in.

$
├── Role "admin"|"operator"|"analyst"|...
├── SideEffect "readonly"|"write"|"destructive"
├── ToolSpec
│   ├── allowed_roles [&:$.Role]
│   ├── description string&length(integer&min(24)...
│   ├── owner re("^[a-z][a-z0-9-]*@corp[.]e...
│   ├── rate_limit (2)
│   ├── requires_approval boolean
│   ├── side_effect "readonly"|"write"|"destructive"
│   └── timeout_ms integer&min(100)&max(600000)
├── argschemas
│   ├── create_ticket (5)
│   ├── delete_records (3)
│   ├── http_request (5)
│   ├── read_file (2)
│   ├── search_docs (3)
│   └── send_email (3)
├── docs
│   ├── header "| tool | effect | rpm | appr...
│   └── table (6)
├── guard
│   ├── create_ticket (2)
│   ├── delete_records (2)
│   ├── http_request (2)
│   ├── read_file (2)
│   ├── search_docs (2)
│   └── send_email (2)
├── registry
│   ├── owner "platform-tools@corp.example"
│   ├── platform "orion"
│   └── version "1.4.0"
└── tools
    ├── create_ticket (7)
    ├── delete_records (7)
    ├── http_request (7)
    ├── read_file (7)
    ├── search_docs (7)
    └── send_email (7)

aontu view doc --depth 2 guard.aon draws it, and check.sh pins it with --out --check. A key with (n) after it is a container the depth bound stopped at, and n is how many keys are not drawn; a leaf carries its canon, which is the kind of thing it is rather than its value.

How the model is designed

  • argschemas is the spine. The set of tool names is the key set of one closed map. tools (metadata) is close(pack($.argschemas, $.ToolSpec)), so metadata for a tool with no argument schema is a located [aontu/closed] error (bad/rogue-tool.aon), and an argument schema with no metadata leaves required ToolSpec fields unresolved and the registry refuses to generate. Both drift directions are refused the moment the registry is evaluated, from one pack.

  • The wire schema is generated, not written. guard: pack( $.argschemas, close({tool: key(), arguments: _})) makes the {tool, arguments} envelope per tool; close() survives the _ clone, so a hallucinated argument is [aontu/closed]. The guard lives in its own file, guard.aon, which includes registry.aon: aontu registry.aon emits only the concrete registry, and aontu vet --at $.guard.<tool> guard.aon call.json is the dispatcher’s one command.

  • Constraint atoms are the guardrail vocabulary: re() for URL / email / id shapes, min/max bounds, length() on strings, length(max(n)) and unique() on lists, enum disjunctions for method, priority, scope, side_effect. A length(min(n)) written on a templated list ([&: ...]) refuses at composition, so “at least one” is a dispatcher rule rather than a schema one.

  • type() marks keep every schema out of the generated JSON while it still constrains: aontu registry.aon emits only the concrete registry (tools, docs): the golden in expected/registry.json. Role, SideEffect and ToolSpec are separate top-level marked fields, each referenced by absolute path.

  • Derived truth: requires_approval: match(.side_effect, destructive, true, false) per tool, and a markdown docs table computed from the tool entries. The rule and the table rows are written once per tool, each naming its tool by absolute path. aontu model why traces the flag back to the match() rule.

  • deprecate() sunsets http_request.max_redirects: still admitted, warned on, with the replacement path in the warning.

  • No defaults near enforcement. Optional arguments carry constraints but no * defaults, so the dispatcher owns runtime defaults and the schema owns the admissible range; dry_run: boolean is required and explicit on delete_records, so destruction says what it means.

  • The vet verdict is the dispatcher’s decision table. valid (exit 0) dispatches; invalid (1) refuses and feeds the findings back to the agent; incomplete (3) asks for the missing argument; error (4) is an unknown tool. All four states fall out of one command, aontu vet --at "$.guard.$tool" guard.aon call.json, with no per-tool code. The findings carry expected, actual, and both sites (schema file:line:col and data file:line:col), and --format json / --format sarif render the same report for machines. The two conflict findings for the cleartext URL call:

    $ aontu vet --at '$.guard.http_request' guard.aon data/call-http-bad.json
    verdict: invalid
    
    $.guard.http_request.url: constraint [conflict]
      [aontu/constraint]: Cannot unify values at path $.guard.http_request.url
      expected: re("^https://")&length(integer&min(0)&max(2048))
      actual:   "http://169.254.169.254/latest/meta-data/"
      data: data/call-http-bad.json:4:12 ("http://169.254.169.254/latest/meta-data/")
      schema: registry.aon:64:19 (re("^https://")&length(integer&min(0)&max(2048)))
    $.guard.http_request.method: empty [conflict]
      [aontu/empty]: Cannot unify values at path $.guard.http_request.method
      data: data/call-http-bad.json:5:15 ("DELETE")
      schema: registry.aon:65:13 ("GET"|"HEAD")

    Each finding names the line in the call and the line in the schema, which is the repair material an agent needs to try again.

  • The MCP server is the same guardrail over JSON-RPC. check.sh starts aontu-mcp, completes the initialize handshake, lists the tools, and calls vet for a good call and a bad one. The vet report is the tool result: a refusal comes back as verdict: invalid with isError: false, so the report is the answer the agent reads, not a protocol failure. check.sh also times 100 vets through one server process and 20 cold CLI spawns and prints both, so you can weigh holding a server open against shelling out per call on your own machine.

  • agentsmd + hash: aontu agentsmd registry.aon emits the AGENTS.md stanza (the pin, the top-level keys, the shape, and the verbs that query the document), and the stanza’s pin is byte-identical to aontu hash registry.aon, so an agent can cheaply detect that the truth changed.

What check.sh proves

  1. aontu registry.aon matches expected/registry.json byte for byte: the six tool entries, their derived requires_approval flags and the docs table come out concrete, and the type()-marked schemas stay out.

  2. aontu --canon registry.aon keeps the constraints, enums and deprecations: re("^https://"), "GET"|"HEAD" and deprecate(integer&min(0)&max(10) all appear in the canonical form.

  3. aontu model get '$.tools.delete_records' registry.aon matches expected/tool-delete-records.json: one tool’s merged truth, as a dispatcher pulls it.

  4. aontu model why '$.tools.delete_records.requires_approval' registry.aon traces the flag to its match() rule:

    $.tools.delete_records.requires_approval = true
      1. type(("readonly"|"write")|"destructive")  registry.aon:24:13
      2. boolean  registry.aon:30:22
      3. match(.side_effect,"destructive",true,false)  registry.aon:168:24
  5. data/call-search-ok.json is verdict: valid, exit 0.

  6. data/call-http-bad.json (a cleartext URL at the metadata service, method DELETE) is verdict: invalid, exit 1, with a located [aontu/constraint] finding naming re("^https://") (the transcript above).

  7. data/call-delete-extra.json carries a hallucinated cascade argument on delete_records: refused by close(), [aontu/closed] at $.arguments.cascade, exit 1.

  8. data/call-search-missing.json omits the required query: verdict: incomplete, [aontu/mapval_required] at $.guard.search_docs.query, exit 3, so the dispatcher asks for the argument instead of dispatching.

  9. data/call-unknown-tool.json names drop_database: verdict: error, no_path at the anchor $.guard.drop_database, exit 4.

  10. data/call-http-deprecated.json sends max_redirects: verdict: valid, exit 0, with the deprecation warning renamed (use $.argschemas.http_request.redirects) (since 1.4.0) in the report.

  11. data/call-ticket-dup-labels.json repeats a label: verdict: invalid, exit 1, [aontu/constraint] at $.guard.create_ticket.labels against length(integer&min(0)&max(10))&unique().

  12. registry.aon alone is not the guardrail entrypoint: vet --at '$.guard.search_docs' registry.aon data/call-search-missing.json is verdict: error with no_path, exit 4, because $.guard exists only in guard.aon.

  13. bad/rogue-tool.aon registers audit_log metadata with no argument schema: [aontu/closed]: Cannot resolve value at path $.tools.audit_log, exit 1.

  14. bad/conflicting-rate.aon restates the search_docs rate limit as 240 against the published 120: [aontu/scalar_value]: Cannot unify values at path $.tools.search_docs.rate_limit.per_minute, exit 1.

  15. aontu agentsmd registry.aon emits the <!-- aontu:begin --> stanza with the top-level keys, and its pin equals the output of aontu hash registry.aon.

  16. The MCP server: initialize answers with the server name aontu, tools/list matches expected/mcp-tools.json (vet, get, why, diff, canon, summary, subsume, breaking, set, relations, reaches, view, hash, trim, jsonschema), and the vet tool’s inputSchema requires schema and data.

  17. tools/call of vet admits the good call (valid, isError: false) and refuses the bad one with invalid and the constraint finding code.

  18. 100 vet calls are answered by one server process, and the elapsed time is printed.

  19. 20 cold-start CLI vets are timed and printed: the per-call price of shelling out instead of holding the server open.

Running it

From this directory, ./check.sh runs all 19 assertions and exits 0. The dispatcher’s two moves, by hand:

aontu registry.aon                                                        # the concrete registry
aontu vet --at '$.guard.search_docs' guard.aon data/call-search-ok.json   # vet one call at its tool's anchor