Wymcp.Tool.Actions (Wymcp v0.8.7)

View Source

Answers every question about the actions a tool module declares: validating its Wymcp.Tool.actions/0 declaration wherever that declaration is checked, and obtaining action schemas out of it wherever one is read.

Both halves are one validation layer. Action-schema validation runs the same chain at each of its two moments, and runs once more — restricted to the mandatory pair — at every runtime obtaining moment, which is why validating and obtaining live together rather than in two modules: one definition of carrying a key has to serve both sides, and here it does, because check_mandatory_keys!/3 calls the chain's own validators: the schema value's shape, then the mandatory pair.

What a consumer may write into Wymcp.Tool.actions/0 is not stated here. The format catalogue belongs with the behaviour a consumer implements, in Wymcp.Tool's moduledoc under "Action schema format", and the two moments this chain runs at are listed there under "When schemas are validated". This module states the rule those moments enforce and holds the code enforcing it.

The vocabulary itself also stays with the behaviour: Wymcp.Tool is the one code home for the key list, read through Wymcp.Tool.action_schema_keys/0, and the chain here reads it through that accessor at each use rather than snapshotting it into an attribute of its own — one owner, read at run time, so the two modules carry no compile-time dependency in this direction.

The callback-surface check and the callback-shape check are different layers and stay in Wymcp.Tool, under their invariants there; validate!/1 is the callback-shape check's first part.

The action-schema invariant

Every key in the action-schema vocabulary is validated: a key outside Wymcp.Tool.action_schema_keys/0 is rejected wherever validate!/1 runs (unknown implies rejected), and every key inside it is checked by a validator in that chain which rejects at least a wrong-type value (known implies validated). That rule is the action-schema invariant.

Three clauses, each with its own enforcement:

  • unknown implies rejected — validate_known_keys!/3 subtracts the vocabulary from an action schema's keys and raises on whatever is left, so a misspelling fails at one of the moments in Wymcp.Tool's "When schemas are validated" instead of vanishing silently from help and tools/list.
  • known implies validated — Wymcp.ActionSchemaInvariantTest derives one cell per key from Wymcp.Tool.action_schema_keys/0 and asserts each one rejects a wrong-type value, so a key joining the vocabulary without a validator fails that test rather than shipping unchecked.
  • restated implies pinned — every other statement of the vocabulary is derived from Wymcp.Tool.action_schema_keys/0 or compared against it by a cell in that same test: Wymcp.Tool.action_schema/0's key set, its mandatory/optional split against Wymcp.Tool.mandatory_action_schema_keys/0, and Wymcp.Tool's "Action schema format" catalogue, which must carry a bullet for every key. The published type has no other enforcement — nothing writes @spec — so without that cell the contract Wymcp.Tool publishes and the chain this module runs can disagree while the build stays green.

The invariant is about coverage: a validator satisfies it by rejecting a wrong type. How deep validation reaches is a separate rule — wymcp validates exactly what it interprets. Below an action schema's own keys, the keywords Wymcp.Tool.KeySet walks — "properties", an "items" that is a single schema, and an "additionalProperties" schema — are the validated surface, and no other property value is checked: what else a well-typed value may contain is that key's own contract. Key type is part of the type: :properties is a map from property name to property schema, so a non-string key is a wrong type there, as a non-binary entry is in :required, and every map the walk visits below it is held to the shape the closure is read from, because that shape decides the closure dispatch enforces and help publishes. What a nested schema may hold is catalogued in Wymcp.Tool's "Action schema format", and the offences the walk reports are listed at Wymcp.Tool.KeySet.schema_offences/1. :defaults is checked against that closure as the unknown-key gate checks a call's data: every key in it, and every key inside a default wherever the walk descends, is a string the object it sits in declares (Wymcp.Tool.KeySet.default_offences/2), so no default merged into data carries a key the gate would refuse from a caller.

A schema that breaks either rule raises one ArgumentError naming every offence — the first ten, sorted by location, then a count of the rest — one per line, each with its fix; shape offences come first, and :defaults is checked only once there are none, because that check reads a well-formed schema. A location is the map holding the offence, spelled in Elixir access syntax from the action-schema key with every segment inspected — :properties["o"]["items"], or :defaults["opts"] for a key inside a default — so it names the map literal the author edits. A data path names a place in data instead, and has no spelling for a schema under "items", which no array index reaches.

A read-side corollary follows: an action schema obtained for reading carries its mandatory keys, and that is checked where it is obtained — not where a field is read. fetch_schema/2 and fetch_schemas!/1 are the only paths by which a reader obtains an action schema, and each checks the mandatory keys — the ones Wymcp.Tool.action_schema/0 writes bare — by calling the same validators the wire-in chain runs, so one definition of carrying a key serves both sides: carrying :properties means a map whose shape passes at every depth the closure walk reads. :defaults is not mandatory, and is checked at the registration moments alone. A reader holding a schema may therefore access those keys directly; a reader meeting a schema without them is looking at a tool no validator ever saw, and obtaining raises rather than let it publish an entry whose silence about :properties would read as a claim that the action takes none. Optional keys carry no such guarantee and are read with their default.

The corollary holds by construction, not by review. This module hands out two things only — a tool's action names, through action_names/1, and checked schemas — so no caller holds a schema the check has not seen. The one way left around the check is a module in lib/ calling Wymcp.Tool.actions/0 itself, and Wymcp.ObtainingMomentTest closes it: every such call sits in this module.

The check covers exactly what was obtained: the one-schema form checks one, the all-schemas form checks all. That scope is the design, and over the wire it reads as malformed-sibling isolation: a tool with one bad action schema still serves its healthy actions through tools/call, which obtains only the schema of the action it dispatches. The malformation surfaces on every surface that renders a whole tool — both of Wymcp.Help's whole-tool answers, its server index and its tool level, each of which obtains every schema a tool declares — and at the bad action's own call, which raises and is answered in the tool dialect. tools/list is on neither list: a tool's definition is built at the registration moment, so there the malformation aborts the mount module's compile instead (Wymcp.Router). Help's action level is the one-schema surface, and is consistent with dispatch: both resolve the action through fetch_schema/2, which checks only the schema it resolves. Each entry point obtains once: fetch_schema/2 resolves the name and reads the schema out of one call to Wymcp.Tool.actions/0, and a path that needs only the names takes them from action_names/1 and obtains no schema. A Wymcp.Tool.actions/0 whose result varies between calls violates the contract Wymcp.Router states, so obtaining once is defence in depth for the runtime readers rather than support for such a tool.

Summary

Functions

The action names module declares, as the wire spells them, sorted. Raises ArgumentError naming the tool when Wymcp.Tool.actions/0 does not return a map, or returns a struct.

Obtain the schema of the action named action_str on the wire, checking its mandatory keys. Returns {:ok, action, schema}, or {:error, {:unknown_action, names}} carrying the tool's action names, as action_names/1 spells them, when it declares no such action. Raises ArgumentError naming the tool, the action and the key when the resolved schema is missing one or carries it malformed.

Obtain every action schema module declares, checking each one's mandatory keys as it is obtained. Raises ArgumentError naming the tool, the action and the key on the first schema that is missing one or carries it malformed.

Validate every action schema in module. Raises ArgumentError with a descriptive message on the first malformed action — and first of all when Wymcp.Tool.actions/0, or a schema in it, is not a map or is a struct.

Functions

action_names(module)

The action names module declares, as the wire spells them, sorted. Raises ArgumentError naming the tool when Wymcp.Tool.actions/0 does not return a map, or returns a struct.

Obtains no schema, so a malformed action schema never fails it: the missing-action answer lists a tool's actions without vouching for any of them.

fetch_schema(module, action_str)

Obtain the schema of the action named action_str on the wire, checking its mandatory keys. Returns {:ok, action, schema}, or {:error, {:unknown_action, names}} carrying the tool's action names, as action_names/1 spells them, when it declares no such action. Raises ArgumentError naming the tool, the action and the key when the resolved schema is missing one or carries it malformed.

Only the resolved schema is checked, so a malformed sibling fails its own call and never this one. The name is resolved and the schema read out of one obtained map, so a tool whose Wymcp.Tool.actions/0 varies between calls cannot answer the two questions from two different maps.

fetch_schemas!(module)

Obtain every action schema module declares, checking each one's mandatory keys as it is obtained. Raises ArgumentError naming the tool, the action and the key on the first schema that is missing one or carries it malformed.

The whole-tool form, for a caller that renders or publishes every action a tool declares. A caller that needs one action's schema uses fetch_schema/2 instead, which leaves a malformed sibling to fail at its own call rather than at this one.

validate!(module)

Validate every action schema in module. Raises ArgumentError with a descriptive message on the first malformed action — and first of all when Wymcp.Tool.actions/0, or a schema in it, is not a map or is a struct.

The first part of Wymcp.Tool.validate_callback_shape!/1, and run by it at both moments a tool's schemas are checked: while a use Wymcp.Tool module itself compiles, and in Wymcp.Router.init/1 while a mount module compiles — so a misconfigured tool fails as early as its own build, and no later than the point it is wired in.