"""Pydantic models for PersonaSpec, JobSpec, AgentState, and related types.

Pure data models with validation. Derived from contracts/*.schema.json.
No I/O imports.
"""

# @invar:allow file_size: Core schema type definitions remain centralized
# @invar:allow file_size_warning: Core schema type definitions remain centralized

from datetime import datetime
from importlib import import_module
from typing import Literal
import warnings

import deal
from pydantic import (
    BaseModel,
    ConfigDict,
    Field,
    computed_field,
    field_validator,
    model_validator,
)

from anima.core.schemas.enums import (
    AgentState,
    CompletionPolicy,
    ContextMode,
    LifecycleType,
    SideEffectPolicy,
    ToolPosture,
)


# =============================================================================
# Type Aliases and Validators
# =============================================================================


# CanSpawn type: controls spawning behavior
# - False: spawning forbidden
# - True: any persona allowed
# - list[str]: only listed persona names allowed
CanSpawn = bool | list[str]


@deal.post(
    lambda result: (
        isinstance(result, bool) or all(isinstance(item, str) for item in result)
    )
)
def _validate_can_spawn(v: CanSpawn) -> CanSpawn:
    """Validate can_spawn field.

    >>> _validate_can_spawn(False)
    False
    >>> _validate_can_spawn(True)
    True
    >>> _validate_can_spawn(["worker", "helper"])
    ['worker', 'helper']
    >>> _validate_can_spawn([])
    []
    >>> _validate_can_spawn(["single"])
    ['single']
    """
    if isinstance(v, bool):
        return v
    if isinstance(v, list):
        # All items must be strings
        for item in v:
            if not isinstance(item, str):
                raise ValueError("can_spawn list items must be strings")
        return v
    raise ValueError("can_spawn must be bool or list[str]")


# =============================================================================
# Pydantic Models (continued)
# =============================================================================


class PersonaSpec(BaseModel):
    """Agent capability specification.

    Validated and assembled by larva from YAML persona definitions into canonical JSON.
    See contracts/persona_spec.schema.json.

    Required fields: id, model, prompt.
    Optional fields: spec_version defaults to '0.1.0' if omitted.
    Optional fields: side_effect_policy, model_params, can_spawn, compaction_prompt.

    tools defaults to an empty mapping when omitted.
    Explicit ``tools=None`` is rejected by validation.

    Contracts:
        - spec_version must be '0.1.0'

    >>> # Invalid spec_version rejection
    >>> p4 = PersonaSpec(  # doctest: +IGNORE_EXCEPTION_DETAIL
    ...     id="test",
    ...     prompt="Test",
    ...     model="gpt-5.2",
    ...     spec_version="0.2.0"
    ... )
    Traceback (most recent call last):
    ...
    ValidationError: ...

    >>> # Extra fields forbidden
    >>> p7 = PersonaSpec(  # doctest: +IGNORE_EXCEPTION_DETAIL
    ...     id="test",
    ...     prompt="Test",
    ...     model="gpt-5.2",
    ...     extra_field="bad"
    ... )
    Traceback (most recent call last):
    ...
    ValidationError: ...

    >>> # Required fields check
    >>> p8 = PersonaSpec(
    ...     id="test",
    ...     description="A test persona",
    ...     prompt="You are a test persona.",
    ...     model="gpt-5.2"
    ... )
    >>> bool(p8.id)
    True
    >>> p8.description
    'A test persona'
    >>> p8.prompt
    'You are a test persona.'
    >>> bool(p8.model)
    True
    >>> p8.spec_version == "0.1.0"
    True

    >>> # tools: explicit per-tool posture map
    >>> p9 = PersonaSpec(
    ...     id="test-tools",
    ...     description="A test persona",
    ...     prompt="Tools test persona.",
    ...     model="gpt-5.2",
    ...     tools={"write_file": "none"}
    ... )
    >>> p9.tools == {"write_file": ToolPosture.none}
    True

    >>> # omitted tools defaults to canonical empty map
    >>> p10 = PersonaSpec(
    ...     id="test-default-tools",
    ...     description="A test persona",
    ...     prompt="Default tools persona.",
    ...     model="gpt-5.2",
    ... )
    >>> p10.model_dump()["tools"]
    {}

    >>> # tools=None is rejected (no coercion)
    >>> PersonaSpec(  # doctest: +IGNORE_EXCEPTION_DETAIL
    ...     id="test-none",
    ...     description="A test persona",
    ...     prompt="None tools persona.",
    ...     model="gpt-5.2",
    ...     tools=None
    ... )
    Traceback (most recent call last):
    ...
    ValidationError: ...

    >>> # prompt is required and non-empty
    >>> PersonaSpec(id="test-missing-prompt", description="A test persona", model="gpt-5.2")  # doctest: +IGNORE_EXCEPTION_DETAIL
    Traceback (most recent call last):
    ...
    ValidationError: ...

    >>> PersonaSpec(id="test-empty-prompt", description="A test persona", prompt="", model="gpt-5.2")  # doctest: +IGNORE_EXCEPTION_DETAIL
    Traceback (most recent call last):
    ...
    ValidationError: ...

    >>> # Optional side_effect_policy is accepted
    >>> p11 = PersonaSpec(
    ...     id="test-policy",
    ...     description="A test persona",
    ...     prompt="Policy persona.",
    ...     model="gpt-5.2",
    ...     side_effect_policy=SideEffectPolicy.read_only,
    ... )
    >>> p11.side_effect_policy
    <SideEffectPolicy.read_only: 'read_only'>

    >>> # Empty tools is valid without warning
    >>> p12 = PersonaSpec(
    ...     id="test-empty-tools",
    ...     description="A test persona",
    ...     prompt="Empty tools ok.",
    ...     model="gpt-5.2",
    ...     tools={}
    ... )
    >>> p12.tools
    {}

    >>> # Mutating tools without side_effect_policy emits warning but validates
    >>> with warnings.catch_warnings(record=True) as caught:
    ...     warnings.simplefilter("always")
    ...     p13 = PersonaSpec(
    ...         id="test-mutating-tools",
    ...         description="A test persona",
    ...         prompt="Mutating tools warning.",
    ...         model="gpt-5.2",
    ...         tools={"write_file": "read_write"}
    ...     )
    >>> p13.tools["write_file"]
    <ToolPosture.read_write: 'read_write'>
    >>> any("mutating tool posture" in str(w.message) for w in caught)
    True

    >>> # destructive tools conflict with read_only policy
    >>> PersonaSpec(  # doctest: +IGNORE_EXCEPTION_DETAIL
    ...     id="test-destructive-read-only",
    ...     description="A test persona",
    ...     prompt="Conflicting policy.",
    ...     model="gpt-5.2",
    ...     tools={"delete_file": "destructive"},
    ...     side_effect_policy=SideEffectPolicy.read_only,
    ... )
    Traceback (most recent call last):
    ...
    ValidationError: ...

    >>> # PersonaSpec no longer accepts legacy tools_profile
    >>> PersonaSpec(id="test-old", description="A test persona", prompt="Legacy field rejection.", model="gpt-5.2", tools_profile="default")  # doctest: +IGNORE_EXCEPTION_DETAIL
    Traceback (most recent call last):
    ...
    ValidationError: ...
    """

    model_config = ConfigDict(extra="forbid")

    id: str = Field(pattern=r"^[a-z0-9]+(-[a-z0-9]+)*$")
    description: str = Field(min_length=1)
    prompt: str = Field(min_length=1)
    model: str = Field(min_length=1)
    model_params: dict | None = None
    spec_version: Literal["0.1.0"] = "0.1.0"
    tools: dict[str, ToolPosture] = Field(default_factory=dict)
    side_effect_policy: SideEffectPolicy | None = None
    can_spawn: CanSpawn = Field(default=False)
    compaction_prompt: str | None = None
    spec_digest: str | None = Field(
        default=None,
        description="Content-addressable hash of the persona spec (SHA-256)",
    )

    @model_validator(mode="before")
    @classmethod
    @deal.pre(
        lambda cls, data: data.get("spec_version", "0.1.0") == "0.1.0",
        message="spec_version must be '0.1.0'",
    )
    def validate_contracts(cls, data: dict) -> dict:
        """Validate all contracts before model construction.

        Contracts:
            - spec_version must be '0.1.0'

        Examples:
            >>> PersonaSpec.validate_contracts({"spec_version": "0.1.0"})
            {'spec_version': '0.1.0'}
        """
        return data

    @field_validator("can_spawn", mode="before")
    @classmethod
    @deal.pre(
        lambda cls, v: (
            isinstance(v, bool)
            or (isinstance(v, list) and all(isinstance(item, str) for item in v))
        )
    )
    @deal.post(
        lambda result: (
            isinstance(result, bool) or all(len(item.strip()) > 0 for item in result)
        )
    )
    def validate_can_spawn(cls, v: CanSpawn) -> CanSpawn:
        """Validate can_spawn field.

        False = forbidden, True = any, list[str] = allowlist.

        Enforces:
        - uniqueItems: no duplicate entries
        - minLength: each item must have at least 1 character

        >>> PersonaSpec.validate_can_spawn(True)
        True
        >>> PersonaSpec.validate_can_spawn(["worker"])
        ['worker']
        """
        result = _validate_can_spawn(v)
        if isinstance(result, list):
            # uniqueItems validation
            if len(result) != len(set(result)):
                raise ValueError("can_spawn list must have unique items")
            # minLength for each item (already checked by _validate_can_spawn,
            # but explicit check for clarity)
            if any(len(item) < 1 for item in result):
                raise ValueError("can_spawn list items must have min length of 1")
        return result

    @model_validator(mode="after")
    @deal.post(
        lambda result: (
            len(result.id) > 0
            and len(result.model) > 0
            and result.spec_version == "0.1.0"
        )
    )
    def validate_required_fields(self) -> "PersonaSpec":
        """Ensure all required fields are populated.

            @post: result has required fields populated

        >>> p = PersonaSpec(
        ...     id="agent",
        ...     description="A test agent",
        ...     prompt="You are a test agent.",
        ...     model="gpt-5.2",
        ... )
        >>> validated = p.validate_required_fields()
        >>> (validated.id, validated.model, validated.spec_version)
        ('agent', 'gpt-5.2', '0.1.0')
        """
        # Pydantic already ensures these are set, but this serves as
        # explicit post-condition documentation
        assert self.id, "id is required"
        assert self.prompt, "prompt is required"
        assert self.model, "model is required"
        assert self.spec_version == "0.1.0", "spec_version must be '0.1.0'"

        has_destructive_tool = any(
            posture == ToolPosture.destructive for posture in self.tools.values()
        )
        if (
            has_destructive_tool
            and self.side_effect_policy == SideEffectPolicy.read_only
        ):
            raise ValueError(
                "ERROR #6: destructive tool posture conflicts with read_only side_effect_policy"
            )

        has_mutating_tool = any(
            posture in {ToolPosture.read_write, ToolPosture.destructive}
            for posture in self.tools.values()
        )
        if has_mutating_tool and self.side_effect_policy is None:
            warnings.warn(
                "WARNING #3: mutating tool posture configured without side_effect_policy",
                UserWarning,
                stacklevel=2,
            )

        return self


class GlobalDefaultsConfig(BaseModel):
    """Global default configuration values loaded from config TOML.

    Context-limit sourcing note:
    ``defaults.context_window_tokens`` is treated as optional by shell-side
    resolution logic for backward compatibility with existing configs that do
    not define the field yet.

    >>> defaults = GlobalDefaultsConfig(model="gpt-5.2")
    >>> defaults.model
    'gpt-5.2'

    >>> defaults2 = GlobalDefaultsConfig()
    >>> defaults2.model is None
    True

    >>> defaults_context_window = GlobalDefaultsConfig(context_window_tokens=120000)
    >>> defaults_context_window.context_window_tokens
    120000

    >>> # LLM timeout configuration
    >>> defaults3 = GlobalDefaultsConfig(llm_timeout=120)
    >>> defaults3.llm_timeout
    120

    >>> defaults4 = GlobalDefaultsConfig()
    >>> defaults4.llm_timeout is None
    True

    >>> defaults5 = GlobalDefaultsConfig(context_window_tokens=None)
    >>> defaults5.context_window_tokens is None
    True

    >>> # Invalid timeout rejection (< 1)
    >>> GlobalDefaultsConfig(llm_timeout=0)  # doctest: +IGNORE_EXCEPTION_DETAIL
    Traceback (most recent call last):
    ...
    ValidationError: ...

    >>> GlobalDefaultsConfig(llm_timeout=-1)  # doctest: +IGNORE_EXCEPTION_DETAIL
    Traceback (most recent call last):
    ...
    ValidationError: ...

    >>> GlobalDefaultsConfig(context_window_tokens=0)  # doctest: +IGNORE_EXCEPTION_DETAIL
    Traceback (most recent call last):
    ...
    ValidationError: ...

    >>> GlobalDefaultsConfig(context_window_tokens=-1)  # doctest: +IGNORE_EXCEPTION_DETAIL
    Traceback (most recent call last):
    ...
    ValidationError: ...
    """

    model_config = ConfigDict(extra="forbid")

    model: str | None = None
    llm_timeout: int | None = None
    context_window_tokens: int | None = None

    @field_validator("llm_timeout")
    @classmethod
    @deal.pre(lambda cls, v: v is None or v >= 1)
    @deal.post(lambda result: result is None or result >= 1)
    def validate_llm_timeout(cls, v: int | None) -> int | None:
        """llm_timeout must be >= 1 if set.

        Spec source: talk-ux-fixes.llm-call-timeout
        Per-turn LLM API call timeout in seconds.

        >>> GlobalDefaultsConfig.validate_llm_timeout(60)
        60
        >>> GlobalDefaultsConfig.validate_llm_timeout(None) is None
        True
        """
        if v is not None and v < 1:
            raise ValueError("llm_timeout must be >= 1 if set")
        return v

    @field_validator("context_window_tokens")
    @classmethod
    @deal.pre(lambda cls, v: v is None or v >= 1)
    @deal.post(lambda result: result is None or result >= 1)
    def validate_context_window_tokens(cls, v: int | None) -> int | None:
        """context_window_tokens must be >= 1 if set.

        >>> GlobalDefaultsConfig.validate_context_window_tokens(120000)
        120000
        >>> GlobalDefaultsConfig.validate_context_window_tokens(None) is None
        True
        """
        if v is not None and v < 1:
            raise ValueError("context_window_tokens must be >= 1 if set")
        return v


class ToolPresetConfig(BaseModel):
    """Tool preset configuration for MCP tool definitions.

    Defines a named tool preset resolved by CLI --tools or persona.tools keys.
    Each preset describes how to invoke an external tool command.

    Contracts:
        - command must be non-empty

    >>> tp = ToolPresetConfig(command="/usr/local/bin/mytool")
    >>> tp.command
    '/usr/local/bin/mytool'
    >>> tp.args
    []
    >>> tp.env
    {}
    >>> tp.description
    ''
    >>> tp.sandbox
    False
    >>> tp.sandbox_allow
    []

    >>> # With all fields
    >>> tp2 = ToolPresetConfig(
    ...     command="python",
    ...     args=["-m", "mytool"],
    ...     env={"DEBUG": "1"},
    ...     description="My custom tool",
    ...     sandbox=True,
    ...     sandbox_allow=["fs:/tmp", "net:example.com"]
    ... )
    >>> tp2.args
    ['-m', 'mytool']
    >>> tp2.env["DEBUG"]
    '1'
    >>> tp2.sandbox
    True
    >>> tp2.sandbox_allow
    ['fs:/tmp', 'net:example.com']

    >>> # Sandbox configuration examples
    >>> tp3 = ToolPresetConfig(command="x", sandbox=True, sandbox_allow=['fs:/tmp'])
    >>> tp3.sandbox
    True
    >>> tp3.sandbox_allow
    ['fs:/tmp']

    >>> tp4 = ToolPresetConfig(command="x")
    >>> tp4.sandbox
    False
    >>> tp4.sandbox_allow
    []

    >>> # Empty command rejection
    >>> ToolPresetConfig(command="")  # doctest: +IGNORE_EXCEPTION_DETAIL
    Traceback (most recent call last):
    ...
    ValidationError: ...

    >>> # Extra fields forbidden
    >>> ToolPresetConfig(command="echo", extra="bad")  # doctest: +IGNORE_EXCEPTION_DETAIL
    Traceback (most recent call last):
    ...
    ValidationError: ...
    """

    model_config = ConfigDict(extra="forbid")

    command: str = Field(min_length=1)
    args: list[str] = Field(default_factory=list)
    env: dict[str, str] = Field(default_factory=dict)
    description: str = ""
    sandbox: bool = False
    sandbox_allow: list[str] = Field(default_factory=list)


class ToolOutputProviderProfile(BaseModel):
    """Provider-level hard caps for tool-output governance.

    >>> profile = ToolOutputProviderProfile(
    ...     hard_cap_per_call_bytes=64000,
    ...     hard_cap_per_turn_bytes=128000,
    ... )
    >>> profile.hard_cap_per_call_bytes
    64000
    >>> profile.hard_cap_per_turn_bytes
    128000
    >>> profile.prefers_pagination
    False

    >>> ToolOutputProviderProfile(hard_cap_per_call_bytes=0)  # doctest: +IGNORE_EXCEPTION_DETAIL
    Traceback (most recent call last):
    ...
    ValidationError: ...
    """

    model_config = ConfigDict(extra="forbid")

    hard_cap_per_call_bytes: int = Field(ge=1)
    hard_cap_per_turn_bytes: int = Field(ge=1)
    prefers_pagination: bool = False


class ToolOutputModelProfile(BaseModel):
    """Model-level advisory caps for tool-output governance.

    >>> profile = ToolOutputModelProfile(per_call_cap_bytes=120000)
    >>> profile.per_call_cap_bytes
    120000
    >>> profile.per_turn_cap_bytes is None
    True
    >>> profile.expand_to_context_window
    False

    >>> ToolOutputModelProfile(per_call_cap_bytes=0)  # doctest: +IGNORE_EXCEPTION_DETAIL
    Traceback (most recent call last):
    ...
    ValidationError: ...
    """

    model_config = ConfigDict(extra="forbid")

    per_call_cap_bytes: int | None = Field(default=None, ge=1)
    per_turn_cap_bytes: int | None = Field(default=None, ge=1)
    expand_to_context_window: bool = False


class ToolOutputToolDefault(BaseModel):
    """Per-tool default shaping configuration.

    >>> directory_tree = ToolOutputToolDefault(
    ...     strategy="paginate_then_excerpt",
    ...     soft_cap_bytes=80000,
    ...     max_items=200,
    ... )
    >>> directory_tree.strategy
    'paginate_then_excerpt'
    >>> directory_tree.soft_cap_bytes
    80000

    >>> ToolOutputToolDefault(strategy="invalid")  # doctest: +IGNORE_EXCEPTION_DETAIL
    Traceback (most recent call last):
    ...
    ValidationError: ...
    """

    model_config = ConfigDict(extra="forbid")

    strategy: Literal[
        "passthrough",
        "summary_excerpt",
        "paginate_then_excerpt",
        "summary_only",
        "summary_placeholder",
        "failsafe_truncate",
    ] = "summary_excerpt"
    soft_cap_bytes: int | None = Field(default=None, ge=1)
    max_items: int | None = Field(default=None, ge=1)
    max_depth: int | None = Field(default=None, ge=1)
    preserve_structured_excerpt: bool = False


class ToolOutputGovernanceConfig(BaseModel):
    """Tool-output governance settings loaded from config TOML.

    >>> cfg = ToolOutputGovernanceConfig()
    >>> cfg.profile is None
    True
    >>> cfg.history_strategy
    'summary_placeholder'
    >>> cfg.provider_profiles
    {}
    >>> cfg.model_profiles
    {}
    >>> cfg.tool_defaults
    {}

    >>> cfg2 = ToolOutputGovernanceConfig(
    ...     profile="default",
    ...     per_call_cap_bytes=120000,
    ...     per_turn_cap_bytes=240000,
    ...     provider_profiles={
    ...         "openai": {
    ...             "hard_cap_per_call_bytes": 64000,
    ...             "hard_cap_per_turn_bytes": 128000,
    ...             "prefers_pagination": True,
    ...         }
    ...     },
    ... )
    >>> cfg2.provider_profiles["openai"].hard_cap_per_call_bytes
    64000
    """

    model_config = ConfigDict(extra="forbid")

    profile: str | None = None
    per_call_cap_bytes: int | None = Field(default=None, ge=1)
    per_turn_cap_bytes: int | None = Field(default=None, ge=1)
    history_strategy: Literal["summary_placeholder"] = "summary_placeholder"
    provider_profiles: dict[str, ToolOutputProviderProfile] = Field(
        default_factory=dict
    )
    model_profiles: dict[str, ToolOutputModelProfile] = Field(default_factory=dict)
    tool_defaults: dict[str, ToolOutputToolDefault] = Field(default_factory=dict)


class CompactionConfig(BaseModel):
    """Compaction configuration for context window management.

    Controls automatic context compaction behavior for agents.

    Contracts:
        - reserved must be >= 0

    >>> cc = CompactionConfig()
    >>> cc.auto
    True
    >>> cc.prune
    True
    >>> cc.reserved
    20000
    >>> cc.prompt is None
    True

    >>> # With all fields
    >>> cc2 = CompactionConfig(auto=False, prune=False, reserved=50000, prompt="Custom prompt")
    >>> cc2.auto
    False
    >>> cc2.prune
    False
    >>> cc2.reserved
    50000
    >>> cc2.prompt
    'Custom prompt'

    >>> # reserved = 0 is valid
    >>> cc3 = CompactionConfig(reserved=0)
    >>> cc3.reserved
    0

    >>> # reserved negative rejection
    >>> CompactionConfig(reserved=-1)  # doctest: +IGNORE_EXCEPTION_DETAIL
    Traceback (most recent call last):
    ...
    ValidationError: ...

    >>> # Extra fields forbidden
    >>> CompactionConfig(extra="bad")  # doctest: +IGNORE_EXCEPTION_DETAIL
    Traceback (most recent call last):
    ...
    ValidationError: ...
    """

    model_config = ConfigDict(extra="forbid")

    auto: bool = True
    prune: bool = True
    reserved: int = 20000
    prompt: str | None = None

    @field_validator("reserved")
    @classmethod
    @deal.pre(lambda cls, v: v >= 0)
    @deal.post(lambda result: result >= 0)
    def validate_reserved(cls, v: int) -> int:
        """reserved must be >= 0.

        >>> CompactionConfig.validate_reserved(0)
        0
        >>> CompactionConfig.validate_reserved(10)
        10
        """
        if v < 0:
            raise ValueError("reserved must be >= 0")
        return v


class AnimaGlobalConfig(BaseModel):
    """Global anima config file schema.

    Spec source: user task ``model-config.global-config-file`` requires
    support for ``~/.anima/config.toml`` with model defaults.

    >>> cfg = AnimaGlobalConfig()
    >>> cfg.defaults.model is None
    True
    >>> cfg.tools
    {}
    >>> cfg.compaction.auto
    True
    >>> cfg.compaction.prune
    True
    >>> cfg.compaction.reserved
    20000
    >>> cfg.compaction.prompt is None
    True

    >>> cfg2 = AnimaGlobalConfig(defaults={"model": "gpt-5.2-mini"})
    >>> cfg2.defaults.model
    'gpt-5.2-mini'

    >>> cfg3 = AnimaGlobalConfig(env={"OPENAI_API_KEY": "sk-openai"})
    >>> cfg3.env["OPENAI_API_KEY"]
    'sk-openai'

    >>> # With tool presets
    >>> cfg4 = AnimaGlobalConfig(tools={
    ...     "mytool": ToolPresetConfig(command="mytool", description="Custom tool")
    ... })
    >>> cfg4.tools["mytool"].command
    'mytool'
    >>> cfg4.tools["mytool"].description
    'Custom tool'

    >>> # With custom compaction config
    >>> cfg5 = AnimaGlobalConfig(compaction={"auto": False, "reserved": 50000})
    >>> cfg5.compaction.auto
    False
    >>> cfg5.compaction.reserved
    50000

    >>> AnimaGlobalConfig(unknown=True)  # doctest: +IGNORE_EXCEPTION_DETAIL
    Traceback (most recent call last):
    ...
    ValidationError: ...
    """

    model_config = ConfigDict(extra="forbid")

    defaults: GlobalDefaultsConfig = Field(default_factory=GlobalDefaultsConfig)
    env: dict[str, str] = Field(default_factory=dict)
    tools: dict[str, ToolPresetConfig] = Field(default_factory=dict)
    compaction: CompactionConfig = Field(default_factory=CompactionConfig)
    tool_output: ToolOutputGovernanceConfig = Field(
        default_factory=ToolOutputGovernanceConfig
    )


class JobSpec(BaseModel):
    """Runtime execution strategy for an agent.

    Generated by nervus on dispatch or by CLI flags for manual runs.
    See contracts/job_spec.schema.json.

    Required fields: persona_ref, instance_id, job_version.

    Contracts:
        - job_version must be '0.1.0'
        - max_turns >= 1 if set
        - max_tokens >= 1 if set
        - finalize_grace_budget >= 0
        - max_depth >= 1
        - context_mode='isolated' and context_bundle_ref cannot both be set

    >>> # Valid with defaults
    >>> j = JobSpec(persona_ref="test@abc", instance_id="inst_123")
    >>> j.persona_ref
    'test@abc'
    >>> j.instance_id
    'inst_123'
    >>> j.job_version
    '0.1.0'
    >>> j.lifecycle
    <LifecycleType.ephemeral: 'ephemeral'>
    >>> j.completion_policy
    <CompletionPolicy.explicit: 'explicit'>
    >>> j.finalize_grace_budget
    500
    >>> j.max_depth
    3

    >>> # Explicit overrides
    >>> j2 = JobSpec(
    ...     persona_ref="p@v1",
    ...     instance_id="inst_456",
    ...     timeout="30s",
    ...     max_turns=10,
    ...     max_tokens=1000,
    ...     completion_policy=CompletionPolicy.auto,
    ...     lifecycle=LifecycleType.daemon,
    ...     context_mode=ContextMode.shared,
    ...     side_effect_policy=SideEffectPolicy.read_only,
    ...     finalize_grace_budget=100,
    ...     max_depth=5
    ... )
    >>> j2.timeout
    '30s'
    >>> j2.max_turns
    10
    >>> j2.max_tokens
    1000
    >>> j2.completion_policy
    <CompletionPolicy.auto: 'auto'>
    >>> j2.lifecycle
    <LifecycleType.daemon: 'daemon'>
    >>> j2.context_mode
    <ContextMode.shared: 'shared'>
    >>> j2.side_effect_policy
    <SideEffectPolicy.read_only: 'read_only'>
    >>> j2.finalize_grace_budget
    100
    >>> j2.max_depth
    5

    >>> # Invalid version rejection
    >>> j3 = JobSpec(persona_ref="p@v1", instance_id="i1", job_version="0.2.0")  # doctest: +IGNORE_EXCEPTION_DETAIL
    Traceback (most recent call last):
    ...
    ValidationError: ...

    >>> # max_turns=0 rejection (< 1)
    >>> j4 = JobSpec(persona_ref="p@v1", instance_id="i1", max_turns=0)  # doctest: +IGNORE_EXCEPTION_DETAIL
    Traceback (most recent call last):
    ...
    ValidationError: ...

    >>> # max_tokens=0 rejection (< 1)
    >>> j5 = JobSpec(persona_ref="p@v1", instance_id="i1", max_tokens=0)  # doctest: +IGNORE_EXCEPTION_DETAIL
    Traceback (most recent call last):
    ...
    ValidationError: ...

    >>> # finalize_grace_budget negative rejection (< 0)
    >>> j6 = JobSpec(persona_ref="p@v1", instance_id="i1", finalize_grace_budget=-1)  # doctest: +IGNORE_EXCEPTION_DETAIL
    Traceback (most recent call last):
    ...
    ValidationError: ...

    >>> # max_depth=0 rejection (< 1)
    >>> j7 = JobSpec(persona_ref="p@v1", instance_id="i1", max_depth=0)  # doctest: +IGNORE_EXCEPTION_DETAIL
    Traceback (most recent call last):
    ...
    ValidationError: ...

    >>> # Extra fields forbidden
    >>> j8 = JobSpec(persona_ref="p@v1", instance_id="i1", extra_field="bad")  # doctest: +IGNORE_EXCEPTION_DETAIL
    Traceback (most recent call last):
    ...
    ValidationError: ...

    >>> # None allowed for optional fields
    >>> j9 = JobSpec(persona_ref="p@v1", instance_id="i1", timeout=None, max_turns=None)
    >>> j9.timeout is None
    True
    >>> j9.max_turns is None
    True

    >>> # context_mode='isolated' with context_bundle_ref is forbidden
    >>> j10 = JobSpec(
    ...     persona_ref="p@v1",
    ...     instance_id="i1",
    ...     context_mode=ContextMode.isolated,
    ...     context_bundle_ref="/path/to/bundle.json"
    ... )  # doctest: +IGNORE_EXCEPTION_DETAIL
    Traceback (most recent call last):
    ...
    ValidationError: ...

    >>> # context_mode='shared' with context_bundle_ref is allowed
    >>> j11 = JobSpec(
    ...     persona_ref="p@v1",
    ...     instance_id="i1",
    ...     context_mode=ContextMode.shared,
    ...     context_bundle_ref="/path/to/bundle.json"
    ... )
    >>> j11.context_mode
    <ContextMode.shared: 'shared'>
    >>> j11.context_bundle_ref
    '/path/to/bundle.json'
    """

    model_config = ConfigDict(extra="forbid")

    persona_ref: str = Field(
        description="Persona identity reference (<persona_id>@<spec_digest>) selected for this job."
    )
    instance_id: str
    job_version: Literal["0.1.0"] = "0.1.0"
    context_bundle_ref: str | None = None
    timeout: str | None = None
    max_turns: int | None = None
    max_tokens: int | None = None
    completion_policy: CompletionPolicy = CompletionPolicy.explicit
    lifecycle: LifecycleType = LifecycleType.ephemeral
    context_mode: ContextMode = ContextMode.isolated
    side_effect_policy: SideEffectPolicy = Field(
        default=SideEffectPolicy.allow,
        description=(
            "Requested side-effect policy for this job. Effective policy is merged "
            "using most_restrictive ordering (read_only > approval_required > allow)."
        ),
    )
    finalize_grace_budget: int = 500
    max_depth: int = 3

    @model_validator(mode="before")
    @classmethod
    @deal.pre(
        lambda cls, data: data.get("job_version", "0.1.0") == "0.1.0",
        message="job_version must be '0.1.0'",
    )
    @deal.pre(
        lambda cls, data: (
            data.get("max_turns") is None or data.get("max_turns", 1) >= 1
        ),
        message="max_turns must be >= 1 if set",
    )
    @deal.pre(
        lambda cls, data: (
            data.get("max_tokens") is None or data.get("max_tokens", 1) >= 1
        ),
        message="max_tokens must be >= 1 if set",
    )
    @deal.pre(
        lambda cls, data: data.get("finalize_grace_budget", 0) >= 0,
        message="finalize_grace_budget must be >= 0",
    )
    @deal.pre(
        lambda cls, data: data.get("max_depth", 1) >= 1,
        message="max_depth must be >= 1",
    )
    @deal.pre(
        lambda cls, data: (
            not (
                data.get("context_mode") == "isolated"
                and data.get("context_bundle_ref") is not None
            )
        ),
        message="context_mode='isolated' conflicts with context_bundle_ref being set",
    )
    def validate_contracts(cls, data: dict) -> dict:
        """Validate all contracts before model construction.

        Contracts:
            - job_version must be '0.1.0'
            - max_turns >= 1 if set
            - max_tokens >= 1 if set
            - finalize_grace_budget >= 0
            - max_depth >= 1

        Examples:
            >>> JobSpec.validate_contracts({"job_version": "0.1.0", "max_turns": 10})
            {'job_version': '0.1.0', 'max_turns': 10}
            >>> JobSpec.validate_contracts({"job_version": "0.1.0", "max_tokens": None})
            {'job_version': '0.1.0', 'max_tokens': None}
        """
        return data

    @field_validator("max_turns")
    @classmethod
    @deal.pre(lambda cls, v: v is None or v >= 1)
    @deal.post(lambda result: result is None or result >= 1)
    def validate_max_turns(cls, v: int | None) -> int | None:
        """max_turns must be >= 1 if set.

        >>> JobSpec.validate_max_turns(5)
        5
        >>> JobSpec.validate_max_turns(None) is None
        True
        """
        if v is not None and v < 1:
            raise ValueError("max_turns must be >= 1 if set")
        return v

    @field_validator("max_tokens")
    @classmethod
    @deal.pre(lambda cls, v: v is None or v >= 1)
    @deal.post(lambda result: result is None or result >= 1)
    def validate_max_tokens(cls, v: int | None) -> int | None:
        """max_tokens must be >= 1 if set.

        >>> JobSpec.validate_max_tokens(1024)
        1024
        >>> JobSpec.validate_max_tokens(None) is None
        True
        """
        if v is not None and v < 1:
            raise ValueError("max_tokens must be >= 1 if set")
        return v

    @field_validator("finalize_grace_budget")
    @classmethod
    @deal.pre(lambda cls, v: v >= 0)
    @deal.post(lambda result: result >= 0)
    def validate_finalize_grace_budget(cls, v: int) -> int:
        """finalize_grace_budget must be >= 0.

        >>> JobSpec.validate_finalize_grace_budget(0)
        0
        >>> JobSpec.validate_finalize_grace_budget(250)
        250
        """
        if v < 0:
            raise ValueError("finalize_grace_budget must be >= 0")
        return v

    @field_validator("max_depth")
    @classmethod
    @deal.pre(lambda cls, v: v >= 1)
    @deal.post(lambda result: result >= 1)
    def validate_max_depth(cls, v: int) -> int:
        """max_depth must be >= 1.

        >>> JobSpec.validate_max_depth(1)
        1
        >>> JobSpec.validate_max_depth(4)
        4
        """
        if v < 1:
            raise ValueError("max_depth must be >= 1")
        return v


# =============================================================================
# ContextBundle Models
# =============================================================================


class RawTailEntry(BaseModel):
    """Entry in raw conversation tail.

    Part of ContextBundle for inherited context.

    >>> e = RawTailEntry(role="user", content="Hello")
    >>> e.role
    'user'
    >>> e.content
    'Hello'
    >>> e.speaker_id is None
    True

    >>> # With speaker_id
    >>> e2 = RawTailEntry(role="assistant", speaker_id="inst_123", content="Hi there")
    >>> e2.speaker_id
    'inst_123'

    >>> # Extra fields forbidden
    >>> RawTailEntry(role="user", content="Hi", extra="bad")  # doctest: +IGNORE_EXCEPTION_DETAIL
    Traceback (most recent call last):
    ...
    ValidationError: ...

    >>> # Invalid role rejected
    >>> RawTailEntry(role="system", content="Hi")  # doctest: +IGNORE_EXCEPTION_DETAIL
    Traceback (most recent call last):
    ...
    ValidationError: ...
    """

    model_config = ConfigDict(extra="forbid")

    role: Literal["user", "assistant", "tool"]
    speaker_id: str | None = None
    content: str


class SummaryRef(BaseModel):
    """Reference to a pre-computed summary.

    Part of ContextBundle for compressed context.

    >>> from datetime import datetime
    >>> s = SummaryRef(
    ...     summary_id="sum_abc",
    ...     model_id="claude-sonnet-4-5-20250929",
    ...     created_at=datetime(2026, 2, 28, 10, 30, 0)
    ... )
    >>> s.summary_id
    'sum_abc'
    >>> s.model_id
    'claude-sonnet-4-5-20250929'
    >>> s.prompt_digest is None
    True
    >>> s.input_digest is None
    True

    >>> # With optional fields
    >>> s2 = SummaryRef(
    ...     summary_id="sum_xyz",
    ...     model_id="gpt-5.2",
    ...     prompt_digest="sha256:aabb",
    ...     input_digest="sha256:ccdd",
    ...     created_at=datetime(2026, 3, 1, 12, 0, 0)
    ... )
    >>> s2.prompt_digest
    'sha256:aabb'
    >>> s2.input_digest
    'sha256:ccdd'

    >>> # Extra fields forbidden
    >>> SummaryRef(summary_id="s1", model_id="m1", created_at=datetime.now(), extra=1)  # doctest: +IGNORE_EXCEPTION_DETAIL
    Traceback (most recent call last):
    ...
    ValidationError: ...
    """

    model_config = ConfigDict(extra="forbid")

    summary_id: str
    model_id: str
    prompt_digest: str | None = None
    input_digest: str | None = None
    created_at: datetime


class ContextBundle(BaseModel):
    """Shared context passed from parent to child agent.

    Enables sub-agents to inherit relevant conversation history
    without full context duplication.

    >>> b = ContextBundle(bundle_id="bundle_xyz")
    >>> b.bundle_id
    'bundle_xyz'
    >>> b.inherited_from is None
    True
    >>> b.raw_tail is None
    True
    >>> b.summary_refs is None
    True
    >>> b.artifact_refs is None
    True

    >>> # With all fields
    >>> b2 = ContextBundle(
    ...     bundle_id="bundle_full",
    ...     inherited_from="inst_parent",
    ...     raw_tail=[RawTailEntry(role="user", content="Hi")],
    ...     summary_refs=[SummaryRef(summary_id="s1", model_id="m1", created_at=datetime(2026, 1, 1))],
    ...     artifact_refs=["plan.json"]
    ... )
    >>> b2.inherited_from
    'inst_parent'
    >>> len(b2.raw_tail)
    1
    >>> len(b2.summary_refs)
    1
    >>> b2.artifact_refs
    ['plan.json']

    >>> # Extra fields forbidden
    >>> ContextBundle(bundle_id="b1", extra="bad")  # doctest: +IGNORE_EXCEPTION_DETAIL
    Traceback (most recent call last):
    ...
    ValidationError: ...
    """

    model_config = ConfigDict(extra="forbid")

    bundle_id: str
    inherited_from: str | None = None
    raw_tail: list[RawTailEntry] | None = None
    summary_refs: list[SummaryRef] | None = None
    artifact_refs: list[str] | None = None


class CapabilityToken(BaseModel):
    """Short-lived capability token.

    Issued by nervus at dispatch time, passed to anima via --token-file,
    presented to tela at MCP connection time via clientInfo.capability_token.

    Contracts:
        - token_id must match pattern ^tok_
        - max_depth >= 0 if set

    >>> t = CapabilityToken(
    ...     token_id="tok_abc123",
    ...     tools_profile="default",
    ...     issued_at=datetime(2026, 2, 28, 10, 0, 0),
    ...     expires_at=datetime(2026, 2, 28, 11, 0, 0),
    ...     signature="sig_xyz"
    ... )
    >>> t.token_id
    'tok_abc123'
    >>> t.tools_profile
    'default'
    >>> t.persona_ref is None
    True
    >>> t.instance_id is None
    True

    >>> # With all optional fields
    >>> t2 = CapabilityToken(
    ...     token_id="tok_full",
    ...     tools_profile="full",
    ...     persona_ref="worker@v1",
    ...     instance_id="inst_123",
    ...     max_depth=5,
    ...     issued_at=datetime(2026, 3, 1, 0, 0, 0),
    ...     expires_at=datetime(2026, 3, 1, 1, 0, 0),
    ...     signature="sig_full"
    ... )
    >>> t2.persona_ref
    'worker@v1'
    >>> t2.max_depth
    5

    >>> # token_id must start with tok_
    >>> CapabilityToken(  # doctest: +IGNORE_EXCEPTION_DETAIL
    ...     token_id="bad_token",
    ...     tools_profile="default",
    ...     issued_at=datetime.now(),
    ...     expires_at=datetime.now(),
    ...     signature="sig"
    ... )
    Traceback (most recent call last):
    ...
    ValidationError: ...

    >>> # budget field removed rejection
    >>> CapabilityToken(  # doctest: +IGNORE_EXCEPTION_DETAIL
    ...     token_id="tok_test",
    ...     tools_profile="default",
    ...     budget=1,
    ...     issued_at=datetime.now(),
    ...     expires_at=datetime.now(),
    ...     signature="sig"
    ... )
    Traceback (most recent call last):
    ...
    ValidationError: ...

    >>> # max_depth negative rejection
    >>> CapabilityToken(  # doctest: +IGNORE_EXCEPTION_DETAIL
    ...     token_id="tok_test",
    ...     tools_profile="default",
    ...     max_depth=-1,
    ...     issued_at=datetime.now(),
    ...     expires_at=datetime.now(),
    ...     signature="sig"
    ... )
    Traceback (most recent call last):
    ...
    ValidationError: ...

    >>> # Extra fields forbidden
    >>> CapabilityToken(  # doctest: +IGNORE_EXCEPTION_DETAIL
    ...     token_id="tok_test",
    ...     tools_profile="default",
    ...     issued_at=datetime.now(),
    ...     expires_at=datetime.now(),
    ...     signature="sig",
    ...     extra="bad"
    ... )
    Traceback (most recent call last):
    ...
    ValidationError: ...
    """

    model_config = ConfigDict(extra="forbid")

    token_id: str = Field(pattern=r"^tok_")
    # Spec source: contract-alignment-integration.fix-seam-proof-blockers (B1).
    # Transitional allowlist scope: CapabilityToken compatibility only.
    # Sunset criteria: remove when token contract migration drops tools_profile.
    tools_profile: str
    persona_ref: str | None = Field(
        default=None,
        description=(
            "Optional persona identity reference (<persona_id>@<spec_digest>) "
            "bound to this capability token."
        ),
    )
    instance_id: str | None = None
    max_depth: int | None = Field(default=None, ge=0)
    issued_at: datetime
    expires_at: datetime
    signature: str


class Meta(BaseModel):
    """Metadata for tracing and idempotency.

    >>> m = Meta(trace_id="trace_abc")
    >>> m.trace_id
    'trace_abc'
    >>> m.event_id is None
    True
    >>> m.idempotency_key is None
    True
    >>> m.instance_id is None
    True
    >>> m.persona_id is None
    True

    >>> # With optional fields
    >>> m2 = Meta(
    ...     trace_id="trace_xyz",
    ...     event_id="evt_1",
    ...     idempotency_key="key_abc",
    ...     instance_id="inst_123",
    ...     persona_id="persona_456",
    ... )
    >>> m2.event_id
    'evt_1'
    >>> m2.idempotency_key
    'key_abc'
    >>> m2.persona_id
    'persona_456'

    >>> # Legacy fields are rejected
    >>> Meta(trace_id="t1", spec_id="legacy")  # doctest: +IGNORE_EXCEPTION_DETAIL
    Traceback (most recent call last):
    ...
    ValidationError: ...
    >>> Meta(trace_id="t1", spec_digest="legacy")  # doctest: +IGNORE_EXCEPTION_DETAIL
    Traceback (most recent call last):
    ...
    ValidationError: ...

    >>> # Extra fields forbidden
    >>> Meta(trace_id="t1", extra="bad")  # doctest: +IGNORE_EXCEPTION_DETAIL
    Traceback (most recent call last):
    ...
    ValidationError: ...
    """

    model_config = ConfigDict(extra="forbid")

    trace_id: str
    event_id: str | None = None
    idempotency_key: str | None = None
    instance_id: str | None = Field(default=None, description="audit trail only")
    persona_id: str | None = None


class TokenUsage(BaseModel):
    """Token usage tracking.

    @post: total_tokens == prompt_tokens + completion_tokens

    >>> u = TokenUsage(prompt_tokens=100, completion_tokens=50)
    >>> u.total_tokens
    150
    >>> u.prompt_tokens = 200
    >>> u.total_tokens
    250
    >>> TokenUsage().total_tokens  # Edge: default values
    0

    >>> # Non-negative constraint
    >>> TokenUsage(prompt_tokens=-1)  # doctest: +IGNORE_EXCEPTION_DETAIL
    Traceback (most recent call last):
    ...
    ValidationError: ...

    >>> TokenUsage(completion_tokens=-1)  # doctest: +IGNORE_EXCEPTION_DETAIL
    Traceback (most recent call last):
    ...
    ValidationError: ...

    >>> # @post: total_tokens always equals sum
    >>> u2 = TokenUsage(prompt_tokens=10, completion_tokens=5)
    >>> u2.total_tokens == u2.prompt_tokens + u2.completion_tokens
    True
    """

    prompt_tokens: int = Field(default=0, ge=0)
    completion_tokens: int = Field(default=0, ge=0)
    last_request_prompt_tokens: int = Field(default=0, ge=0)

    @computed_field
    @property
    @deal.pre(lambda self: self.prompt_tokens >= 0 and self.completion_tokens >= 0)
    @deal.post(lambda result: result >= 0)
    def total_tokens(self) -> int:
        """Total tokens used. Always equals prompt_tokens + completion_tokens.

        >>> TokenUsage(prompt_tokens=7, completion_tokens=5).total_tokens
        12
        >>> TokenUsage().total_tokens
        0
        """
        return self.prompt_tokens + self.completion_tokens


class AgentResult(BaseModel):
    """Agent execution result.

    Returned when agent reaches terminal state (completed, failed, killed).

    >>> from datetime import datetime
    >>> r = AgentResult(final_state=AgentState.completed)
    >>> r.final_state
    <AgentState.completed: 'completed'>
    >>> r.result_ref is None
    True
    >>> r.token_usage is None
    True
    >>> r.error is None
    True
    >>> r.response_text is None
    True

    >>> # With all fields
    >>> r2 = AgentResult(
    ...     result_ref="results/inst_123.json",
    ...     final_state=AgentState.failed,
    ...     token_usage=TokenUsage(prompt_tokens=100, completion_tokens=50),
    ...     error="Budget exhausted",
    ...     response_text="Here is my response"
    ... )
    >>> r2.result_ref
    'results/inst_123.json'
    >>> r2.token_usage.total_tokens
    150
    >>> r2.error
    'Budget exhausted'
    >>> r2.response_text
    'Here is my response'
    """

    result_ref: str | None = None
    final_state: AgentState
    token_usage: TokenUsage | None = None
    error: str | None = None
    response_text: str | None = None


class ToolCallCount(BaseModel):
    """Count of tool calls for a single tool.

    >>> t = ToolCallCount(name="read_file", count=5)
    >>> t.name
    'read_file'
    >>> t.count
    5
    """

    name: str
    count: int = 0


class LogEntry(BaseModel):
    """Event log entry for agent state transitions.

    Written to log.jsonl, one JSON line per event.
    Per docs/INTERFACES.md Section F, serialized with 'ts' field (not 'timestamp').

    >>> e = LogEntry(
    ...     ts="2026-03-01T10:00:00Z",
    ...     event="state_transition",
    ...     details={"from": "pending", "to": "running"}
    ... )
    >>> e.ts
    '2026-03-01T10:00:00Z'
    >>> e.event
    'state_transition'
    >>> e.details
    {'from': 'pending', 'to': 'running'}

    >>> # Serialization uses 'ts' per spec
    >>> e.model_dump_json()
    '{"ts":"2026-03-01T10:00:00Z","event":"state_transition","details":{"from":"pending","to":"running"}}'

    >>> # With minimal details
    >>> e2 = LogEntry(ts="2026-03-01T10:00:00Z", event="created")
    >>> e2.details is None
    True
    """

    ts: str
    event: str
    details: dict | None = None


class AgentStateData(BaseModel):
    """Agent state persisted to state.json.

    See docs/INTERFACES.md Section F (agents/<instance_id>/state.json).

    Mutable-state boundary:
    - Owns live lifecycle state, transport handles, counters, timestamps, and
      result/error references.
    - Does not own effective tool semantics, resolved lifecycle/completion
      policy, model-context truth, or persona spawnability truth.
    - ``persona_ref`` remains a compatibility field for spawn inputs and display;
      child-agent spawnability must come from ``ResolvedExecutionContract``
      persona provenance rather than raw ``persona_ref`` presence.

    >>> # Minimal construction
    >>> a = AgentStateData(instance_id="inst_abc", persona_name="test", trace_id="tr_xyz")
    >>> a.instance_id
    'inst_abc'
    >>> a.persona_name
    'test'
    >>> a.model is None
    True
    >>> a.trace_id
    'tr_xyz'
    >>> a.state
    <AgentState.pending: 'pending'>
    >>> a.lifecycle
    <LifecycleType.ephemeral: 'ephemeral'>
    >>> a.parent_id is None
    True
    >>> a.children
    []
    >>> a.depth
    0
    >>> a.max_depth
    3
    >>> a.pid is None
    True
    >>> a.result is None
    True
    >>> a.token_usage.total_tokens
    0
    >>> a.spec_id is None
    True
    >>> a.spec_digest is None
    True

    >>> # State transition
    >>> a.state = AgentState.running
    >>> a.state.value
    'running'

    >>> # With model field
    >>> a_model = AgentStateData(
    ...     instance_id="inst_model",
    ...     persona_name="worker",
    ...     trace_id="tr_model",
    ...     model="gpt-5.2"
    ... )
    >>> a_model.model
    'gpt-5.2'

    >>> # JSON round-trip preserves model
    >>> import json
    >>> json_str = a_model.model_dump_json()
    >>> a_roundtrip = AgentStateData.model_validate_json(json_str)
    >>> a_roundtrip.model
    'gpt-5.2'

    >>> # With result
    >>> a2 = AgentStateData(
    ...     instance_id="inst_xyz",
    ...     persona_name="worker",
    ...     trace_id="tr_abc",
    ...     state=AgentState.completed,
    ...     result=AgentResult(final_state=AgentState.completed)
    ... )
    >>> a2.result.final_state
    <AgentState.completed: 'completed'>

    >>> # With spec_id and spec_digest
    >>> a3 = AgentStateData(
    ...     instance_id="inst_spec",
    ...     persona_name="reviewer",
    ...     trace_id="tr_spec",
    ...     spec_id="code-reviewer@abc123",
    ...     spec_digest="sha256:def456"
    ... )
    >>> a3.spec_id
    'code-reviewer@abc123'
    >>> a3.spec_digest
    'sha256:def456'

    >>> # Backward compatibility: agent_id in JSON is ignored
    >>> a4 = AgentStateData(
    ...     instance_id="inst_legacy",
    ...     persona_name="legacy",
    ...     trace_id="tr_old"
    ... )
    >>> # Parsing JSON with agent_id should succeed (agent_id in JSON ignored, computed property returns instance_id)
    >>> json_data = '{"instance_id":"inst_new","persona_name":"test","trace_id":"tr_1","agent_id":"ag_old"}'
    >>> a5 = AgentStateData.model_validate_json(json_data)
    >>> a5.instance_id
    'inst_new'
    >>> a5.agent_id == a5.instance_id  # agent_id is computed alias for instance_id
    True

    >>> # Backward compatibility: JSON without model field still works
    >>> json_no_model = '{"instance_id":"i1","persona_name":"p","trace_id":"t1"}'
    >>> a6 = AgentStateData.model_validate_json(json_no_model)
    >>> a6.model is None
    True

    >>> # Backward compatibility: persona_id in legacy JSON maps to persona_name
    >>> json_legacy_persona = '{"instance_id":"i2","persona_id":"legacy","trace_id":"t2"}'
    >>> a7 = AgentStateData.model_validate_json(json_legacy_persona)
    >>> a7.persona_name
    'legacy'

    >>> # Extra fields (other than legacy agent_id) forbidden
    >>> AgentStateData(  # doctest: +IGNORE_EXCEPTION_DETAIL
    ...     instance_id="i1", persona_name="p", trace_id="tr_1", extra="bad"
    ... )
    Traceback (most recent call last):
    ...
    ValidationError: ...
    """

    model_config = ConfigDict(extra="forbid")

    instance_id: str
    trace_id: str  # End-to-end trace ID (docs/INTERFACES.md Section E)
    persona_name: str
    model: str | None = (
        None  # Display/runtime snapshot only; resolved truth belongs in execution contract
    )
    persona_ref: str | None = (
        None  # Compatibility spawn input only; not authoritative spawnability truth
    )
    spec_id: str | None = None  # Persona spec ID (docs/INTERFACES.md Section E)
    # Spec source: contract-alignment-integration.fix-seam-proof-blockers (B1).
    # Transitional allowlist scope: runtime state persistence compatibility only.
    # Sunset criteria: remove in same migration as create_agent_state(..., spec_digest=...).
    spec_digest: str | None = None  # Persona spec digest (docs/INTERFACES.md Section E)
    state: AgentState = AgentState.pending
    lifecycle: LifecycleType = LifecycleType.ephemeral
    parent_id: str | None = None
    children: list[str] = Field(default_factory=list)
    depth: int = 0
    max_depth: int = 3
    pid: int | None = None
    mcp_socket: str | None = None
    created_at: str | None = None
    updated_at: str | None = None
    token_usage: TokenUsage = Field(default_factory=TokenUsage)
    tools_called: list[ToolCallCount] = Field(default_factory=list)
    current_turn: int = 0
    max_turns: int | None = None
    budget_total: int | None = None
    grace_budget: int = 500
    error: str | None = None
    result_ref: str | None = None
    result: AgentResult | None = None
    resume_after: str | None = None  # ISO timestamp for timer resume mechanism
    work_dir: str | None = None  # Path to agent working directory

    @computed_field
    @property
    @deal.post(lambda result: len(result) > 0)
    def agent_id(self) -> str:
        """Alias for instance_id for backward compatibility.

        Returns instance_id to maintain compatibility with code that
        references agent_id. Per docs/INTERFACES.md, the canonical identifier
        is instance_id; agent_id is deprecated.

        >>> a = AgentStateData(instance_id="inst_abc", persona_name="test", trace_id="tr_1")
        >>> a.agent_id == a.instance_id
        True
        >>> a.agent_id
        'inst_abc'
        """
        return self.instance_id

    @model_validator(mode="before")
    @classmethod
    @deal.post(lambda result: "agent_id" not in result and "persona_id" not in result)
    def normalize_legacy_fields(cls, data: dict) -> dict:
        """Normalize legacy fields for backward compatibility.

        Old JSON files may contain legacy fields no longer stored:
        - ``agent_id``: dropped because ``instance_id`` is canonical
        - ``persona_id``: mapped into ``persona_name``

        If both ``persona_id`` and ``persona_name`` are present and differ,
        validation fails to keep migration boundaries explicit.

        >>> data = {"instance_id": "i1", "persona_name": "p", "trace_id": "t", "agent_id": "ag_old"}
        >>> result = AgentStateData.normalize_legacy_fields(data)
        >>> "agent_id" not in result
        True
        >>> result["instance_id"]
        'i1'

        >>> legacy = {"instance_id": "i2", "persona_id": "legacy", "trace_id": "t2"}
        >>> mapped = AgentStateData.normalize_legacy_fields(legacy)
        >>> mapped["persona_name"]
        'legacy'

        >>> conflict = {
        ...     "instance_id": "i3",
        ...     "persona_name": "new",
        ...     "persona_id": "old",
        ...     "trace_id": "t3",
        ... }
        >>> AgentStateData.normalize_legacy_fields(conflict)  # doctest: +IGNORE_EXCEPTION_DETAIL
        Traceback (most recent call last):
        ...
        ValueError: ...
        """
        if not isinstance(data, dict):
            return data

        normalized = data

        if "agent_id" in normalized:
            normalized = dict(normalized)
            normalized.pop("agent_id", None)

        if "persona_id" in normalized:
            legacy_persona = normalized.get("persona_id")
            persona_name = normalized.get("persona_name")

            if (
                isinstance(legacy_persona, str)
                and isinstance(persona_name, str)
                and legacy_persona != persona_name
            ):
                raise ValueError(
                    "Conflicting legacy fields: persona_id and persona_name differ"
                )

            if "persona_name" not in normalized:
                normalized = dict(normalized)
                normalized["persona_name"] = legacy_persona

            normalized.pop("persona_id", None)

        return normalized


_EXECUTION_COMPAT_EXPORTS = {
    "ExecutionIntentOverrides": "anima.core.execution.contracts",
    "ExecutionIntentDefaults": "anima.core.execution.contracts",
    "ExecutionIntent": "anima.core.execution.contracts",
    "ResolvedRuntime": "anima.core.execution.contracts",
    "ResolvedLifecyclePolicy": "anima.core.execution.contracts",
    "ResolvedExecutionContract": "anima.core.execution.contracts",
    "ExecutionSessionState": "anima.core.execution.contracts",
    "ExecutionContractResolver": "anima.core.execution.contracts",
    "ExecutionToolAccessView": "anima.core.execution.contracts",
    "ExecutionLaunchView": "anima.core.execution.contracts",
    "ExecutionSpawnabilityView": "anima.core.execution.contracts",
    "RuntimeContextFactValue": "anima.core.execution.contracts",
    "OutputPolicyProviderProfileView": "anima.core.execution.contracts",
    "OutputPolicyPerCallCapView": "anima.core.execution.contracts",
    "OutputPolicyPerTurnBudgetView": "anima.core.execution.contracts",
    "OutputPolicyTruncationStrategyView": "anima.core.execution.contracts",
    "RuntimeOutputPolicyView": "anima.core.execution.contracts",
    "RuntimeContextFactsView": "anima.core.execution.contracts",
    "ExecutionOutputPolicyView": "anima.core.execution.contracts",
    "ExecutionModelContextView": "anima.core.execution.contracts",
    "ExecutionOutputGovernanceView": "anima.core.execution.contracts",
    "ExecutionControlView": "anima.core.execution.contracts",
    "AgentMutableStateView": "anima.core.execution.contracts",
    "EXECUTION_INTENT_VERSION": "anima.core.contracts.execution",
    "CONTRACT_SCHEMA_VERSION": "anima.core.contracts.execution",
    "EXECUTION_SESSION_VERSION": "anima.core.contracts.execution",
    "ContractSourceHashes": "anima.core.contracts.execution",
    "ContractResolverProvenance": "anima.core.contracts.execution",
    "ContractIdentity": "anima.core.contracts.execution",
    "RuntimeToolAccess": "anima.core.contracts.execution",
    "SpawnabilityReason": "anima.core.contracts.execution",
    "PersonaSpawnability": "anima.core.contracts.execution",
    "PersonaProvenance": "anima.core.contracts.execution",
    "RuntimeOutputPolicy": "anima.core.contracts.execution",
    "RuntimeModelContext": "anima.core.contracts.execution",
    "SessionTransportState": "anima.core.contracts.execution",
}


# @invar:allow missing_contract: module-level __getattr__ must raise AttributeError for unknown symbols.
def __getattr__(name: str) -> object:
    """Resolve migrated execution-contract symbols lazily for compatibility.

    >>> imported = __getattr__("ExecutionIntent")
    >>> imported.__name__
    'ExecutionIntent'
    >>> __getattr__("ContractIdentity").__name__
    'ContractIdentity'
    """
    module_name = _EXECUTION_COMPAT_EXPORTS.get(name)
    if module_name is None:
        raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

    module = import_module(module_name)
    return getattr(module, name)


# @invar:allow missing_contract: module-level __dir__ only affects introspection output.
def __dir__() -> list[str]:
    """Include compatibility exports in module introspection.

    >>> "ExecutionIntent" in __dir__()
    True
    """
    return sorted(set(globals()) | set(_EXECUTION_COMPAT_EXPORTS))
