# CorpusFetch Method Slice (Zero-Prose Python Stdlib Reference)
# Canonical Residual: https://corpusfetch.com/c/which-invariants-prevent-deterministic-replay-of-agentic-traces-when-the-tool-call-boundary-is-non-idempotent.md
# Remnant Warrant:    https://corpusfetch.com/c/which-invariants-prevent-deterministic-replay-of-agentic-traces-when-the-tool-call-boundary-is-non-idempotent.warrant.json
# Markdown SHA-256:   07aeaf972158ff2ef83cd757120b9c7051bb93fa060d801e0e1aa0b8a7dd14bc
# Code AST SHA-256:   5523432ab8239a817f8fa83acf8ab8c221aed25eb31aa693ebf5b8694b956c50
# Verified Classes:   NonReplayableTraceError
# Defensive Guards:   2 fail-closed invariants (zero external deps)

class NonReplayableTraceError(RuntimeError):
    """Raised when a trace contains a tool call that cannot be safely re-executed."""


# Tools whose results are stable across executions and safe to re-derive.
IDEMPOTENT_TOOLS = frozenset({"read_file", "query_metrics", "get_config"})

# Tools that return handles the agent may branch on; results must be injected.
HANDLE_RETURNING_TOOLS = frozenset({"pager.trigger", "payments.charge", "deploy.post"})


def assert_dynamically_replayable(trace):
    """Refuse dynamic replay if any tool call is non-idempotent or handle-returning.

    Static tuple inspection remains available; this guard only gates re-execution.
    """
    for step in trace.steps:
        tool = step.tool_call.name
        if tool in HANDLE_RETURNING_TOOLS:
            raise NonReplayableTraceError(
                f"step {step.index}: tool {tool!r} returns a handle the agent may "
                f"branch on; re-execution would regenerate the handle and diverge. "
                f"Use static inspection or inject recorded result {step.result!r}."
            )
        if tool not in IDEMPOTENT_TOOLS:
            raise NonReplayableTraceError(
                f"step {step.index}: tool {tool!r} is not classified idempotent; "
                f"dynamic replay may commit a second side effect."
            )
    return True
