# 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>`** — runtime entity wrapping any `Runnable<S, S>` with `AgentEventSink`, optional `Approver` (HITL gate), `ExecutionMode::{Auto, Supervised}` (`Supervised` requires an `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 — `gen_ai.usage.{input_tokens, output_tokens, total_tokens}`, `entelix.usage.{requests, tool_calls}`, and `entelix.agent.usage.cost` (cumulative `Decimal` USD rendered as a string; deliberately distinct from `OtelLayer`'s per-call `gen_ai.usage.cost` to avoid dashboard collision).
- **`AgentEvent<S>`** — runtime event union. Every variant (`Started`, `ToolStart`, `ToolComplete`, `ToolError`, `Failed`, `Complete`, `ToolCallApproved`, `ToolCallDenied`) carries `run_id: String` and `tenant_id: TenantId` as a flat header (invariant 11 — multi-tenant scope mandatory at every emit). `Failed` and `ToolError` carry `envelope: ErrorEnvelope` (typed wire shape from `Error::envelope()`) so SSE adapters and audit replay read one `Copy` value instead of pattern-matching the inner `Error`. `ToolError` additionally carries `error_for_llm: RenderedForLlm<String>` for the model channel (invariant 16); the audit projection routes that rendering into `GraphEvent::ToolResult`.
- **Recipes** — `create_react_agent` / `create_supervisor_agent` / `create_chat_agent`. Each returns a ready-to-stream `Agent<StateType>` so the common patterns are one call. 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>` concatenates the `ContentPart::Text` body of the most recent `Role::Assistant` message — the canonical SSE / chat-UI extraction point (returns `None` when only tool-use blocks or reasoning blocks were emitted).
- **`Subagent::builder(model, &parent_registry, name, description)`** — returns a `SubagentBuilder` that narrows the parent `ToolRegistry` through `restrict_to(&[…])` / `filter(predicate)` selection verbs and accepts `with_skills` / `add_sink` / `with_approver` configuration. `build()` finalises into a `Subagent` whose identity (`name()` / `description()` / `metadata()`) is inspectable before the `into_tool()` conversion exposes it as a parent-side `Tool`. Layer factory rides over by `Arc` (per root CLAUDE.md §"Anthropic managed-agent shape" — *Brain passes hand*).
- **`AgentEventSink` trait** + `BroadcastSink` / `CaptureSink` / `ChannelSink` / `DroppingSink` / `FanOutSink` / `FailOpenSink` / `StateErasureSink` — fan-out for `AgentEvent<S>`. `FanOutSink` (callback-shaped — every sink runs synchronously inside `send`) and `FailOpenSink` (composition adapter that swallows the inner sink's `Err`) compose naturally: `FanOutSink::new().push(audit).push(Arc::new(FailOpenSink::new(otel)))` keeps `audit` must-succeed and `otel` best-effort. `StateErasureSink<S>` wraps any `Arc<dyn AgentEventSink<()>>` and produces an `AgentEventSink<S>` for any `S`, fanning in heterogeneous agents (`Agent<ReActState>`, `Agent<SupervisorState>`, …) to a single state-agnostic audit / SSE / OTel pipeline through `AgentEvent::erase_state`. Distinct from `BroadcastSink` (subscribe-shaped — consumers pull from their own `Receiver`).
- **`AgentObserver` trait** + `DynObserver` adapter — `pre_turn` / `on_complete` lifecycle hooks (ctx-last per naming taxonomy).
- **`Approver` trait** + `AlwaysApprove` / `ChannelApprover` — `decide(&request, ctx)` for HITL gating. `ApprovalLayer` (`NAME = "tool_approval"`) wires onto `ToolRegistry::layer`; `ToolApprovalEventSink::record_approved(&TenantId, run_id, tool_use_id, tool)` / `record_denied(&TenantId, run_id, tool_use_id, tool, reason)` adapter routes decisions through the agent's `AgentEventSink<S>` channel (tenant-scoped, type-erased across heterogeneous agent state types).
- **Tool-side layers** — `ApprovalLayer` (`"tool_approval"`), `ToolEventLayer<S>` (`"tool_event"` — emits `ToolStart` / `ToolComplete` / `ToolError`), `ToolHookLayer` (`"tool_hook"` — `before_tool` / `after_tool` / `on_tool_error` hooks) all impl `entelix_core::NamedLayer` and compose via `ToolRegistry::layer`. The `ToolRegistry::layer_names()` accessor surfaces the composed stack for diagnostic dashboards.
- **`RunnableCompacting<R>`** + **`MessageRunnableCompactionExt::with_compaction(compactor, threshold_chars)`** — auto-compaction adapter for any `Runnable<Vec<Message>, Message>`. Wraps the model itself (not the agent loop), so every recipe (ReAct / Supervisor / Chat) inherits threshold-driven compaction unchanged: `let model = my_model.with_compaction(Arc::new(HeadDropCompactor), 8_192);`. Routes through `messages_to_events` + `Compactor::compact` + `CompactedHistory::to_messages` so the `tool_call` / `tool_result` pair invariant survives the round-trip end-to-end.
- **`SummaryCompactor<M>`** — LLM-summary [`Compactor`] impl. Wraps any `Runnable<Vec<Message>, Message>` summariser model (operator's `ChatModel`, layered model, or test stub). Drops oldest turns past `keep_recent_turns` into a single synthetic `Turn::User` carrying the summariser's output; recent turns survive verbatim. `with_system_prompt` / `with_keep_recent_turns` for tuning. Pair invariant survives because dropped turns leave with their `ToolPair`s. Closes Claude Agent SDK auto-compaction / LangChain `SummarizationMiddleware` parity.
- **`SupervisorDecision { Agent(String), Finish, Handoff { agent, payload } }`** — replaces the prior `String` + `SUPERVISOR_FINISH` sentinel pairing (invariant 17). `Handoff` carries a JSON payload the supervisor dispatch injects as the next agent's leading `system` message (typed context transfer without round-tripping through the model). `team_from_supervisor` builds a nested-supervisor topology.

## Crate-local rules

- **`SubagentBuilder::restrict_to` strict, `filter` graceful** — typo in `restrict_to` returns `Error::Config` at `build()`; predicate filter accepts empty result (pure-orchestration shape). The asymmetry is regression-locked. `build()` also rejects empty `name` or `description` so operators fail-fast instead of shipping an empty tool name to the LLM.
- **Sub-agent dispatch emits `record_sub_agent_invoked`** on the parent's `AuditSink` (invariant 18). Sub-agents NEVER bypass the parent registry's layer stack — `cargo xtask managed-shape` fails the PR.
- **Recipe routers MUST return typed `SupervisorDecision`**, not stringly-typed sentinels. Probability literals (`0.X`) in recipe routing are forbidden (`cargo xtask magic-constants`, invariant 17).
- **`Agent::execute_stream`** opens its own root span; consumers compose with `tower::Layer<S>` (e.g. `OtelLayer`, `PolicyLayer`) on the underlying `ChatModel`, not on the agent itself.

## Forbidden

- A `Subagent` path that constructs a fresh `ToolRegistry::new()` instead of narrowing the parent registry — 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 modelling as a `StateGraph` with conditional edge to `END` (invariant 8).

## References

- Root `CLAUDE.md` §"Anthropic managed-agent shape" — sub-agent permission narrowing through `restrict_to` / `filter`.
