Skip to content

feat(ergon): hook lifecycle + model wire types (BRO-997)#1152

Merged
broomva merged 1 commit into
mainfrom
feature/bro-997-ergon-hook-lifecycle
May 6, 2026
Merged

feat(ergon): hook lifecycle + model wire types (BRO-997)#1152
broomva merged 1 commit into
mainfrom
feature/bro-997-ergon-hook-lifecycle

Conversation

@broomva
Copy link
Copy Markdown
Owner

@broomva broomva commented May 6, 2026

Summary

Second slice of ergon per spec §12 work order. Lands the eight-event hook lifecycle and the wire types those hooks observe.

  • model.rs — provider-agnostic wire types (Message with block-structured content, ContentBlock enum, ToolCall, ToolResult, ToolDefinition, ModelRequest, ModelResponse, Usage)
  • hook.rsHook trait with 8 events (workflow_start/end, step_start/end, pre/post_inference, pre/post_tool_use), HookCtx<'a>, three outcome enums (HookOutcome, ToolHookOutcome, InferenceHookOutcome), and HookRegistry builder.
  • 18 new unit tests (43 total in ergon, up from 25)
  • All clippy + fmt clean, workspace check passes

Spec: core/life/docs/superpowers/specs/2026-05-05-ergon-v0.1.md (§3.7, §3.8)
Linear: BRO-997. Umbrella: BRO-994.

Why two modules in one PR

The Hook trait references ModelRequest, ModelResponse, ToolCall, ToolResult directly. Three options for where those types live:

Option Cost
Re-export from praxis_core / arcan_provider Couples ergon to those crates, defeats Layer-2 framing
Placeholder types refactored in BRO-998 Churns hook signatures the moment step.rs lands
Ergon owns the wire types ← this PR Hook contract is provider-independent forever

Ergon owning the wire types is what makes step.rs a translator (not a coupling) — it converts between ergon's shapes and arcan_provider / praxis_core::Tool at the boundary.

Spec deviation: hook event defaults

Spec §3.7 only defaulted on_workflow_start to Continue; the other 7 events were abstract. This PR defaults all 8 events to Ok(_::Continue).

Rationale: a real-world hook (e.g. `NousScoreHook`) only cares about one event. Forcing eight no-op implementations on every hook is boilerplate without safety — the same `Continue` ships either way. The original spec choice was a compile-time push to "force the implementer to think about each event" — ergonomically counterproductive once you have more than two hooks. Documented in CHANGELOG and `crates/ergon/ergon/CLAUDE.md`.

Test plan

  • `cargo fmt --all -- --check` — clean
  • `cargo clippy -p ergon --all-targets -- -D warnings` — clean
  • `cargo clippy --workspace -- -D warnings -A clippy::too_many_arguments` — clean
  • `cargo test -p ergon --all-targets` — 43 passed; 0 failed
  • `cargo check --workspace` — no regression
  • CI: format / lint / test-linux / test-macos / msrv all green

What's next (BRO-998, separate PR)

`step.rs` will:

  1. Use these wire types as the inner contract
  2. Add substrate dependencies (`arcan-provider`, `praxis-core`, `lago-journal`, `life-vigil`) for the autonomous loop body
  3. Land the three default sinks (`LagoSink`, `VigilSink`, `LifegwSink`)
  4. Implement `StepCtx::run_inference_streaming` with full hook firing per spec §5

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Hook lifecycle system enabling interception and modification of agent behavior at key points
    • Wire-type definitions for model interaction, including messages, tools, requests, and responses
  • Documentation

    • Updated CHANGELOG and module documentation with expanded feature descriptions
    • Added 18 new unit tests (43 total), improving test coverage

Lands the second slice of `ergon` per spec §12 work order: the eight-event
hook lifecycle and the wire types those hooks observe. Together these are
the mutation/extension surface that step.rs (BRO-998) will fire during the
autonomous loop.

## Why two modules in one PR

The Hook trait references `ModelRequest`, `ModelResponse`, `ToolCall`,
`ToolResult` directly in its signatures. Defining those types in this PR
(rather than re-exporting from `praxis_core` / `arcan_provider`) keeps
ergon's contract independent of any specific provider crate — step.rs
will translate, not couple. Bundling the types with the trait that uses
them avoids the alternative (placeholder types churned in BRO-998).

## What lands

- `model.rs` (~480 LOC) — provider-agnostic wire types:
  - `Message` with `Vec<ContentBlock>` (block-structured content; flat
    string drops the structure modern providers actually emit)
  - `ContentBlock`: Text / Reasoning / ToolUse / ToolResult variants,
    `#[non_exhaustive]`, `#[serde(tag = "type")]`
  - `ToolCall`, `ToolResult`, `ToolDefinition` — provider-agnostic
    `is_error` flag distinguishes model-visible errors from runtime
    failures (which surface as `ErgonError::Tool`)
  - `ModelRequest` + `ModelResponse` + `Usage`
  - Convenience helpers: `Message::user_text`, `Message::assistant_text`,
    `ModelResponse::extract_tool_calls`, `ModelResponse::text`
  - 8 unit tests covering serde round-trips, content extraction, helpers
- `hook.rs` (~400 LOC) — eight-event lifecycle trait:
  - `Hook` with 8 events (workflow_start/end, step_start/end,
    pre/post_inference, pre/post_tool_use)
  - `HookCtx<'a>` carries SessionId + workflow name + tracing span
  - `HookOutcome` (Continue/Deny), `ToolHookOutcome` (Continue/Deny/Stub
    -> JSON), `InferenceHookOutcome` (Continue/Deny/Stub -> Message)
  - `HookRegistry` builder with `with` / `with_arc` / `iter` / `len`
  - 10 unit tests: registry order, default Continue across all 8
    events, Deny short-circuits, Stub variants carry payload, mutation
    in pre_inference (`req.system = ...`) round-trips, Arc-shared
    registry iteration

## Spec deviation: hook event defaults

Spec §3.7 only defaulted `on_workflow_start` to `Continue`; the other 7
events were abstract. This PR defaults **all 8 events** to
`Ok(_::Continue)`. Rationale: real-world hooks (e.g. `NousScoreHook`)
typically care about one event; forcing eight no-op implementations on
every hook is boilerplate without safety — the same `Continue` ships
either way. The original spec choice was a compile-time push to "force
the implementer to think about each event" — ergonomically
counterproductive once you have more than two hooks. Documented in
CHANGELOG and `crates/ergon/ergon/CLAUDE.md`.

## Validation

- `cargo fmt -p ergon -- --check` clean
- `cargo clippy -p ergon --all-targets -- -D warnings` clean
- `cargo clippy --workspace -- -D warnings -A clippy::too_many_arguments` clean
- `cargo test -p ergon --all-targets` — **43 passed; 0 failed** (was 25
  after #1151; +18 new tests)
- No regression in workspace check

Linear: BRO-997. Tracker: BRO-994.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@linear
Copy link
Copy Markdown

linear Bot commented May 6, 2026

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 6, 2026

Caution

Review failed

Failed to post review comments

📝 Walkthrough

Walkthrough

This PR lands two foundational ergon modules: a Hook trait system with eight lifecycle events and a HookRegistry for managing hook invocations, plus a Model module defining provider-agnostic wire-format types (MessageRole, ContentBlock, Message, ToolDefinition, ToolCall, ToolResult, ModelRequest, Usage, ModelResponse) with builder helpers. Both modules are exported in lib.rs and documented in CHANGELOG.md and CLAUDE.md.

Changes

Hook & Model Foundation

Layer / File(s) Summary
Data Shapes
crates/ergon/ergon/src/hook.rs
crates/ergon/ergon/src/model.rs
Hook defines HookCtx, HookOutcome, ToolHookOutcome, InferenceHookOutcome. Model defines MessageRole, ContentBlock, Message, ToolDefinition, ToolCall, ToolResult, ModelRequest, Usage, ModelResponse with serde tagging and non-exhaustive stability.
Core Implementations
crates/ergon/ergon/src/hook.rs
crates/ergon/ergon/src/model.rs
Hook trait specifies eight lifecycle events (on_start, on_input, on_pre_inference, on_post_inference, on_pre_tool_use, on_post_tool_use, on_output, on_end) with default Continue outcomes; HookRegistry collects hooks in order via with() and with_arc() builders. Model types include constructors (::new), helpers (user_text, assistant_text, extract_tool_calls, text), and field accessors.
API Surface & Exports
crates/ergon/ergon/src/lib.rs
Two new public modules declared (hook, model); re-exports Hook, HookCtx, HookOutcome, ToolHookOutcome, InferenceHookOutcome, HookRegistry and model wire types (ContentBlock, Message, MessageRole, ModelRequest, ModelResponse, ToolCall, ToolDefinition, ToolResult, Usage).
Documentation & Status
CHANGELOG.md
crates/ergon/ergon/CLAUDE.md
Changelog entry expanded to detail hook lifecycle, wire types, and 18 new unit tests (43 total). CLAUDE.md marks model.rs and hook.rs as Done (BRO-997), documents step.rs as Not yet landed, and introduces Spec deviations section covering license.workspace and hook event defaults.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related issues

Poem

🐰 *ears twitch with joy*

Hooks now loop through agent life,
From start to end, they guide the strife,
Models talk in tongues so pure,
Wire-format, generic, sure.
Registries await the call,
Foundations built, we've landed all! ✨
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main changes: introducing hook lifecycle system with eight events and provider-agnostic model wire types for the ergon crate.
Docstring Coverage ✅ Passed Docstring coverage is 85.51% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/bro-997-ergon-hook-lifecycle

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@broomva broomva merged commit 7c5871b into main May 6, 2026
15 checks passed
@broomva broomva deleted the feature/bro-997-ergon-hook-lifecycle branch May 6, 2026 15:55
broomva added a commit that referenced this pull request May 7, 2026
… pattern (BRO-1000) (#1169)

New sibling crate at `crates/ergon/ergon-life-hooks/` housing the four
"auto-registered" hooks the spec §3.8 calls for. Each hook is paired
with a small adapter trait the hook consumes via `Arc<dyn _>`. The
arcan adapter (BRO-1001) implements those traits against the actual
substrate; this crate stays substrate-free.

- `crates/ergon/ergon-life-hooks/` — new crate, ~600 LOC + 15 tests
- `capability.rs` — `CapabilityResolver` + `PraxisCapabilityHook`
  (fires `on_pre_tool_use`; `Err(reason)` → `ToolHookOutcome::Deny` with
  reason; on Deny, autonomous loop synthesizes model-visible
  is_error result so model can recover)
- `budget.rs` — `BudgetGate` + `AutonomicBudgetHook` (fires
  `on_pre_inference`; gate operates on `&mut ModelRequest` so
  implementations can ALSO narrow the request — reduce max_tokens,
  filter tools — and return Continue)
- `score.rs` — `ResponseScorer` + `NousScoreHook` (fires
  `on_post_inference`; observe-only in v0.1; failures non-fatal,
  surface via `tracing::warn!`)
- `attestation.rs` — `SoulAttester` + `AnimaAttestHook` (fires
  `on_workflow_start` and `on_workflow_end`; failures non-fatal —
  refusing to run because attestation infra is offline is worse than
  running unsigned; observable via telemetry)

The crate's Cargo.toml lists only `ergon`, `async-trait`, `serde`,
`serde_json`, `tokio`, `tracing`. **No** `anima-core`, **no**
`autonomic-core`, **no** `nous-core`, **no** `aios-protocol-extension`.

Substrate dep lives in BRO-1001 (the arcan adapter), where each adapter
trait gets implemented against the real substrate type
(`PolicySet` for `CapabilityResolver`, `AutonomicGatingProfile` for
`BudgetGate`, `NousEvaluator` for `ResponseScorer`, `AgentSoul` for
`SoulAttester`).

Result: hooks are mockable, ergon stays vendor-neutral, substrate API
churn doesn't ripple through hooks or workflow authors.

Three new deviations stacked on top of the five from earlier PRs:

6. **Auto-hooks live in their own crate** (not in ergon). Reason: ergon
   should be vendor-neutral; Life-specific governance hooks belong in
   their own crate so a future ergon consumer (TS port, alternate agent
   OS) can ship its own governance set.

7. **Hooks consume adapter traits, not substrate types**. Each hook
   takes `Arc<dyn AdapterTrait>` at construction. The adapter trait is
   owned by ergon-life-hooks (not by anima/autonomic/nous/aios). The
   arcan adapter (BRO-1001) provides the impls.

8. **Failure semantics differ by hook**: capability and budget denials
   are hard veto (`Deny`); score and attestation failures are
   `tracing::warn!` + `Continue`. Documented at length in the crate's
   CLAUDE.md "Failure semantics — three tiers" table.

Adds two missing constructors to ergon's `model.rs`. Both types are
`#[non_exhaustive]` (set in BRO-997), which means external crates
can't struct-literal construct them. The constructors are required for
testing in ergon-life-hooks; the omission was an oversight in BRO-997.
Purely additive; ergon's 43 tests still pass.

- `cargo fmt --all -- --check` clean
- `cargo clippy -p ergon-life-hooks --all-targets -- -D warnings` clean
- `cargo clippy --workspace -- -D warnings -A clippy::too_many_arguments` clean
- `cargo test -p ergon-life-hooks --all-targets` — **15 passed; 0 failed**
- `cargo test -p ergon --all-targets` — **43 passed; 0 failed** (no regression)

This PR branches off main, parallel to #1165 (BRO-998 — autonomous loop
body in step.rs). The two are strictly independent: ergon-life-hooks
uses only the Hook trait + wire types (already in main from #1152),
NOT Step / StepCtx / Workflow (which are in #1165 / future PRs).

The substrate adapters in BRO-1001 will pull both together by
constructing a HookRegistry from these four auto-hooks.

Linear: BRO-1000. Tracker: BRO-994.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant