# entelix-agents

Production agent SDK on top of `entelix-runnable` + `entelix-graph`. Recipes (ReAct, Supervisor, Hierarchical, Chat) wrap a `StateGraph<S>` into a typed `Agent<S>` with execution-mode + observer + sink wiring.

## Surface

- **`Agent<S>` + `AgentBuilder<S>`** — wraps `Runnable<S, S>` with `AgentEventSink`, optional `Approver` (HITL gate), `ExecutionMode::{Auto, Supervised}` (`Supervised` requires `Approver` — `build` returns `Error::Config` otherwise), and `Vec<Arc<dyn AgentObserver<S>>>`. `Agent::execute` opens the `entelix.agent.run` OTel span and records per-run aggregates from the frozen `RunBudget` snapshot.
- **`AgentEvent<S>`** — runtime event union. Every variant carries `run_id` + `tenant_id` (invariant 11). Variants: `Started` / `ToolStart` / `ToolComplete` / `ToolError` / `Failed` / `Interrupted` / `Complete` / `ToolCallApproved` / `ToolCallDenied`.
- **Lifecycle contract** — every run attempts `Started` followed by exactly one of `Complete` / `Failed` / `Interrupted`. The three terminal variants are mutually exclusive. `Interrupted { kind: InterruptionKind, payload }` is resumable via `CompiledGraph::resume_with(Command)`. `Failed` and `ToolError` carry `envelope: ErrorEnvelope`; `ToolError` adds `error_for_llm: RenderedForLlm<String>` (invariant 16) + `kind: ToolErrorKind`.
- **`FailureKind`** — typed cause discriminator on `Failed`: `ToolErrorTerminal { kind, tool_name }`, `UsageLimitExceeded`, `Cancelled`, `DeadlineExceeded`, `ModelDispatch`, `Internal`, `Unclassified` (SDK-version drift). `wire_id()` surfaces through OTel (`entelix.failure.kind`) and structured logs.
- **Recipes** — `create_react_agent` / `create_supervisor_agent` / `create_chat_agent` each return a ready-to-stream `Agent<StateType>`. Nested-supervisor topologies wire `team_from_supervisor` into a parent `create_supervisor_agent`.
- **Chat-shape state** — `ChatState` / `ReActState` / `SupervisorState` carry `messages: Vec<Message>`. `last_assistant_text(&self) -> Option<String>` is the canonical SSE / chat-UI extraction point.
- **`Subagent::builder(model, &parent_registry, name, description)`** — `SubagentBuilder` narrows the parent `ToolRegistry` via `restrict_to(&[…])` / `filter(predicate)` + `with_skills` / `add_sink` / `with_approver`. `build()` finalises into a `Subagent` whose identity is inspectable before `into_tool()` exposes it as a parent-side `Tool`. Layer factory rides over by `Arc` (managed-agent shape — *Brain passes hand*).
- **`AgentEventSink` trait** + `BroadcastSink` / `CaptureSink` / `ChannelSink` / `DroppingSink` / `FanOutSink` / `FailOpenSink` / `StateErasureSink` — fan-out for `AgentEvent<S>`. `FanOutSink` (callback) + `FailOpenSink` (swallows `Err`) compose for mixed must-succeed + best-effort wiring. `StateErasureSink<S>` fans heterogeneous agents into a single state-agnostic pipeline via `AgentEvent::erase_state`.
- **`AgentObserver` + `DynObserver`** — `pre_turn` / `on_complete` lifecycle hooks (ctx-last).
- **`Approver` trait** + `AlwaysApprove` / `ChannelApprover` — `decide(&request, ctx)` for HITL gating. `ApprovalLayer` (`"tool_approval"`) wires onto `ToolRegistry::layer`. `ApprovalLayer::AwaitExternal` is the originator for `AuditSink::record_interrupted` (durable HITL audit).
- **Tool-side layers** — `ApprovalLayer`, `ToolEventLayer<S>` (`"tool_event"` — emits `ToolStart` / `ToolComplete` / `ToolError`), `ToolHookLayer` (`"tool_hook"` — `before_tool` / `after_tool` / `on_tool_error`). All impl `NamedLayer`.
- **Auto-compaction** — `MessageRunnableCompactionExt::with_compaction(compactor, threshold_chars)` wraps the model itself so every recipe inherits threshold-driven compaction unchanged. Routes through `Compactor::compact` so the `tool_call` / `tool_result` pair invariant (21) survives end-to-end. `SummaryCompactor<M>` is the LLM-summary strategy (`HeadDropCompactor` in `entelix-session` is the reference drop-oldest strategy).
- **`SupervisorDecision { Agent(String), Finish, Handoff { agent, payload } }`** — typed router return (invariant 17). `Handoff` payload becomes the receiving agent's leading `system` message — structured context transfer without round-tripping through the model.

## Crate-local rules

- **`SubagentBuilder::restrict_to` strict, `filter` graceful** — typo in `restrict_to` returns `Error::Config` at `build()`; predicate filter accepts empty result. `build()` rejects empty `name` / `description`.
- **Sub-agent dispatch emits `record_sub_agent_invoked`** on parent's `AuditSink` (invariant 18). Sub-agents never bypass the parent registry's layer stack — `cargo xtask managed-shape` fails the PR.
- **Recipe routers return typed `SupervisorDecision`**, not stringly-typed sentinels. Probability literals in routing are forbidden (`cargo xtask magic-constants`).
- **`Agent::execute_stream` opens its own root span** — consumers compose `tower::Layer<S>` on the underlying `ChatModel`, not on the agent.

## Forbidden

- A `Subagent` path that constructs a fresh `ToolRegistry::new()` instead of narrowing the parent — silently drops the parent's layer stack.
- A recipe that holds `ApprovalRequest` state in agent struct fields (state lives in `SessionGraph` events, invariant 1).
- Looping `while !done { … }` in recipe code instead of a `StateGraph` with conditional edge to `END` (invariant 8).
