Skip to content

PyCharter — Agent-Native Contracts (AN)

Status: absorbed into ai-agent-toolkit.md · Created: 2026-07-13 · Audience: maintainers · Extends: concept-backed-contracts.md (CB-series), vocabulary-consolidation.md (VC-series) · Related: cross-package-decoupling.md, kubernetes-style-manifests.md, namespace-scoped-contract-identity.md

Why

LLM agents are becoming a primary producer of structured data, and they fail against strict ontologies in three recurring ways:

  1. Context-window inflation — teams paste whole schema registries or ontologies into prompts so the model "knows the rules", wasting tokens and degrading output quality.
  2. Semantic drift — the agent emits customer_loss where the contract says churn_rate, or misses an enum spelling by one character.
  3. Rigid failures — a binary ValidationError halts an asynchronous pipeline even when the agent's output was 95% conformant.

PyCharter already owns the primitives that solve all three — it just does not surface them as agent-facing features:

  • Validation is already graduated, not binary: Validator.validate() is non-strict by default and returns a ValidationResult carrying structured FieldDiagnostics, coerced data, and warnings (runtime_validator/_engine.py, validator_core.py, diagnostics.py — which already ships render_text / render_markdown). Severity levels, on_error: fail|warn|quarantine, the DLQ module, and ErrorMode.{STRICT,LENIENT,COLLECT} complete the soft→hard ladder.
  • Schema conversion runs both directions (get_merged_schema_from_contract, pydantic_generator/, json_schema_converter/).
  • The Vocabulary pillar gives a governed, deterministic alias substrate (field bindings, concept relationships, the suggestion-queue flywheel).
  • The MCP server already serves governed context packs to agents (mcp/server.py: fetch_concept_pack, list_concepts, query_context) with role/workspace policy filtering and no bundled LLM.
  • An optional, provider-agnostic AIProvider protocol (generate, get_embedding, NoOpProvider fallback) exists behind the [ai] extra (semantic/ai/provider.py).

The gap is the last mile of packaging, not new subsystems. This proposal names that last mile: emit contracts in LLM-ready shapes, feed diagnostics back to agents, map loose agent output onto strict fields deterministically, and expose all of it over MCP so an agent needs zero pip installs to conform.

Two architectural rules bound every decision below:

  • The runtime core stays stateless and LLM-free. Anything that affects validation is baked into the artifact at publish time (the CB rule); repair loops and providers live in the shell layer.
  • LLM/RAG extraction stays delegated to pyintake (cross-package-decoupling.md). The AN features are runtime validation UX for agent-produced data, not extraction — they consume the AIProvider protocol already resident in this package.

Decisions

AN-1 — LLM-optimized schema emission

New emission surface on the contract/validator layer:

  • to_llm_schema(*, budget_tokens: int | None = None) -> dict — a pruned JSON Schema built on get_merged_schema_from_contract: $refs inlined, enums and field descriptions kept (they are the semantic payload the model needs), governance/vendor keys (x-…, ownership, lineage, binding internals) stripped. When a token budget is given, descriptions are truncated deepest-first so the top-level shape survives.
  • to_tool_schema(provider: Literal["anthropic", "openai"]) -> dict — the same pruned schema wrapped as a ready-to-use tool definition / response_format block for the named provider.

The JIT part: per-step emission. A pipeline node holding a StepContractBlock (pipeline_artifact/models.py) can emit only its step contract, so an agent embedded mid-pipeline receives exactly the sub-schema for that single transition — never the registry.

Rejected alternative: teaching agents to fetch raw stored schemas. Stored contracts carry governance metadata and $ref indirection that inflate prompts and confuse tool-calling; a dedicated emission path is the contract-shaped equivalent of a public API response model.

AN-2 — Agent feedback rendering

ValidationResult.to_agent_feedback() -> str — a compact, model-consumable correction message rendered from the existing FieldDiagnostic list (field path, expected, found, constraint), plus a one-line instruction header ("return the corrected object only"). This is a thin, named wrapper over diagnostics.render_markdown tuned for models rather than terminals: no colour, no tables, stable ordering, bounded length.

Naming it matters more than writing it: "pass result.to_agent_feedback() back to your model" is the one-line pitch that makes the graduated-validation story legible to agent builders.

AN-3 — Bounded repair loop (shell layer)

repair_loop(emit_fn, contract, *, max_attempts=3, on_exhaust="quarantine") — a helper that validates, feeds AN-2 feedback to the caller-supplied callable (the agent), and retries; on exhaustion it escalates through the existing on_error semantics (raise, warn, or wrap in a DLQ envelope via dlq/). Async-first with a sync wrapper, matching house convention.

Boundaries:

  • Lives in the shell layer, not inside runtime_validator — the validator never calls a model.
  • Provider-agnostic: the caller owns the callable. An optional convenience adapter accepts an AIProvider (create_provider(...)) for teams already on the [ai] extra; with NoOpProvider the loop degrades to a single validation pass.
  • pyintake boundary: this is runtime validation UX, not extraction, and it introduces no new LLM dependency — so it belongs here. Flagged for maintainer review all the same.

Rejected alternative: a soft=/hard= contract lifecycle flag. The ladder already exists (severity, strict, on_error, DLQ); adding a parallel lifecycle would duplicate it. AN-2/AN-3 only add the feedback loop on top of the ladder we have.

AN-4 — SemanticMapper transformer

A new transformer in the | pipeline family (pipeline_generator/transformers/): map loosely named agent output onto contract field names before validation.

agent_step | SemanticMapper(contract="customer_profile") | validate_step

Resolution is deterministic-first, frozen at publish time:

  1. Contract-declared aliases (a new optional aliases list per field).
  2. Vocabulary-derived aliases: field bindings plus concept same_as/label relationships from the bound scheme (pinned name:version, per CB).
  3. (Authoring-time only, opt-in) embedding-assisted candidate suggestions via AIProvider.get_embedding — surfaced to the author, never applied silently.

Whatever the author accepts is baked into the pipeline artifact as a frozen alias map; the runtime mapper applies a dict lookup, nothing else. This honours the CB rule (no runtime semantic resolution, no drift) and keeps validation reproducible record-for-record.

Unresolved incoming fields at runtime are not guessed: they are reported (and optionally routed to the suggestion queue), so real agent traffic grows the vocabulary through the governed flywheel instead of silently mutating data.

Rejected alternative: per-record embedding lookup at runtime — violates determinism and the stateless core, makes validation results irreproducible, and adds a hot-path model dependency.

AN-5 — MCP server expansion

Extend mcp/server.py (same dispatch pattern, same policy filtering):

  • fetch_contract_schema — AN-1 output over MCP, namespace-aware (namespace/name/version arguments, riding the NS identity work).
  • validate_payload — the agent submits candidate output and receives is_valid plus AN-2 feedback text. An MCP-connected agent can self-correct against the registry with zero pycharter install.
  • list_contracts — namespace-scoped contract discovery, mirroring list_concepts.

This is likely the highest-leverage adoption vector in the proposal: the MCP server becomes the way any agent framework speaks to the registry, and the same governed policy layer already gates it.

AN-6 — contrib adapters

Following the contrib/fastapi / contrib/airflow / contrib/dagster pattern: one small module per framework that turns a contract into that framework's structured-output type plus a validation hook. First targets: Claude Agent SDK and Pydantic AI (both consume a Pydantic model, which pydantic_generator already produces from a contract). Each adapter is a thin import-guarded module with no new runtime deps.

AN-7 — Positioning and docs

  • A "PyCharter for AI agents" guide extending docs/guides/agent-integration-mcp.md: JIT schema → agent emits → SemanticMapper → validate → repair loop → quarantine.
  • A cookbook recipe with a runnable example against the ecommerce seed.
  • README section + llms.txt so agents themselves can discover the package.
  • Reframe existing features in agent terms: quarantine + quality score + drift detection is "agent output conformance monitoring" — the worker and Prometheus surface already exist; only the story is missing.

AN-8 — Non-goals

  • No LLM ships with pycharter; [ai] stays optional and the default path works with NoOpProvider.
  • No runtime semantic/embedding resolution in any validation or mapping hot path (see AN-4).
  • No agent-orchestration framework — pycharter supplies contracts, feedback, and mapping; the loop's driver belongs to the caller.
  • Document/RAG extraction stays in pyintake.

Execution sketch (post-approval)

  1. AN-1 emission — pruning + provider wrappers over get_merged_schema_from_contract; golden-file tests per provider.
  2. AN-2 feedback — to_agent_feedback() on ValidationResult; snapshot tests.
  3. AN-5 MCP tools — fetch_contract_schema / validate_payload / list_contracts; policy-filter tests mirror the concept tools. (Ships early — it depends only on AN-1/AN-2.)
  4. AN-3 repair loop — shell helper + DLQ escalation; unit tests with a scripted fake agent (converges, exhausts, escalates).
  5. AN-4 SemanticMapper — alias schema key, vocabulary-derived resolution, publish-time freezing, suggestion-queue routing; config-change review per /config-change (touches the contract artifact format).
  6. AN-6 adapters + AN-7 docs — last; they demo everything above.

Risk register

Risk Mitigation
Scope creep toward an agent framework AN-8 non-goals; the loop driver is caller-owned.
Alias maps drifting from vocabulary edits Maps are frozen into the artifact with pinned scheme versions; re-publish re-derives them (CB semantics).
validate_payload exposing schemas across workspaces Same requester/policy filtering as fetch_concept_pack; contract tools reuse the existing context builder.
Provider tool-schema formats churning Golden-file tests per provider; wrappers are leaf code, one file each.
Contract format change (field aliases) breaking codecs Optional key, ignored by ODCS export initially; KM round-trip gate extended in the same change.

Acceptance

  • An agent given to_tool_schema("anthropic") for one pipeline step receives only that step's pruned schema — no registry dump, no governance keys.
  • A record failing validation yields to_agent_feedback() text that, fed back to a scripted fake agent in tests, converges inside repair_loop within the attempt budget; exhaustion lands the record in the DLQ with diagnostics attached.
  • SemanticMapper renames user_email → email_address from a frozen alias map with no model call at runtime; an unknown incoming field is reported and (when enabled) queued as a suggestion, never guessed.
  • An MCP client with no pycharter install lists contracts, fetches a pruned schema, and validates a payload — all policy-filtered.
  • ./scripts/ci.sh green; docs build green.