# Arg recovery — recover when we can, scream clearly when we can't

A rejected tool call still costs a full provider turn and returns nothing useful.
The executor therefore runs a **pre-flight recovery** layer before dispatch for
every declared args model. Recovery is the default; rejection is the last resort
with an actionable menu.

**Verified against code 2026-07-23.**

## Flattened dispatcher variants

Provider-facing schemas currently advertise the union of every action's
fields, while runtime validation uses the stricter selected Pydantic variant.
If validation fails *only* because the call includes fields owned by another
declared variant, the executor removes those cross-variant extras, revalidates
once, and emits a loud `VARIANT_RECOVERED` trace. Unknown fields and typos are
never removed. Example: `sql(action="query", query="…", table="…")` drops the
redundant `table`; `table` remains required for write variants.

## Where it lives

| Piece | Role |
|---|---|
| [`_dispatch_util.py`](_dispatch_util.py) | `coerce_stringified_containers`, `infer_missing_discriminator`, `remove_flattened_variant_extras`, `format_args_error` |
| [`executor.py`](executor.py) pre-flight | Runs inference → validate → cross-variant recovery / coerce (+ re-infer) → clear reject |
| Dispatcher implementation boundary | Re-runs inference AND cross-variant extra removal before local union validation, for any dispatcher that can be invoked outside the executor (`ctx.py`; aidream's `_cms_shared.dispatch` for the whole CMS bundle) |
| Trace sinks | `COERCED` / `INFERRED` events (file + `cx_tool_trace`) |
| Result notices | `arg_coercion_notice` / `arg_inference_notice` on dict outputs so the model learns |

## Layer 1 — missing `action` / `command` inference

Applies to **every** discriminated-union dispatcher (`web`, `context`, `sql`,
`memory`, `dataset`, `note`, `task`, `seo`, `picklist`, `cloud_file`, `skill`,
`context_patch`, …) — not a per-tool special case.

When the discriminator is omitted (or blank):

1. **Structural match** — exactly one variant has `required ⊆ present ⊆ allowed`
2. **Prefer required** — if several fit, prefer the one that *required* a present
   field over empty-required catch-alls (`{query}` → `search`, not `recall`)
3. **Unique-field vote** — a field declared on exactly one variant votes for it
   (e.g. `operations` → `json_patch`, `queries` → `search`)
4. **Conflict / nothing unique** — reject with an action menu naming each
   variant and its required fields — **never** Pydantic's
   `Unable to extract tag using discriminator 'action'`

Examples (`web`): `queries`→`search`, `url`→`read`, `urls`→`batch_read`.
Examples (`context`): `key`→`get`, `key`+`content`→`create`, `requests`→`batch`.

Dispatchers that also validate their union inside the implementation consume
the same generic inference helper there as defense in depth. This keeps direct,
wrapped, and test-host invocation paths from depending on executor pre-flight.

## Layer 2 — stringified JSON containers

A JSON *string* where a dict/list is required is decoded once and re-validated
(`coerce_stringified_containers`). Same loud `COERCED` path as before.

## Layer 3 — `context` `mode=summary` never hard-fails

`mode=summary` **always succeeds**:

1. AI summary when `summary_agent_id` is set (`summary_kind=agent`)
2. Source-backed objects → precomputed `descriptor.summary` (`summary_kind=descriptor`)
3. Otherwise first page with `fell_back_from=summary`

A rejected summary wastes a paid turn for nothing. The fallback is the product.

## Layer 4 — `context` key reconciliation (spelling only)

A requested context key that is not in the turn's manifest is reconciled when it
normalizes (lowercase, separators stripped — `Route Brief` / `route-brief` /
`routeBrief` → `route_brief`) to **exactly one** attached key. That is the same
name spelled differently, so it resolves, logs `COERCED` to both sinks, and
rides an `arg_coercion_notice` back to the model.

**Reconciliation stops there, deliberately.** More than one normalized match →
refuse and name every candidate. A merely *similar* name is never substituted:
`bundle_members` does not resolve to `bundle_member_count`, because those are
different objects and answering with the wrong one fabricates data. Near names
are surfaced as "closest existing names" inside the rejection instead.

Every unreconcilable miss returns the **full inventory of keys that do exist**
plus an explicit *do not retry* — the manifest block is ephemeral (stripped
before persistence), so by the time the model guesses wrong the list of real
keys may be many turns out of its context. Implementation + rationale:
[`implementations/ctx.py`](implementations/ctx.py).

**Not an arg-recovery case:** `context(action="create")` denied by the session's
`allow_context_create` gate. The args are well-formed and there is nothing to
recover — the fix is that the denial names an action the *model* can take
(carry the value in the reply / pass it to the consuming tool) instead of
`allow_context_create=true`, which only the caller controls.

## Layer 5 — schema-proven `action` → `type` alias

Client-delegated tools do not run a server Pydantic handler, but their arguments
still cross the shared executor before the durable row and `tool_delegated`
event. If a model supplies the platform-common `action` discriminator while
the published client-tool schema defines a `type.enum`, the executor copies the
value to `type` only when the enum contains it and the schema has no real
`action` field. Canonical/batched, unknown, and ambiguous shapes are untouched.
The repair emits loud `ALIAS_RECOVERED` traces. Clients keep the same narrow
normalization as defense in depth for mixed-version servers.

## Adding a new dispatcher

1. Register a discriminated-union `RootModel` + `$variants` in `tool_def`
2. Give each variant at least one **variant-unique** required (or unique optional)
   field so inference can fire
3. Do **not** hand-roll per-tool `if "queries" in args` — the executor already
   covers you
4. If a mode/enum value is advertised in the schema, it must either work or
   soft-fall-back — never hard-reject a reasonable synonym

## Tests

- `packages/matrx-ai/tests/test_infer_discriminator.py`
- `packages/matrx-ai/tests/test_ctx_summary_fallback.py`
- `packages/matrx-ai/tests/test_arg_error_hints.py` (coercion)

## Change Log

- 2026-08-15 — Added Layer 5 after production conversation `f659f218…`
  delegated `user({action:"notify", ...})`: the shared executor now canonicalizes
  the schema-proven alias before persistence/emission, so every client receives
  valid arguments; unknown and ambiguous values still fail normally.

- 2026-08-15 — Added Layer 4 (`context` key reconciliation). `context` was the
  top `tool_defect` in production traces: over 7 days, **60 `context_not_attached`
  FAILs across 59 conversations**, plus 12 `batch_partial_failure` / 8
  `batch_all_failed` carrying the same cause, 18 `validation` (all of them the
  create-gate denial), and 4 `matrx_validation_gate`. Root cause of the dominant
  bucket: the model pattern-completes a plausible key from the ones it can see
  (`bundle_members` × 20 convs when the turn carried `bundle_member_count`;
  `html_content` × 21 convs when it carried `html_pages_structure` + `content`),
  and the rejection carried neither the real inventory nor a stop instruction.
  Also fixed here: `page`/`summary` returned a stringified dict whenever the
  slice covered the whole value, tripping the output gate (the 4
  `matrx_validation_gate` FAILs). Tests:
  `packages/matrx-ai/tests/test_ctx_key_reconciliation.py`.

- 2026-07-30 — Closed the dispatcher-boundary gap for the CMS bundle. aidream's
  `_cms_shared.dispatch` validated the union itself and hard-422'd, so all ten
  `cms_*` tools reached outside executor pre-flight (workflow node, wrapped
  call, test host) rejected recoverable shapes — the `site`-on-a-page_id-action
  class (feedback ad5b617c, ≥15 occurrences across ≥5 conversations). It now
  runs both layers via `_recover_args`, and an ambiguous missing discriminator
  returns the action menu instead of Pydantic's tag error. Tests:
  `aidream/tools/tests/test_cms_arg_recovery.py`.
- 2026-07-11 — Added `infer_missing_discriminator` + executor `INFERRED` path;
  `context` `mode=summary` never hard-fails (descriptor / page fallback).
