Cache compiled output-schema validators on ClientSession

Merged
JL
3mo ago
+134-9

Closes #3133.

ClientSession.validate_tool_result called one-shot jsonschema.validate() on every
tool result. That rebuilds the validator class, re-runs check_schema(), and re-crawls
$refs on each call, then throws the whole thing away. Compiling costs ~60x what
validating costs, so on an in-memory call_tool it was the dominant term. This compiles
the validator once and reuses it.

Measured end-to-end on an in-memory call_tool (median of 2000 calls after warm-up):

output schemabeforeafter
flat, two properties0.313 ms0.066 ms
nested, with $defs/$ref0.832 ms0.130 ms

Behavior is unchanged. The cache stores the schema object it compiled alongside the
validator and only reuses it on an identity match — _absorb_tool_listing assigns a
freshly parsed schema object on every listing, so a server that changes a tool's output
schema forces a rebuild rather than being served the stale validator. A failed
check_schema() is never cached, so an invalid schema still raises
RuntimeError("Invalid schema for tool ...") on every call, as before. Validation errors
still go through best_match, which is what jsonschema.validate() used internally, so
error messages are identical. The jsonschema import stays inside the method to keep it
(and its attrs/referencing tree) off the cold-start path for clients that never
validate.

Tests cover the reuse (the regression guard) and the changed-schema rebuild (the
correctness guard), plus the invalid-schema path — which was previously marked
# pragma: no cover; those pragmas are now removed.


AI assistance disclosure: this change was written with Claude Code. I reviewed the design
and the diff, ran the suite, coverage, pyright, and ruff locally, and can speak to it.