Metadata-Version: 2.4
Name: keble-agentic-chat
Version: 3.42.0
Author-email: zhenhao-ma <bob0103779@gmail.com>
Requires-Python: <3.14,>=3.13
Requires-Dist: keble-db<2.0.0,>=1.1.2
Requires-Dist: keble-exceptions<2.0.0,>=0.0.15
Requires-Dist: keble-helpers<2.0.0,>=1.41.0
Requires-Dist: langgraph-checkpoint-mongodb>=0.1.0
Requires-Dist: langgraph-checkpoint>=4.1.1
Requires-Dist: langgraph>=1.2.4
Requires-Dist: pydantic-ai-slim<2.0.0,>=1.102.0
Requires-Dist: tenacity<10.0.0,>=9
Provides-Extra: test
Requires-Dist: keble-agentic-override<1,>=0.0.1; extra == 'test'
Requires-Dist: keble-rag<2,>=1.0.1; (python_version >= '3.13') and extra == 'test'
Requires-Dist: pydantic-ai-slim[openai]<2.0.0,>=1.102.0; extra == 'test'
Requires-Dist: pydantic-settings<3.0.0,>=2.2.1; extra == 'test'
Requires-Dist: pyright<2.0.0,>=1.1.403; extra == 'test'
Requires-Dist: pytest-asyncio<0.24.0,>=0.23.6; extra == 'test'
Requires-Dist: pytest<9.0.0,>=8.2.0; extra == 'test'
Description-Content-Type: text/markdown

# keble-agentic-chat

`keble-agentic-chat` owns the generic, host-neutral chat runtime used by Keble
services that run Pydantic-AI agents. Version `1.0.x` is a breaking storage and
API release: timeline entries are the only public read-side contract, and raw
provider messages are private resume state only.

> **Resume-created pending action bridge (3.41.1):** every deferred PAUSE that
> persists pending client/server action rows must also persist
> `chat.pending_resume`. Fresh user turns use the user row as the origin; RESUME
> turns that immediately ask for the next browser confirmation carry the prior
> origin metadata forward. This keeps `ChatAction` rows and
> `ChatPendingResume.pending_action_ids` aligned so backend resolve guards do not
> reject valid guided-bootstrap clicks with "chat has no pending resume state".

## Testing

Default fast tests are offline and exclude live, slow, eval, and local-stack
storage tests:

```bash
uv run pytest -m "not live and not slow and not eval and not local_stack"
```

The shared marker vocabulary matches the rest of Keble:

- `unit`: deterministic Python, schema, runtime, and fake-model tests.
- `contract`: recorded or fixture-based external contract tests.
- `integration`: real local dependency tests.
- `live`: real external service calls.
- `eval`: AI quality or retrieval quality tests.
- `db`, `mongo`, `qdrant`, `db_stack`, `local_stack`: database and stack layers.
- `llm`, `embedding`, `keepa`, `keepa_contract`, `keepa_live`: external model/API layers.

Storage-backed tests that use MongoDB, Redis, or Qdrant fixtures are marked
`integration`, `db`, and `local_stack` during collection and require:

```bash
RUN_INTEGRATION=1 uv run pytest -m integration
```

Real LLM canaries are marked `live` and `llm`, and require:

```bash
RUN_LLM_LIVE=1 uv run pytest -m "live and llm"
```

Before finishing Python changes, run:

```bash
uv run pytest -m "not live and not slow and not eval and not local_stack"
npx --yes pyright .
```

Side effect if changes:
- `tests/conftest.py::pytest_collection_modifyitems` owns test-layer markers for
  this package.
- Default local and CI runs stay offline unless a worker explicitly enables
  `RUN_INTEGRATION=1` or `RUN_LLM_LIVE=1`.
- Backend/package consumers can rely on the same marker names used by
  `keble-db.testing` and `keble-keepa.testing`.

> **Agent-core contract collapse (3.39.0):** general session tools and provider
> manifests name `keble_helpers.AgentToolRegistrationConfig`,
> `AgentToolDescriptor`, `AgentContext`, and `AgentLifecycleResult` directly.
> Current-line code should use the typed `approval` vocabulary and must not
> reintroduce the retired alias models.

> **Owner-list chat run liveness (3.40.0):** `ChatRunActionType` and
> `ChatRunStatusChangedPayload` define the package-owned workspace event
> `CHAT_RUN_STATUS_CHANGED`. Backend task-list sockets forward it to CHAT
> subscribers so the frontend sidebar can render active chat runs without polling.

> **Agent-core identity projection (3.39.0):** this package bundles the
> helper-owned provider/agent identity vocabulary. Provider origins derive from
> `KebleProviderId.origin`; package code should not hand-map provider/source
> strings.

> **Client-tool primitives (3.38.0):** generic browser client tools now include
> `Select`, `MultiSelect`, `Number`, and `Money` payload/result pairs beside
> `Confirm` and `TextInput`. Payload objects own semantic result validation
> through `validate_result(...)` so stored browser results stay compact while
> domain packages can reject stale option values, out-of-range numbers, and
> cross-currency money answers before persistence.

> **Engine status (2.3.0):** `LangGraphChatRuntime` (`runtime/langgraph/runtime.py`)
> is the **sole turn engine** — pydantic-ai executes, LangGraph controls flow. It
> exposes `arun_turn` / `aresolve_action` / `astream_turn` / `astream_resolve_action`
> over a compiled graph + a `ChatHistoryStore`, with streaming, thinking,
> interrupt/resume (Mongo checkpointer), compaction, per-run cost, and
> `ChatMemoryStore` recall/remember.
>
> **`AgenticChat` (2.3.0) is the thin per-scope SSOT holder** (`runtime/agentic_chat.py`)
> — NOT the removed 2.x god-engine. One instance per chat scope owns the durable
> store + cooperative run-control, and builds the per-turn engine via
> `abuild_runtime(graph=..., memory=..., compactor=...) -> LangGraphChatRuntime`.
> It satisfies the framework-neutral `keble_helpers.ChatScopeRuntimeProtocol`, so
> hosts (and future agent packages) type against that contract without importing
> this package. Surface: `.store`, `arequest_interrupt(...)`, `abuild_runtime(...)`.
> The v1 timeline-first `AgenticChat` turn engine + its `legacy|langgraph` flag were
> **removed** in 2.2.0; the long `AgenticChat(...)` snippets further down are
> **historical** (they used the removed engine's constructor) — the new class takes
> only `store=` + `run_control_stale_seconds=`. `service.py` holds only the
> canonical `ChatActionResolutionError`. The store classes
> (`InMemoryAgenticChatStore` / `MongoAgenticChatStore` / `RedisCachedAgenticChatStore`)
> are unchanged.
>
> **Memory contracts moved to keble-helpers (2.7.0):** `ChatMemoryRecord`,
> `ChatMemoryKind`, and `ChatMemoryStoreProtocol` are owned by framework-neutral
> `keble_helpers` (1.16.0) so other packages produce/consume the same records
> without importing this engine. `runtime/langgraph/memory.py` re-exports them
> (`ChatMemoryStore` stays the engine-side alias) and keeps `NoOpChatMemoryStore`.
> Recall scoping is locked: cross-chat per `(owner, scope_type)`;
> `scope_id`/`chat_id` are write-side provenance metadata, not recall filters.
> Per-turn episodes are built with `ChatMemoryRecord.episode(...)` (no free-string
> kinds). Half-resolved action batches raise the typed `ChatActionResolutionError`
> (2.7.0) instead of a raw `ValueError`.

## Version 3.35.0 Client-tool envelope helper adoption

`request_client_action` now builds `ChatAction.request` through
`keble_helpers.build_client_action_request_from_raw(...)` (helpers 1.39.1). The
runtime stays domain-neutral: host packages still own marketplace/report/etc.
payload schemas, while this package validates only the common `{tool_type,
payload}` envelope shape.

This is an additive Stage B migration step. Stored action request shape remains
the existing snake-case raw dict; helpers readers accept both snake/camel during
the downstream backend, `keble-core`, and frontend migration.

## Version 3.35.1 Helpers 1.39.2 alias-fix refresh

Private wheel refresh only: `keble-agentic-chat` now bundles
`keble-helpers` `1.39.2`, where typed client-tool payload builders preserve
camel-case nested payload aliases. Runtime request shape and chat behavior are
unchanged from 3.35.0.

## Version 3.36.1 Helpers 1.40.0 identity projection refresh

Private wheel refresh only: `keble-agentic-chat` now bundles
`keble-helpers` `1.40.0`, which adds
`KebleProviderId.for_provider(...)`. Runtime request shape and chat behavior
are unchanged from 3.36.0; this release exists so positioning, segmenting,
task, and decision packages can consume the same helper wheel during Stage B
Phase 4.

## Version 3.38.0 Generic Client-Tool Primitives

`keble_agentic_chat.schemas.client_primitives` now exports:

- `ClientToolOption`
- `SelectClientToolPayload` / `SelectClientToolResult`
- `MultiSelectClientToolPayload` / `MultiSelectClientToolResult`
- `NumberClientToolPayload` / `NumberClientToolResult`
- `MoneyClientToolPayload` / `MoneyClientToolResult`

Side effect if changes:
- AMZ guided bootstrap and future frontend client tools can render standard
  select, multi-select, number, and money questions without bespoke result
  schemas.
- Host packages should call each payload's `validate_result(...)` before
  accepting a browser result, because the stored result intentionally carries
  only the compact answer.
- The `Money` primitive reuses `keble_helpers.typings.Money`; do not introduce a
  second `{amount, currency}` shape in agentic-chat.

## Agentic schema convention (3.33.0)

GENUINE pydantic-ai tool I/O schemas — the typed input to / return projection of
an `@agent.tool`, and the tool-registration config for such tools — are named
with the `*ForAgent` suffix (configs with an `Agent` infix, e.g.
`*AgentToolsConfig`) and live in **one** module,
`keble_agentic_chat/schemas/for_agent.py`. The convention guard
`tests/test_schemas/test_for_agent.py` enforces both the naming and the
placement.

This package is a MIXED BAG: most `*View` classes are NOT tool payloads and
keep their names and locations:

- `BackgroundSessionStatusForAgent` (`schemas/for_agent.py`) — the RETURN
  projection of the host-neutral session-status tools in
  `runtime/general_tools.py` and the `SubAgentBinding` protocol. Renamed from
  `BackgroundSessionStatusView` in 3.31.0 (re-landed on main in 3.33.0).
- `GeneralAgentToolsConfig` (`schemas/for_agent.py`) — tool-registration config
  for `register_general_tools`. Renamed from `GeneralToolsConfig` in 3.31.0
  (re-landed on main in 3.33.0).
- KEPT as read models (NOT tool payloads): `ChatHistoryView`,
  `ChatRunStatusView`, `ChatActionDisplay`, and `SubAgentJobView` — the
  jobs-panel SSOT carried on `ChatHistoryView.subagent_jobs` and live events, a
  display projection rather than a tool I/O contract.

This rename is code-breaking with NO aliases: consumers importing
`BackgroundSessionStatusView` / `GeneralToolsConfig` must switch to the new
names from `keble_agentic_chat.schemas.for_agent` (both are also re-exported
from the package root and `keble_agentic_chat.schemas`).

## Version 3.18.1 Provider-Guided Async Waits

`SUBAGENT_DELEGATION_PROMPT_RULES` now tells parent agents to inspect the
delegate tool result and provider-owned guidance before ending a turn. If a
provider returns a child session that must finish before final success, the
parent must make exactly one `await_background_sessions` call with the returned
full `session_id` values before giving the final answer. Otherwise the previous
"report started and end" behavior still applies.

This keeps async monitoring single-source: generated delegate tools provide the
typed child references, `await_background_sessions` is the only sanctioned wait,
and providers such as AMZ can require honest completion without adding a second
wait tool or backend-specific prompt branch.

## Version 3.16.8 Scoped-Agent ToolScope Enforcement

`adrive_scoped_agent(...)` now verifies the freshly built child agent's function
tools against `AgentRegistration.tool_scope.tool_names` before the generated
`conclude` tool is mounted. A SCOPED_AGENT with provider/family metadata but no
materialized `tool_names` allowlist now fails with `SubAgentToolScopeViolation`
instead of treating the scope as advisory.

Hosts that build scoped child agents must resolve provider/family scopes into
concrete tool names from the same provider manifests they compose. This keeps
SYNC and ASYNC scoped-agent runs from silently exposing mutation, delegation, or
unregistered tools.

## Version 3.16.7 Subagent Prompt Export Cleanup

The old `BACKGROUND_SESSION_PROMPT_RULES` public name is removed. The canonical
prompt discipline is now `SUBAGENT_DELEGATION_PROMPT_RULES`, owned by
`subagents/delegate_tools.py` alongside generated `delegate_*` tools. Runtime
status tools still expose `check_background_sessions`, `update_background_session`,
`cancel_background_session`, and `await_background_sessions` for durable stored
session views, but they no longer own delegation prompt copy.

## Version 3.16.6 Sync Runner Cleanup

The generated delegate tool path no longer reads model fields through
`getattr`, and `SyncSubAgentRunner` now owns its digest cap/evidence merge
helpers as class methods. This keeps the SYNC scoped-agent path single-owner
without changing the typed spawn spec or `AgentLifecycleResult` contract.

## Version 3.16.5 Scoped-Agent Host Context

`adrive_scoped_agent(...)` now passes `SubAgentRunContext.host_context` into
`Agent.run(..., deps=...)` on every turn. Backend-built SCOPED_AGENT factories
can therefore use the same typed dependency object as parent console tools
without separate context injection or package-specific global state.

## Version 3.16.4 Sync Scoped-Agent Runner

`subagents/sync_runner.py` now delegates SYNC scoped-agent execution to the
bounded `adrive_scoped_agent(...)` driver instead of running a one-turn
`agent.run(...)` path. SYNC delegates therefore share the generated typed
`conclude` tool, output-model validation, deterministic-check retry, and
archetype budgets with ASYNC scoped sessions.

The runner formats the typed `AgentLifecycleResult` as a capped JSON digest and
drains child evidence onto the parent delegate tool call through the canonical
`EvidenceRecorder.attach()` path, so in-turn helpers such as `quarantine_digest`
can surface their source chips on the visible action row.

## Version 3.16.3 Bounded Scoped-Agent Driver

`subagents/scoped_agent_driver.py` now has a real bounded SCOPED_AGENT driver
instead of only the initial prompt helper. `adrive_scoped_agent(...)` builds the
archetype agent, mounts a generated `conclude` tool, carries message history
between turns, honors stop and queued-instruction hooks, enforces the archetype
turn/wall/token budgets, and returns a typed `AgentLifecycleResult`.

The generated `conclude` schema exposes `status`, `verdict`, `narrative`, and
the archetype output fields. `ACHIEVED` cannot be recorded unless the output
model validates; deterministic acceptance checks run after the tool call and a
failed check clears the conclusion before retrying with the failed criterion.
This makes scoped-agent success depend on the archetype contract rather than a
parent-authored prose goal.

## Version 3.16.2 Workflow Checkpointer Compilation

`subagents/workflow_driver.py` now compiles raw LangGraph `StateGraph`
builders with the host-supplied checkpointer before invoking or resuming a
WORKFLOW archetype. Already compiled graphs and the graph-like backend adapter
objects still pass through unchanged.

This closes the resumability part of the WORKFLOW driver contract: provider
packages may return an uncompiled `StateGraph`, and the host controls durable
thread storage through the shared checkpointer keyed by `run.session_id`.

## Version 3.16.1 Workflow Interrupt/Resume Driver Completion

`subagents/workflow_driver.py` now handles the workflow control-flow contract
expected by typed WORKFLOW archetypes:

- initial invocation uses the validated archetype input model and stable
  `thread_id = run.session_id`;
- LangGraph-style `__interrupt__` values carrying `EscalationQuestion` are
  persisted through `run.escalation_gate.araise(...)` and return an
  `ESCALATED` conclusion;
- `aresume_workflow(...)` sends `Command(resume=<EscalationAnswer JSON>)` and
  validates the resumed terminal output through the archetype output model and
  deterministic acceptance checks.

The driver still accepts the existing graph-like objects used by backend
workflow adapters, so this patch tightens the framework contract without
reopening any free-text child-goal path.

## Version 3.16.0 Removed Legacy Free-Text Session Contracts

New child work is created only through generated `delegate_*` subagent tools
backed by typed archetype input models and `SubAgentSpawnSpec`. The old
free-text session creation surface and autonomous goal driver are deleted from
the public package API, not shimmed.

- `schemas/subagent.py` is the single child-session schema module.
- `SubAgentSpawnSpec` forbids extra fields, so a model or host cannot sneak in
  a `goal` escape hatch.
- `SubAgentBinding` is the only host protocol for async subagent sessions,
  supervision verbs, and escalation answers.
- `register_general_tools(...)` and `GeneralChatToolProvider` expose only
  status/supervision helpers for already-started sessions:
  `check_background_sessions`, `update_background_session`,
  `cancel_background_session`, `await_background_sessions`, and bounded
  `sleep`.
- `register_memory_tools(...)` and `MemoryToolProvider`
  (`adapters/memory.py`) expose the host `ChatMemoryStoreProtocol` to the model
  as explicit `create_memories` / `search_memories` / `update_memory` tools
  (on top of the engine's implicit recall/remember). `search_memories` passes
  `include_shared=True` so cross-owner SHARED conclusions are reusable; the host
  injects the store + per-run owner/scope/chat accessors (same pattern as
  `get_binding`). The host maps the three tool names to user-safe labels —
  raw names never reach the UI.
- Implicit per-turn recall (3.25.0) renders a STRUCTURED, caveated context block
  (`_recall_context_block`): the agent sees `auto_memory_search_query` (the raw
  user message, which may be an imperfect retrieval query) + the recalled
  memories in full, and is told to fall back to
  `search_memories`/`create_memories`/`update_memory` when the auto-search
  missed. Each user turn also persists a REFERENCE of what was recalled on the
  USER `ChatTimelineEntry.metadata` via `AutoMemoryRecall` (the single
  `auto_memory_recall` key, `search_query` + `{memory_id, score}` refs — NO
  text, so an edited/deleted memory is never duplicated or left stale). Hosts
  read it back with `AutoMemoryRecall.from_metadata(...)` and resolve ids to
  current text from the store for a "what did the agent recall" view. When memory
  is DISABLED (no store wired → the `NoOpChatMemoryStore`), recall is suppressed
  entirely (3.26.0): no caveat block and no `auto_memory_recall` metadata, so a
  memory-less host never shows a caveat naming unmounted tools.
- Synchronous helper work goes through `SyncSubAgentRunner` and registered
  `SCOPED_AGENT` archetypes, not a separate quarantine module.

Backend or provider packages that need new background work must register a
`AgentRegistration` and let `SubAgentDelegationToolProvider` generate one
typed delegate tool for that archetype.

## Version 3.14.2 Snake-Case Delegate Field Names

Generated delegate tools keep Python field names as the model-facing contract
even when the archetype input model has camel-case API aliases. The delegate
still validates the final payload against the archetype input model by field
name before building `SubAgentSpawnSpec`, so backend providers can keep
camel-case wire schemas without leaking those aliases into LLM tool calls.

## Version 3.14.1 Flat Delegate Tool Schemas

Generated `delegate_<archetype>` tools expose the archetype input model fields
directly as tool parameters. A scoped agent that requires context also gets
flattened `user_request_verbatim`, `context_facts`, and `acceptance_criteria`
parameters, so pydantic-ai can apply field-level schema guidance and
`ModelRetry` to the exact archetype contract.

## Version 3.39.0 Agent Contracts

Current-line agent contracts used by backend/provider packages:

- `schemas/subagent.py` owns typed spawn specs, escalation question/answer
  schemas, lifecycle-result usage, and generalized session notice shapes.
- `subagents/spec.py` and `subagents/registry.py` add `AgentRegistration`,
  `AcceptanceContract`, `SubAgentRunContext`, `SubAgentRegistry`, and
  `compose_subagent_providers(...)` with manifest drift and duplicate-name
  checks.
- `SubAgentDelegationToolProvider` mounts generated `delegate_<archetype>` tools
  under `ChatProviderFamily.SUB_AGENT`.
- `SubAgentBinding` is the host protocol for typed async subagent starts,
  supervision verbs, and escalation answers.
- `scoped_agent_driver`, `sync_runner`, and `workflow_driver` provide the first
  typed driver seams. Since 3.15.0, the legacy general-tools creation surface is
  removed; new delegation work must use archetypes, not free-text goals.

## Version 3.13.1 Evidence Recorder Dedupe And Cap

`EvidenceRecorder.attach()` is the single evidence write path. It dedupes by
`AgenticEvidenceItem.canonical_key()` and caps each tool-call bucket at eight
chips, so `take()` and `drain_all()` always return clean lists. Helper evidence
drains from the child recorder and attaches to the parent recorder, which owns
the policy.

```python
from keble_agentic_chat import AgenticChat, RedisCachedAgenticChatStore

chat = AgenticChat(store=RedisCachedAgenticChatStore(...), run_control_stale_seconds=900.0)
runtime = chat.abuild_runtime(graph=compiled_scope_graph, memory=memory_store)
async for event in runtime.astream_turn(owner=..., scope_type="TASK", scope_id=..., chat_id=..., user_input=...,
                                         server_progress_text="Reviewing setup request."):  # optional "fake thinking"
    ...  # token / thinking / final; server_progress_text (2.5.0, 1.x parity) streams
    ...  # as the FIRST thinking delta + persists in the run's THINKING row, so chat-
    ...  # completions providers with no native reasoning still show immediate feedback
await chat.arequest_interrupt(owner=..., scope_type="TASK", scope_id=..., chat_id=...)  # cooperative stop
```

> **Tool composition (2.4.0; contract tightened 3.28.0):**
> `compose_tool_providers(*, agent, providers)` (`runtime/tool_providers.py`) is the
> canonical handler that attaches domain tools onto a scope agent. A host wraps each
> domain's `register_*_tools` as a small provider satisfying
> `keble_helpers.ChatToolProviderProtocol` (`provider_id: ChatToolProviderId` +
> first-class `manifest` + `register(*, agent)`), then hands an ordered list to this
> function — so every host/scope wires tools identically, the way `abuild_runtime`
> is the single store→runtime point. Composition asserts `manifest`↔registration
> parity (both drift directions), asserts `manifest.provider_id is provider_id`, and
> rejects a duplicate provider id in one scope. It returns the ordered
> `ChatToolProviderId`s for room diagnostics. The dead `context` parameter was
> removed in 3.28.0 (every host passed `None`; every provider captures deps at
> construction). Requires `keble-helpers>=1.32.0`.
>
> ```python
> from keble_agentic_chat import compose_tool_providers
>
> result = compose_tool_providers(agent=agent, providers=[positioning_provider, task_provider])
> # result.provider_ids == [ChatToolProviderId.POSITIONING_QUERY, ChatToolProviderId.TASK_QUERY]
> # tools now attached to `agent` in that order
> ```

## Version 3.4.0 session notices (push)

The push half of the session lifecycle: when a child session reaches a
terminal status or escalates, the host posts a `SessionNotice` to the PARENT
chat (`AgenticChat.apost_session_notice` - a REBASED write that can never
race a live turn into failure). The next user-driven turn prepends all
unconsumed notices as a leading context block ("Background session updates
since your last turn…") and its persist clears EXACTLY the consumed ids
(`ChatHistoryPatch.clear_session_notice_ids` - id-scoped so a notice posted
mid-turn survives for the next turn). Notices are idempotent by `notice_id`
(the child session id); a worker retry re-posting the same fact updates in
place. A notice NEVER auto-triggers a turn — cost stays user-driven.
`ChatHistoryBase.session_notices` carries the unconsumed set (additive).
`NoOpChatMemoryStore` now documents the recall-consistency contract and its
degradation is pinned visible by tests.

## Version 3.3.0 durable session registry and status verbs

Phases 1-2 of the agentic stack hardening program (compaction-safe child
identity plus steerable/cancellable background work):

1. **Durable session registry** — new checkpointed `background_sessions`
   graph channel (NEVER cleared by a fresh turn) + `BackgroundSessionTracker`
   seeded by the agent node each run; session identity now survives
   provider-message compaction. `check_background_sessions` answers TERMINAL
   sessions from the registry without a binding call and stamps
   `last_checked_at`; `BackgroundSessionStatusView` gains additive
   `created_at`/`last_checked_at`. Wire via `register_general_tools(...,
   get_tracker=...)` + `DefaultTurnGraphProvider(session_tracker=...)`.
2. **Prompt discipline** — exported `SUBAGENT_DELEGATION_PROMPT_RULES` (never
   auto-poll; statuses in history are ALWAYS stale; never truncate ids; no
   duplicate starts); the start/check/sleep tool descriptions now match (the
   old copy actively instructed poll-with-sleep loops).
3. **Session verbs (BREAKING for binding implementers)** -
   `SubAgentBinding` owns `aupdate_subagent_session`,
   `acancel_subagent_session`, and `await_subagent_sessions`; matching status
   tools are mounted by `register_general_tools(...)`.
4. **Run liveness** - `ChatRunStatusView` +
   `LangGraphChatRuntime.aget_run_status` for reload-surviving "generating…"
   state and targeted interrupts.

## Version 3.2.0 chat history concurrency correctness

Four guarantees added after a live incident (a background session's completion
line killed the parent turn; a leaked run lock burned an autonomous session to
a lying MAX_TURNS in milliseconds; every concurrent room open left twin
history documents):

1. **Rebased persist** — `_apersist` / `_aabandon_stale_pending` /
   `aacquire` / `arelease` retry `ChatHistoryRevisionConflict` through
   `store.protocol.apatch_chat_history_rebased`, re-deriving the patch from
   the latest snapshot. Out-of-band timeline appends (worker THINKING /
   completion lines) are commutative with runtime patches, so a mid-turn
   append can no longer discard the turn. Hosts appending out-of-band keep
   doing exactly what they did; the engine now always rebase-wins too.
2. **Crash-safe run lock** — every runtime command body runs inside
   `HeldRunLock`; an exception or an abandoned stream generator releases the
   lock immediately (run-id-guarded `arelease`), instead of wedging the chat
   for the 900s stale TTL. The success path is unchanged: the final persist
   still clears the lock atomically with the turn's rows.
3. **Busy turns fail loud** — `ChatTurnResult.competing_run_id` is set ONLY
   when a command ran nothing because another run held the lock. Host drivers
   must treat that as a typed contention failure instead of counting no-op
   turns toward a budget.
4. **Atomic scope chat** — `aget_or_create_scope_chat` is one
   `find_one_and_update` upsert under a new UNIQUE
   `(owner, scope_type, scope_id)` index; `MongoAgenticChatStore.ainit` first
   dedupes legacy twins (deletes ONLY empty twins, keeps content, logs loud
   and skips the unique index while any scope still holds multiple
   content-bearing documents). The in-memory store holds one lock across
   find+create.

New dependency: `tenacity>=9` (transient lost-race retries only — never
control flow).

## Historical Background-Session Note

Older releases had a model-facing, prose-driven child-session creation API.
That path is intentionally removed in 3.16.0. Current code keeps only the
read-side `BackgroundSessionStatusView` name so downstream renderers do not need
an unrelated UI rename; the executable contract is typed subagent delegation.

## Version 2.9.0 backend tool-call timeline cards

(2.9.1: the NON-streamed agent node derives cards from `result.new_messages()`
instead of attaching `event_stream_handler` — attaching one to `Agent.run(...)`
forces a streamed provider request and breaks models without stream support.
Output tools are excluded by name via the agent's output schema.)

Auto-executed backend tools are no longer invisible to the chat UI. Every agent
run observes its provider event stream (`event_stream_handler`, both `run` and
`run_stream` paths) and records each finished function-tool call as one terminal
`ChatAction(kind=SERVER_TOOL_CALL)` (shared `keble_helpers.AgenticActionKind`,
requires `keble-helpers>=1.17.0`):

1. Created directly SUCCEEDED (`ToolReturnPart`) or FAILED (retry prompt) with the
   deterministic identity `tool-call:<tool_call_id>` — never pending, never
   resolvable, and excluded from the deferred `pending` batch contract.
2. The deferred client-action registrar tool, host-excluded names
   (`tool_action_excluded_tools`, e.g. a `sleep` poll loop), and tool_call_ids
   replayed by a deferred resume are skipped, so approval/client tools never
   duplicate as cards.
3. Rows ride the new `tool_actions` graph channel into `_apersist` (ordered
   thinking -> tool cards -> pending actions -> assistant) and stream live as the
   new `ChatStreamEntryAdded` event (custom channel kind `STREAM_TOOL_ACTION`),
   reusing the SAME entry id so live upserts and the refetched timeline converge.
4. The persisted action carries only safe typed metadata: the stable `tool_name`,
   the terminal status, and optional host display copy via `tool_call_formatter`
   (returning None hides a call). Raw args/results are never stored; the frontend
   owns localized labels per tool name.

## Version 1.1.0 unified actions + background subtasks

Two changes land together:

1. **Unified action contract.** `ChatAction` now inherits
   `keble_helpers.AgenticClientActionBase`, and `ChatActionKind`/`ChatActionStatus`/
   `ChatActionProgress` are aliases of the shared `AgenticActionKind`/
   `AgenticActionStatus`/`AgenticActionProgress`. Server approvals, browser client
   tools, and self-served subagent decisions are one contract — no chat-local status
   or progress enum exists anymore. Requires `keble-helpers>=1.13.0`.
2. **Background subtasks + self-serving subagents.** A chat agent can spawn a child
   task (e.g. an Amazon report) that runs in the background without blocking the
   conversation, then poll it. See `protocols.py` (`BackgroundTaskBinding`,
   `SelfServingResolver`), `schemas/subtask.py` (`SpawnedSubtaskRef`,
   `SubtaskStatusView`, `SelfServeContext`), `runtime/general_tools.py`
   (`register_general_tools`: `spawn_subtask`/`check_background_tasks`/
   `check_subtasks`/bounded `sleep`), and `runtime/self_serving.py`
   (`adrive_self_serving`, bounded by `MAX_SELF_SERVE_ITERATIONS`). The package stays
   host-neutral: the backend owns the worker, persistence, and watchdog. A domain task
   is no longer guaranteed to be a ROOT task, and a client-tool invoke is no longer
   guaranteed to raise a human prompt.

## Version 1.0.25 deferred action cleanup

- Removed the obsolete single-action deferred-result converter after the runtime
  moved to batch-owned resume aggregation.
- The existing batch converter remains the only provider resume path for
  `aresolve_action(...)` and `astream_resolve_action(...)`, keeping
  `pending_resume.pending_action_ids` as the single source of truth.

## Version 1.0.24 deferred action batch resume

- Action resolution now treats `pending_resume.pending_action_ids` as one
  provider-owned deferred batch. Resolving one action updates that timeline row
  and keeps `pending_resume` intact while any sibling action remains pending.
- When the last pending action in the batch resolves, `aresolve_action(...)` and
  `astream_resolve_action(...)` resume Pydantic-AI with one aggregate
  `DeferredToolResults` containing every server approval and browser client-tool
  result from that provider turn.
- Hosts still resolve one visible action at a time. Multiple simultaneous client
  tools are allowed when the model has enough current domain state, but they are
  never a hard-coded runtime requirement.

## Version 1.0.22 current Pydantic-AI stream API

- The package now requires `pydantic-ai-slim >=1.102.0` and consumes streamed
  provider snapshots through `StreamedRunResult.stream_response(...)`.
- Streamed chat still derives the terminal flag from `ModelResponse.state`, so
  the public `ChatStream*` sequence remains unchanged while the deprecated
  `stream_responses()` API is no longer called.
- The existing `AgenticChat._stream_run_usage(...)` normalizer remains in place
  for usage-accounting safety across host environments.

## Version 1.0.21 streamed usage compatibility

- Streamed usage accounting now normalizes Pydantic-AI `StreamedRunResult.usage`
  whether the installed Pydantic-AI version exposes it as a `RunUsage` value or
  as a zero-argument method.
- This keeps backend task-cost recording from turning successful streamed chat
  turns into ERROR timeline rows on Docker runtimes pinned to older
  Pydantic-AI minor versions.

## Version 1.4.0 LangGraph orchestration substrate

`keble_agentic_chat.runtime.langgraph` adds the first integration where LangGraph
owns chat orchestration and each pydantic-ai `Agent` run is a graph node. Read
`runtime/langgraph/protocols.py` for the interface-first adapter map (node↔agent
run, `interrupt`↔client-tool=`AgenticClientActionBase`, checkpointer↔durable
thread keyed by chat id, stop↔`ChatRunControl`).

- `build_agentic_turn_graph(...)` builds the generic `agent -> pause -> apply`
  turn graph; `make_agent_node` / `make_pause_node` / `make_apply_node` are
  composable factories so host graphs (e.g. the AMZ bootstrap) reuse the agent
  and pause nodes and supply their own draft-aware apply + `finalize` nodes.
- `make_agent_node(agent_for_state=...)` rebuilds the agent per turn from channel
  state — needed when the system prompt derives from evolving domain state.
- `apply_resolutions_to_actions(...)` is the shared resolution-stamp SSOT
  (`action_with_resolution`) so host apply nodes never re-implement resolution.
- Adds `langgraph` (+ `langgraph-checkpoint`); langgraph transitively requires
  `langchain-core`. Host runtimes select the engine via a `legacy|langgraph` flag
  (legacy default until parity is proven).

## Version 1.3.0 broadened provider-thinking persistence

- `AgenticChat(persist_provider_thinking=True)` (default) persists provider
  thinking from **any** model into the run-owned `THINKING` timeline rows, not
  only OpenAI Responses summaries. Hosts that want summary-only behaviour for
  privacy pass `persist_provider_thinking=False`.
- A single `_thinking_part_source(part)` classifier drives both the streamed and
  non-streamed paths: OpenAI Responses summaries always persist
  (`PROVIDER_REASONING_SUMMARY`); other displayable thinking (Anthropic,
  DeepSeek, …) persists as `PROVIDER_THINKING` only when the flag is on; OpenAI
  raw chain-of-thought (reserved part ids or `raw_content`) is never surfaced.
- Non-streamed turns (`arun_turn` / `aresolve_action` / `arun_internal_turn`)
  now flush final-message thinking into the `thinking:<run_id>` row, so
  reasoning models no longer show a transient "thinking…" with empty history.
  Streamed turns persist incrementally as before (no double-write).

## Version 1.0.20 chat thinking follow-up

- `ChatLiveReady` now carries `scopeType` and `scopeId`, matching the frontend
  live-frame contract without adding a parallel ready-frame schema.
- `AgenticChat.astream_turn(...)` accepts optional backend-authored
  `server_progress_text`. The runtime appends it after the user row under the
  same run-owned `THINKING` timeline entry and streams a newline-terminated
  `THINKING_DELTA` before provider text.
- `keble-helpers >=1.12.16` is required so clean Python 3.13 installs keep
  shared helper imports and Aliyun OSS compatibility stable.

## Version 1.0.18 usage accounting

- Chat turn, internal turn, action-resolution, and streaming paths accept an
  optional `UsageAccountingRecorderProtocol`.
- After a Pydantic-AI run or completed stream, the runtime emits one
  package-neutral LLM token event with `RunUsage`, model/provider metadata,
  elapsed seconds, and chat tags.
- The package does not price usage or persist cost rows; backend services own
  pricing and storage.

## Version 1.0.16 resume text safety

- `AgenticChat` accepts an optional `resume_text_sanitizer` hook that runs after
  provider-history role validation and before model replay.
- The sanitizer only rewrites provider-authored text parts. Tool calls, tool
  returns, user prompts, and pending action identity remain unchanged.
- Hosts can now remove legacy technical diagnostics from old compacted or
  assistant provider text without adding a new stored schema.

## Version 1.0.15 provider resume safety

- Native compaction now trims kept provider tails to a valid tool-call boundary.
  A retained `tool-return` or retry prompt must still have its matching
  assistant tool call in the compacted tail.
- Provider-history resume now runs the same validation when loading existing
  stored chats, so old compacted histories with orphan tool rows are repaired
  before the next model request.
- Action-resolution resume preserves the final pending assistant tool call only
  for the provider call that is about to attach the matching deferred result.
- This prevents OpenAI-compatible providers from receiving orphan `tool` role
  messages after summary replacement.
- Public timeline rows remain unchanged; the fix is isolated to private
  Pydantic-AI provider-message persistence and replay.

## Version 1.0.14 compaction tail safety

- Initial compaction-tail repair release. Superseded by `1.0.15`, which also
  repairs already-persisted provider histories during resume loading.

## Version 1.0.13 action progress update

- `ChatAction.progress` is the canonical stored progress surface for
  browser/client-tool and backend task work associated with one timeline action.
- `ChatActionProgressUpdate` owns action matching by `action_id` first and
  stored `progress_key` second, then updates the existing action row instead of
  creating a parallel progress history.
- Stores expose `aapply_action_progress(...)`, returning the updated timeline
  entry for live `ENTRY_UPDATED` frames. Mongo, Redis-cache, and memory stores
  all delegate through the same patch builder.
- Terminal progress maps back to `ChatActionStatus.SUCCEEDED` or
  `ChatActionStatus.FAILED` when the progress report reaches a known terminal
  state.

## Version 1.0.10 update

- Packages the pending-resume audit clarification that keeps timeline actions
  and `pending_action_ids` as the canonical resume source of truth.
- No storage shape, timeline API, or provider runtime behavior changes are
  introduced in this release.

## Runtime Contract

`AgenticChat` runs normal user turns and resumes pending actions:

```python
service: AgenticChat[MyDeps, ChatAssistantText, str] = AgenticChat(
    store=store,
    output_type=ChatAssistantText,
)

result = await service.arun_turn(
    agent=agent,
    owner=owner,
    scope_type="task_room",
    scope_id=task_id,
    chat_id=chat.id,
    user_input=ChatUserInput(text="Continue the setup."),
    deps=deps,
)
```

Custom assistant output schemas do not inherit a package base class. Pass an
explicit text adapter so the runtime can append assistant timeline rows:

```python
service: AgenticChat[MyDeps, MyOutput, str] = AgenticChat(
    store=store,
    output_type=MyOutput,
    assistant_text_adapter=lambda output: output.answer,
)
```

Pending server approvals and browser-resolved tools both use the same action
resolution API:

```python
await service.aresolve_action(
    agent=agent,
    owner=owner,
    scope_type="task_room",
    scope_id=task_id,
    chat_id=chat.id,
    resolution=ChatActionResolution(
        action_id=action_id,
        status="APPROVED",
    ),
    deps=deps,
)
```

Hosts with a live transport should use `astream_resolve_action(...)` for the
same browser/server action resumes. It persists the accepted/rejected action
before provider execution, emits transient text/reasoning deltas while the
resume runs, and finishes with the same persisted `ChatTurnResult` contract as
`aresolve_action(...)`.

If one provider response requested multiple client tools or server approvals,
the runtime keeps the private pending-resume state until every action in that
batch has a result. The frontend or host app should still send one
`ChatActionResolution` per user click; the final resolution in the batch is the
only one that resumes the provider, and it resumes with all deferred results.

When a provider emits normal assistant text in the same response as deferred
tool requests, the runtime appends the assistant row before the action rows.
This keeps the visible transcript in the same order the model produced it:

```text
USER
ASSISTANT
ACTION
```

Hosts that need to advance a deterministic workflow after a tool resolution can
pass `resume_prompt` to `aresolve_action(...)` or run
`arun_internal_turn(...)`. Internal turns are stored only in private provider
history and do not add synthetic `USER` rows to the public timeline; only the
assistant/action result is appended.

Browser tools are registered as deferred client actions. The model tool name is
configurable, but defaults to `request_client_tool` for model familiarity:

```python
register_client_action_tool(
    agent,
    validate_client_action_request=validate_client_action,
)
```

For the two universal browser interactions — a YES/NO confirm and a free-text /
textarea question with an editable default — the package ships generic,
host-agnostic primitive schemas in `keble_agentic_chat.schemas.client_primitives`
(re-exported from the package root): `ConfirmClientToolPayload` /
`ConfirmClientToolResult` and `TextInputClientToolPayload` /
`TextInputClientToolResult`. These are reusable building blocks, not new agent
tools or a new registrar — a host embeds the primitive payload inside its own
typed client-action payload (or the generic `request_client_action` payload
dict) and parses the primitive result out of `ChatActionResolution.result`. The
camelCase wire aliases are the cross-repo contract mirrored by `keble-core` and
the frontend forms:

```text
confirm  payload {prompt, defaultAnswer, confirmLabel?, cancelLabel?} -> result {confirmed}
text     payload {prompt, defaultValue, multiline, placeholder?, maxLength?} -> result {text}
```

Streaming uses the same timeline/action runtime as normal turns.
`astream_turn` and `astream_resolve_action` set `run_control.mode="STREAM"`,
emit `ChatStreamTextDelta` rows while the provider stream is active, may emit
transient `ChatStreamReasoningSummaryDelta` rows for provider-marked safe
reasoning summaries, and always finish with one `ChatStreamFinal` containing
the canonical `ChatTurnResult`. Reasoning summaries are never persisted in the
public timeline and must not carry raw chain-of-thought or provider
`raw_content`. `arequest_interrupt(...)` only targets active stream runs and
clears run control with an interrupted final result instead of persisting a
stale assistant row. Streaming must always call the provider stream directly;
do not implement it as a final-only wrapper around `arun_turn(...)`.

Active run control is a private lock, but the public history view exposes
`runtimeState.isCommandRunning` and `runtimeState.mode` so hosts can disable
inputs and poll during live recovery. Duplicate `arun_turn(...)`,
`aresolve_action(...)`, `arun_internal_turn(...)`, and `astream_turn(...)`
calls return the currently persisted chat state while a non-stale command owns
the lock. `run_control_stale_seconds` clears abandoned locks using
`run_control.started_at` or the chat `updated` timestamp, then allows the new
command to proceed without appending a synthetic `ERROR` row. Hosts that need
to recover a live request before constructing expensive runtime dependencies
should call `aget_active_run_result_or_clear_stale(...)`; it uses the same
package-owned stale-lock policy as normal commands. If another command wins the
lock between the public pre-check and command preparation, the error boundary
returns that competing active result instead of clearing the lock or writing an
`ERROR` row.

Runtime cleanup is also run-id scoped. Interrupted streams and runtime error
boundaries re-read the latest stored chat before clearing `run_control`; if a
newer command owns the lock, the runtime returns that active state and leaves
the newer lock untouched. Runtime mode and timeline row kind are exported enums
inside Python while preserving the same uppercase JSON values.

## Native Auto-Compaction

Native compaction is opt-in. Pass both a `ChatCompactionPolicy` and a host-owned
`compactor`; otherwise the runtime behaves as it did before. Compaction replaces
only private `provider_messages`; it never deletes public timeline rows, rewrites
actions, or changes action ids/statuses/results. When compaction runs, the
runtime appends a visible `SUMMARY` timeline row before the user message that
triggered compaction.

```python
async def compact_chat_context(
    request: ChatCompactionRequest,
    deps: MyDeps,
) -> ChatCompactionResult:
    """Summarize old chat context with a host-owned model or service."""

    summary = await deps.summary_agent.asummarize(request)
    return ChatCompactionResult(
        summary_text=summary.text,
        metadata={"summary_model": summary.model_name},
    )


service = AgenticChat(
    store=store,
    output_type=ChatAssistantText,
    compaction_policy=ChatCompactionPolicy(
        max_provider_messages=40,
        keep_recent_provider_messages=12,
    ),
    compactor=compact_chat_context,
)
```

Compaction triggers when an enabled threshold is exceeded: provider-message
count, serialized provider-message character count, or optional latest provider
input-token usage. The compacted private history is built with Pydantic-AI typed
messages: one `SystemPromptPart` summary request plus the configured recent raw
provider tail. If a new user message supersedes unresolved actions, the runtime
abandons those actions first and does not keep the unsafe deferred-tool tail.

Action resolution never compacts before resume because Pydantic-AI needs the
exact deferred tool-call history. Native compaction is also disabled when a
custom `history_builder` is installed, because the host already owns provider
history construction. Pydantic-AI history processors are useful request-time
transforms, but they are not enough for native compaction because they do not
persist compacted provider state or create visible `SUMMARY` audit rows through
`ChatHistoryPatch`.

## Storage Contract

`ChatHistoryMongoObject` persists one v1.0 document:

- `schema_version`
- `owner`, `scope_type`, `scope_id`, `title`
- `revision`
- `timeline`
- `provider_messages`
- `pending_resume`
- `run_control`

The public `ChatHistoryView` exposes only `id`, scope fields, `title`,
`timeline`, and the safe `runtime_state` summary. Do not add duplicate read-side
fields for tool approvals, browser tools, pending tool state, model messages,
raw run ids, or compaction internals.

`pending_resume.request_raw` is an audit/debug snapshot of the provider deferred
request. Runtime resume decisions must use the canonical timeline actions plus
`pending_resume.pending_action_ids`; do not treat `request_raw` as a second
source of truth for action identity or status.

All stores implement one optimistic patch method:

```python
await store.apatch_chat_history(
    owner=owner,
    scope_type=scope_type,
    scope_id=scope_id,
    chat_id=chat_id,
    patch=ChatHistoryPatch(
        expected_revision=chat.revision,
        append_timeline=[entry],
        update_actions=[resolved_action],
    ),
)
```

Memory and Mongo stores both raise `ChatHistoryRevisionConflict` when the
expected revision is stale. Redis is a cache wrapper only; cached pre-1.0
documents are ignored and replaced from the canonical store.

## Breaking Migration Note

There is no 0.8.x storage migration layer. Old documents or cache payloads that
contain these removed fields are invalid for v1.0:

- `tool_approval_request`
- `tool_calls`
- `client_tool_request`
- `client_tool_calls`
- `pending_tool_requests`
- `pending_tool_state`
- `timeline_entries`
- `model_messages`
- `turns`
- `tool_descriptions`
- `stream_control`

Hosts should create new v1.0 chat rows or run their own explicit migration
outside this package.

## 3.17.0 LangGraph Workflow Contract

WORKFLOW graph factories must return a real LangGraph `StateGraph` or
`CompiledStateGraph`. The driver compiles raw graphs with the host checkpointer,
passes `thread_id` on both fresh and resumed invocations, and resumes with
`Command(resume=<EscalationAnswer JSON>)`.

Duck-typed adapters with only `ainvoke` are rejected because they bypass
checkpoint ownership and durable resume. SCOPED_AGENT escalation is opt-in by
descriptor; non-FORBIDDEN scoped archetypes receive one `escalate_to_parent`
tool and hosts resume them through the existing steering channel.

## Verification

Use Python `3.13` and the package `uv` environment:

```bash
uv run pytest -q
uv run pyright
```

## Mongo Startup Indexes

Hosts should call `store.ainit(amongo=...)` during startup. The Mongo store
creates the `owner/scope_type/scope_id/updated desc` index used by scoped chat
history listing, the Redis store delegates to its durable store, and the memory
store is a no-op for the same contract. The startup method is part of the store
protocol so host apps can initialize every concrete store through one public API.

## 1.0.17 Persisted Thinking Timeline

`keble-agentic-chat 1.0.17` adds durable `THINKING` timeline rows and
canonical `ChatStreamThinkingDelta` / `ChatLiveThinkingDelta` frames. Thinking
text is provider-safe summary or host-authored progress copy only; raw
chain-of-thought must never enter timeline rows, stream frames, provider
messages, or compatibility reasoning-summary deltas.

Use `ChatTimelineEntryTextAppend` with `store.aappend_timeline_entry_text(...)`
to upsert one newline-delimited thinking row by stable `entry_id`, such as
`thinking:<run_id>` or `thinking:<progress_key>`. `REASONING_SUMMARY_DELTA`
remains as a compatibility frame during migration, but new consumers should
render `THINKING_DELTA` and persisted `ChatTimelineEntryKind.THINKING`.

## 1.0.19 Usage Access Compatibility

Agentic chat usage accounting continues to emit one package-neutral `LLM` event
for normal and streamed turns when a host recorder is provided. Runtime code now
reads Pydantic-AI usage through the current `result.usage` property instead of
the deprecated method form.

## 1.0.23 Result Usage Normalization

Normal and streamed chat accounting now delegates usage normalization to
`UsageAccountingEvent.from_result_usage(...)` /
`UsageAccountingEvent.normalize_run_usage(...)`. This keeps chat-specific code
focused on chat runtime state while the helper package owns compatibility for
Pydantic-AI value-style and method-style usage APIs.

## 1.0.26 Pending Action Progress State

`ChatActionProgressUpdate` may attach progress to a pending action, but terminal
progress no longer changes `PENDING` to `SUCCEEDED` or `FAILED`. User/browser
confirmation remains the only way to resolve pending client tools.

Terminal progress can still close actions that have already entered the accepted
execution path through `APPROVED` or `SUBMITTED`; rejected, denied, and
abandoned actions keep their original lifecycle state.
## Version 3.18.0 Update

Scoped-agent runs now mount the shared tool-resilience capability. Unexpected
tool exceptions are returned to the model as safe recoverable tool results so a
child can try another tool or conclude honestly; exhausted children can return
a typed non-empty `NOT_ACHIEVED` conclusion when the host opts in.

WORKFLOW runs stream LangGraph node events through the optional
`workflow_node_event_sink` on `SubAgentRunContext`, and topology is derived
from compiled graphs via `workflow_topology_from_graph(...)`. Providers should
not hand-author parallel topology metadata.

## 3.39.0 Agent Lifecycle Contract Collapse

`keble-agentic-chat 3.39.0` uses the helper-owned agent-core projection as the
only current-line lifecycle contract:

1. `AgentRegistration.descriptor` is an `AgentDescriptor`.
2. Terminal child results are `AgentLifecycleResult(status, verdict,
   errorCode, result, narrative, concludedAt)`.
3. Escalations use `EscalationQuestion.audience`.
4. Spawn/run context uses `AgentContext`.
5. Descriptor-owned escalation tools mount through
   `register_agent_escalation_tools(...)`.
