How must an instance handle tool side-state leakage between invocations?

**Date of Record:** 2026 October 27 **Scope:** Applies when an agentic session invokes a stateful tool (session-scoped API, cookie-based auth, cached connection) multiple times, and state from one invocation leaks into subsequent invocations, causing behavior drift based on prior calls. Does not apply to strictly stateless tools or tool invocations with explicit state management.

Core Distinction

The separation between **per-invocation semantics** (each call should be independent) and **invocation-history semantics** (each call's result depends on prior calls because tool state persists). Relying on per-invocation semantics creates the **State Leakage Drift**: the tool behaves differently on call N than on call 1 because of accumulated state, and the agent, expecting idempotent behavior, misinterprets the results or makes decisions based on stale tool state.

What Was Observed

In stateful tool-invocation architectures, failing to handle leakage produces three primary failure modes:

  1. **Auth Context Accumulation:** A tool uses cookie-based auth. First call sets a cookie. Second call inherits the cookie but with escalated scope from a prior operation. Third call executes with unintended privileges.
  2. **Cache Confusion:** A tool caches results within the session. Call 1 returns cached data. The underlying data changes. Call 2 returns the stale cache, not the current data, without indication.
  3. **Session Limit Exhaustion:** A tool has a per-session rate limit. Multiple invocations in a chain exhaust the session, and subsequent calls fail even though per-invocation limits are respected.

Recorded Elements

  1. **`tool_state_scope_receipt`:** For each invocation, the exact state scope (what state the tool might carry forward), so the session knows which calls are independent.
  2. **`invocation_state_delta_signature`:** For each invocation, the state changes the invocation introduced (cookies set, cache entries, counters incremented), allowing tracking of accumulated state.
  3. **`invocation_isolation_policy_receipt`:** The explicit policy for how tool state should be handled across invocations (isolated per call, shared per session, or shared per agent), preventing assumptions.
  4. **`state_leakage_detection_signature`:** For invocations whose results suggest state from prior calls, the cryptographic record of the leakage and its potential impact.

Condition of Validity

* **Applies when:** Tools have per-session or per-agent state, invocations may leak state across calls, and downstream decisions depend on invocation independence. * **Does not apply when:** Tools are strictly stateless, or the session explicitly manages tool state between calls.

Decision Boundary

  1. **Does the tool have session-scoped or per-agent state?** If no, per-invocation semantics apply. If yes, proceed.
  2. **Does the `tool_state_scope_receipt` define the state boundaries?** If no, the session cannot know what state carries forward. The tool must provide this.
  3. **Is the `invocation_state_delta_signature` tracked across invocations?** If yes, accumulated state is known. If no, the session may be surprised by state accumulation.
  4. **Conversely, when can a session ignore state leakage?** Only when the tool's operations are strictly additive (state accumulation has no effect on outcomes), or the session actively manages state (resets between calls).

Operational Effect

  1. **Auth Context Integrity:** Unintended privilege accumulation is prevented.
  2. **Cache Freshness Awareness:** Stale cache returns are surfaced, not hidden.
  3. **Session Limit Transparency:** Per-session limits are visible, allowing the session to plan.
from dataclasses import dataclass
from enum import Enum, auto
import hashlib
import json

class StateVerdict(Enum):
    STATELESS_TOOL = auto()
    ISOLATED_INVOCATIONS = auto()
    STATE_LEAKAGE_DETECTED = auto()
    SESSION_LIMIT_EXHAUSTED = auto()

@dataclass(frozen=True)
class ToolInvocation:
    invocation_id: str
    tool_id: str
    has_session_state: bool
    state_delta_hash: str | None
    session_usage_ratio: float
    result_hash: str

@dataclass(frozen=True)
class StateLeakContext:
    prior_state_deltas: tuple[str, ...]
    isolation_policy: str  # "ISOLATED" | "SESSION_SHARED" | "AGENT_SHARED"

@dataclass(frozen=True)
class StateLeakPolicy:
    require_isolation_for_critical: bool
    max_session_usage_ratio: float

@dataclass(frozen=True)
class StateLeakManifest:
    verdict: StateVerdict
    directive: str
    manifest_digest: str

class ToolStateLeakageGuard:
    @staticmethod
    def _hash(data: str) -> str:
        return hashlib.sha256(data.encode("utf-8")).hexdigest()

    @classmethod
    def evaluate_invocation(
        cls, inv: ToolInvocation, ctx: StateLeakContext, policy: StateLeakPolicy
    ) -> StateLeakManifest:
        
        # Gate 1: Stateless
        if not inv.has_session_state:
            digest = cls._hash(json.dumps({
                "invocation": inv.invocation_id,
                "tool": inv.tool_id,
                "type": "STATELESS"
            }, sort_keys=True))
            return StateLeakManifest(
                verdict=StateVerdict.STATELESS_TOOL,
                directive=f"PROCEED: Tool {inv.tool_id} is stateless.",
                manifest_digest=digest
            )

        # Gate 2: Session limit
        if inv.session_usage_ratio >= policy.max_session_usage_ratio:
            return StateLeakManifest(
                verdict=StateVerdict.SESSION_LIMIT_EXHAUSTED,
                directive=f"HALT: Session usage {inv.session_usage_ratio:.2%} >= {policy.max_session_usage_ratio:.2%}.",
                manifest_digest=cls._hash(f"SESSION_LIMIT:{inv.invocation_id}")
            )

        # Gate 3: Isolation policy
        if policy.require_isolation_for_critical and ctx.isolation_policy != "ISOLATED":
            return StateLeakManifest(
                verdict=StateVerdict.STATE_LEAKAGE_DETECTED,
                directive=f"HALT: Policy requires isolation but tool is {ctx.isolation_policy}.",
                manifest_digest=cls._hash(f"NO_ISOLATION:{inv.invocation_id}")
            )

        # Gate 4: State delta accumulation check
        if ctx.prior_state_deltas and len(ctx.prior_state_deltas) >= 3:
            digest = cls._hash(json.dumps({
                "invocation": inv.invocation_id,
                "tool": inv.tool_id,
                "prior_deltas": str(len(ctx.prior_state_deltas)),
                "current_delta": inv.state_delta_hash
            }, sort_keys=True))
            return StateLeakManifest(
                verdict=StateVerdict.STATE_LEAKAGE_DETECTED,
                directive=f"WARN: {len(ctx.prior_state_deltas)} prior state deltas accumulated for {inv.tool_id}.",
                manifest_digest=digest
            )

        # Gate 5: Isolated
        digest = cls._hash(json.dumps({
            "invocation": inv.invocation_id,
            "tool": inv.tool_id,
            "policy": ctx.isolation_policy,
            "session_usage": f"{inv.session_usage_ratio:.2%}"
        }, sort_keys=True))
        
        return StateLeakManifest(
            verdict=StateVerdict.ISOLATED_INVOCATIONS,
            directive=f"PROCEED: Invocation {inv.invocation_id} with {ctx.isolation_policy} isolation.",
            manifest_digest=digest
        )