# Tool Output Governance Contract

Status: Proposed
Date: 2026-03-14
Step: `tool-output-governance-design.contract-governance-and-seams`

## Purpose

Define the contract that governs tool-result size, structure, and degradation
before any product-code changes. This document is intentionally pre-implementation:
it names the authoritative data shape, resolution points, and concrete seams,
but does not ship algorithms.

## Scope Boundary

- In scope: governed tool-result metadata, layer handoffs, config schema shape,
  override precedence, and seam inventory.
- Out of scope: implementation logic, provider-specific token estimation, and
  concrete pagination algorithms.

## Three Governance Layers

| Layer | Owner | Input | Output | Exact handoff |
| --- | --- | --- | --- | --- |
| `adapter` | Repo-owned tool wrappers around MCP/external tool results | raw tool response + resolved policy | `GovernedToolResult` | When `src/anima/shell/mcp_toolset_policy.py::PolicyWrappedToolset.call_tool` returns a normalized payload to PydanticAI |
| `current_turn` | Agent loop while the same turn is still executing | `GovernedToolResult` from adapter layer | model-visible governed payload for the active turn | When `agent.iter(...)/run.all_messages()` captures the turn in `src/anima/shell/agent.py` and `src/anima/shell/agent_interact.py` |
| `history` | Reuse/compaction path for prior turns | prior-turn governed payloads | degraded summary/placeholder form that preserves structure | When prior messages are reused or compacted via `src/anima/shell/agent_interact.py::process_interact_message`, `src/anima/core/prune.py::prune_tool_outputs`, and `src/anima/shell/agent_compaction.py::run_compaction` |

## Signatures

```python
from __future__ import annotations

from typing import Any, Literal, Mapping, Protocol, TypeAlias

JsonScalar: TypeAlias = str | int | float | bool | None
JsonValue: TypeAlias = JsonScalar | list["JsonValue"] | dict[str, "JsonValue"]

GovernanceLayer: TypeAlias = Literal["adapter", "current_turn", "history"]
GovernanceStrategy: TypeAlias = Literal[
    "passthrough",
    "summary_excerpt",
    "paginate_then_excerpt",
    "summary_only",
    "summary_placeholder",
    "failsafe_truncate",
]
BudgetSource: TypeAlias = Literal[
    "tool_default",
    "provider_profile",
    "hard_cap",
    "config",
    "env",
    "cli",
]


class LayerBudgetAccount(Protocol):
    layer: GovernanceLayer
    budget_limit: int
    budget_used: int
    budget_remaining: int
    source: BudgetSource


class ContinuationHint(Protocol):
    pagination_cursor: str | None
    continuation_hint: str | None
    has_more: bool


class GovernedToolPayload(Protocol):
    summary_payload: JsonValue | None
    excerpt_payload: JsonValue | None
    pagination: ContinuationHint | None


class GovernedToolResult(Protocol):
    tool_name: str
    layer: GovernanceLayer
    strategy: GovernanceStrategy
    original_size: int
    returned_size: int
    truncation_flag: bool
    payload: GovernedToolPayload
    budget_accounting: Mapping[GovernanceLayer, LayerBudgetAccount]


class ToolOutputGovernanceResolver(Protocol):
    def resolve_policy(
        self,
        *,
        tool_name: str,
        model_name: str | None,
        cli_overrides: Mapping[str, Any],
        env: Mapping[str, str],
    ) -> "ResolvedToolOutputPolicy": ...


class ToolOutputAdapter(Protocol):
    async def govern_source_result(
        self,
        *,
        tool_name: str,
        raw_result: Any,
        policy: "ResolvedToolOutputPolicy",
    ) -> GovernedToolResult: ...


class HistoryDegrader(Protocol):
    def degrade_for_history(
        self,
        *,
        result: GovernedToolResult,
        target_layer: GovernanceLayer,
    ) -> GovernedToolResult: ...
```

## Type Defs

### Authoritative Governed Result Shape

`GovernedToolResult` is the canonical payload shape across all three layers.

Required fields:

- `original_size`: byte count of the tool result before repo-owned governance.
- `returned_size`: byte count of the payload actually returned after governance.
- `truncation_flag`: `true` when any information was removed, summarized,
  paginated, or failsafe-truncated relative to the raw result.
- `strategy`: one of the contract strategies above; this explains why
  `returned_size <= original_size`.
- `payload.summary_payload`: durable summary suitable for current-turn reuse and
  older-history survival.
- `payload.excerpt_payload`: bounded excerpt for the active turn; may be `null`
  after history degradation.
- `payload.pagination.pagination_cursor`: opaque cursor/token when more data can
  be fetched.
- `payload.pagination.continuation_hint`: user/model-facing continuation text or
  structured hint.
- `budget_accounting`: exactly one entry per governance layer (`adapter`,
  `current_turn`, `history`) so each layer can report its own cap/usage.

Invariants:

- `original_size >= returned_size >= 0`.
- `truncation_flag` MUST be `true` for `summary_excerpt`,
  `paginate_then_excerpt`, `summary_only`, `summary_placeholder`, and
  `failsafe_truncate`.
- `payload.summary_payload` MUST survive every layer transition.
- `payload.excerpt_payload` MAY exist only in `adapter` and `current_turn`
  layers.
- `payload.pagination` MAY survive into `history`, but only as a continuation
  hint, never as raw page content.
- `budget_accounting["history"].budget_used` tracks the degraded form, not the
  raw form.

### Resolved Policy Shape

```python
class ProviderBudgetProfile(Protocol):
    name: str
    hard_cap_per_call_bytes: int
    hard_cap_per_turn_bytes: int
    prefers_pagination: bool


class ToolOutputToolDefault(Protocol):
    strategy: GovernanceStrategy
    soft_cap_bytes: int | None
    max_items: int | None
    max_depth: int | None
    preserve_structured_excerpt: bool


class ResolvedToolOutputPolicy(Protocol):
    profile_name: str
    per_call_cap_bytes: int
    per_turn_cap_bytes: int
    tool_default: ToolOutputToolDefault | None
    provider_profile: ProviderBudgetProfile
    history_strategy: Literal["summary_placeholder"]
    final_failsafe_strategy: Literal["failsafe_truncate"]
```

Resolution rule:

- Conservative adaptation is mandatory. Provider/model profiles constrain caps,
  but do not expand governance to the full runtime context window by default.

## Config Schema

The chosen config shape adds a top-level `tool_output` section to
`AnimaGlobalConfig` instead of overloading `compaction` or `defaults`. This is
the least ambiguous ownership boundary because tool-result governance spans
adapter, current-turn, and history layers, while compaction governs only older
history.

```python
class ToolOutputGovernanceConfig(Protocol):
    profile: str | None
    per_call_cap_bytes: int | None
    per_turn_cap_bytes: int | None
    history_strategy: Literal["summary_placeholder"]
    provider_profiles: Mapping[str, ProviderBudgetProfile]
    tool_defaults: Mapping[str, ToolOutputToolDefault]


class AnimaGlobalConfig(Protocol):
    defaults: object
    env: Mapping[str, str]
    tools: Mapping[str, object]
    compaction: object
    tool_output: ToolOutputGovernanceConfig
```

TOML shape:

```toml
[tool_output]
profile = "default"
per_call_cap_bytes = 120000
per_turn_cap_bytes = 240000
history_strategy = "summary_placeholder"

[tool_output.provider_profiles.default]
hard_cap_per_call_bytes = 120000
hard_cap_per_turn_bytes = 240000
prefers_pagination = true

[tool_output.tool_defaults.directory_tree]
strategy = "paginate_then_excerpt"
soft_cap_bytes = 80000
max_items = 200
max_depth = 4
preserve_structured_excerpt = true
```

Override sources and precedence:

1. CLI override (highest authority for a single run)
2. Environment override
3. Config `tool_output.*`
4. Provider/model hard-cap profile
5. Tool default
6. Built-in hard default

Chosen env keys:

- `ANIMA_TOOL_OUTPUT_PROFILE`
- `ANIMA_TOOL_OUTPUT_PER_CALL_CAP_BYTES`
- `ANIMA_TOOL_OUTPUT_PER_TURN_CAP_BYTES`

Chosen CLI override points:

- `--tool-output-profile`
- `--tool-output-per-call-cap-bytes`
- `--tool-output-per-turn-cap-bytes`

Resolution ownership:

- Schema declaration: `src/anima/core/schemas/models.py`
- Load + precedence resolution: `src/anima/shell/model_config.py`
- CLI wiring: `src/anima/shell/cli.py` and supporting CLI parameter helpers

## Behavioral Notes

### Current Turn vs Older History

Current turn:

- The active turn MAY preserve `summary_payload + excerpt_payload +
  continuation_hint`.
- The active turn MUST NOT rely on raw ungoverned tool output after the adapter
  handoff.
- Recursive failsafe truncation remains available as the last guardrail and MUST
  update `truncation_flag`, `returned_size`, and `budget_accounting`.

Older history:

- Before the next model call reuses prior messages, older tool results MUST
  degrade to `summary_payload + continuation_hint + structural placeholder`.
- `excerpt_payload` is not allowed to survive as older history.
- Compaction MAY further collapse older tool-result history into the existing
  compaction summary flow, but the governed-result contract remains the source
  shape before that collapse.

### Wrapper Behavior for Uncontrolled External Tools

`directory_tree` is the reference case for an uncontrolled external tool.

- If the upstream tool supports pagination/limit arguments, the repo-owned
  wrapper MUST prefer those controls before local truncation.
- If the upstream tool does not expose such controls, the wrapper MUST normalize
  the raw result into `summary_payload` + bounded `excerpt_payload` +
  continuation hint in this repo.
- The wrapper MUST preserve tool identity (`tool_name="directory_tree"`) and
  expose whether more content exists through `pagination_cursor` or an explicit
  continuation hint.
- The wrapper MUST apply recursive failsafe truncation after wrapper-level
  normalization, not before.

### Resolution Points

| Concern | Resolution point | Why this seam owns it |
| --- | --- | --- |
| Per-tool defaults | `src/anima/core/schemas/models.py` + `src/anima/shell/model_config.py` | defaults belong in schema/config, not ad hoc tool wrappers |
| Per-call cap | `src/anima/shell/model_config.py` | single policy resolver keeps adapter layer deterministic |
| Per-turn cap | `src/anima/shell/model_config.py` for resolution, consumed in `src/anima/shell/agent.py::AgentRunner._run_agent_loop` and `src/anima/shell/agent_interact.py::process_interact_message` | turn budget is cross-tool, so it cannot live in one wrapper |
| Env/config/CLI overrides | schema in `src/anima/core/schemas/models.py`, parsing in `src/anima/shell/model_config.py`, flags in `src/anima/shell/cli.py` plus CLI helper modules | matches existing config-resolution architecture |
| Adapter normalization | `src/anima/shell/mcp_toolset_policy.py` plus repo-owned tool wrappers | last in-repo seam before PydanticAI message assembly |
| Current-turn reuse boundary | `src/anima/shell/agent.py` and `src/anima/shell/agent_interact.py` | these are the only repo-owned call sites that pass message history back into the model |
| Older-history degradation | `src/anima/core/prune.py` and `src/anima/shell/agent_compaction.py` | history pruning/compaction already lives here |

## Seam Inventory

| Seam | Layer | Reason |
| --- | --- | --- |
| `src/anima/shell/agent_factory.py::create_agent` | adapter | attaches wrapped MCP toolsets; the resolved governance policy must enter here before any run/iter call |
| `src/anima/shell/mcp_toolset_policy.py::PolicyWrappedToolset.call_tool` | adapter -> current_turn | authoritative last-chance seam before PydanticAI stores tool results in the active turn |
| `src/anima/shell/agent.py::AgentRunner._run_agent_loop` | current_turn | initial-turn model execution path and post-run history capture |
| `src/anima/shell/agent.py::AgentRunner._process_interact_message` | current_turn | continuation seam that decides when prior history becomes older history |
| `src/anima/shell/agent_interact.py::process_interact_message` | current_turn -> history | already prunes prior tool outputs before reuse; downstream implementation must stay bounded to this seam |
| `src/anima/core/prune.py::prune_tool_outputs` | history | current placeholder behavior is the direct seam for summary/placeholder degradation |
| `src/anima/shell/agent_compaction.py::run_compaction` | history | compaction owns post-degradation collapse into summary text |
| `src/anima/shell/model_config.py::load_global_config` | config | loads persistent source values for governance settings |
| `src/anima/shell/model_config.py` future tool-output resolver adjacent to `resolve_model_context_limit` | config | existing precedence resolvers already live here; downstream work should not invent another resolver site |
| `src/anima/core/schemas/models.py::AnimaGlobalConfig` and `src/anima/core/schemas/models.py::GlobalDefaultsConfig` | config | schema source of truth for new config surface |
| external seam: `pydantic_ai.toolsets.AbstractToolset.call_tool` / message assembly internals | boundary note | outside repo control; therefore adapter-layer normalization must complete before this handoff |

Discovered helper boundary:

- No additional in-repo helper was found that assembles tool-result messages after
  `PolicyWrappedToolset.call_tool` returns. That means downstream implementation
  should treat `mcp_toolset_policy.py` as the primary current-turn handoff seam,
  not search for hidden post-processors.

## Stubs

These are file-level stubs for downstream implementation. They are intentionally
signature-only.

```python
# src/anima/core/tool_output_contracts.py

from typing import Protocol, TypeAlias

GovernanceLayer: TypeAlias = str
GovernanceStrategy: TypeAlias = str


class GovernedToolResult(Protocol):
    tool_name: str
    original_size: int
    returned_size: int
    truncation_flag: bool
    strategy: GovernanceStrategy


class ResolvedToolOutputPolicy(Protocol):
    per_call_cap_bytes: int
    per_turn_cap_bytes: int
```

```python
# src/anima/shell/tool_output_resolver.py

from typing import Any, Mapping, Protocol


class ToolOutputGovernanceResolver(Protocol):
    def resolve_policy(
        self,
        *,
        tool_name: str,
        model_name: str | None,
        cli_overrides: Mapping[str, Any],
        env: Mapping[str, str],
    ) -> object: ...
```

```python
# src/anima/shell/tool_output_adapters.py

from typing import Any, Protocol


class ToolOutputAdapter(Protocol):
    async def govern_source_result(
        self,
        *,
        tool_name: str,
        raw_result: Any,
        policy: object,
    ) -> object: ...
```

## Trade-offs

- Gain: one authoritative payload shape across adapter, current-turn, and
  history layers.
- Give up: slightly more metadata in tool payloads and explicit config surface.
- Gain: downstream tests can target named seams instead of guessing.
- Give up: provider-specific optimization remains intentionally conservative
  until a later implementation step proves a safer expansion.

## Open Questions

- Should governed sizes be measured strictly in UTF-8 bytes, or should a future
  token-aware approximation be added alongside bytes? This contract chooses bytes
  now because that is the only repo-verifiable invariant today.
- Should CLI overrides apply to all commands that construct agents, or only
  `run`/`spawn`/`chat`? This document defines the override surface but leaves the
  exact command exposure to the implementation step.

## Certainty

- [Proven] The seam inventory for `mcp_toolset_policy.py`, `agent.py`,
  `agent_interact.py`, `prune.py`, `model_config.py`, and `models.py` is grounded
  in the current codebase.
- [Likely] A dedicated top-level `tool_output` config section fits the existing
  `AnimaGlobalConfig` ownership pattern better than extending `compaction`.
- [Likely] The current-turn handoff must complete before PydanticAI internals,
  because no later repo-owned message post-processor was found.
