agentwerk 0.1.13

A minimal Rust crate that gives any application agentic capabilities.
Documentation
//! Structured events the loop emits so callers can observe a run
//! without wrapping the loop itself.

use std::sync::Arc;

use crate::providers::{RequestErrorKind, TokenUsage};

/// Why the context-window compaction seam fired.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CompactReason {
    /// The next-request token estimate crossed the model's compaction
    /// threshold before sending; the warning fired ahead of any failure.
    Proactive,
    /// The provider itself reported a context-window overflow, either as
    /// a `ProviderError::ContextWindowExceeded` or via
    /// `ResponseStatus::ContextWindowExceeded` on a successful reply.
    Reactive,
}

/// Which configured policy a [`EventKind::PolicyViolated`] refers to.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PolicyKind {
    /// `max_turns` — the turn cap across all agents.
    Turns,
    /// `max_input_tokens` — cumulative request-side token cap.
    InputTokens,
    /// `max_output_tokens` — cumulative reply-side token cap.
    OutputTokens,
    /// `max_schema_retries` — consecutive schema-validation failures
    /// while processing one ticket. Resets after every successful
    /// schema-checked tool call.
    MaxSchemaRetries,
}

/// Categorical discriminant for [`EventKind::ToolCallFailed`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolFailureKind {
    /// The registry had no tool with that name.
    ToolNotFound,
    /// The tool was invoked but its execution raised an error.
    ExecutionFailed,
    /// A schema-checked tool rejected its input. Counted against
    /// `policies.max_schema_retries` by the loop.
    SchemaValidationFailed,
}

/// Observation emitted during an agent run.
#[derive(Debug, Clone)]
pub struct Event {
    /// Name of the agent that produced this event.
    pub agent_name: String,
    /// What happened.
    pub kind: EventKind,
}

impl Event {
    pub(crate) fn new(agent_name: impl Into<String>, kind: EventKind) -> Self {
        Self {
            agent_name: agent_name.into(),
            kind,
        }
    }
}

/// Categorical discriminant of [`Event`].
#[derive(Debug, Clone)]
pub enum EventKind {
    /// Agent claimed a ticket and began working on it.
    TicketStarted { key: String },
    /// Ticket settled with `Status::Done`.
    TicketDone { key: String },
    /// Ticket settled with `Status::Failed`.
    TicketFailed { key: String },
    /// Provider request began.
    RequestStarted { model: String },
    /// Provider request finished successfully. Carries the model and the
    /// token counts the provider reported for the response.
    RequestFinished { model: String, usage: TokenUsage },
    /// Provider request failed. The run is about to stop for this ticket.
    RequestFailed {
        kind: RequestErrorKind,
        message: String,
    },
    /// Provider request failed transiently; the loop is about to sleep
    /// and retry. `attempt` is 1-based; on the first retry the user
    /// sees `attempt = 1, max_attempts = N`.
    RequestRetried {
        attempt: u32,
        max_attempts: u32,
        kind: RequestErrorKind,
        message: String,
    },
    /// A streamed text chunk arrived from the provider.
    TextChunkReceived { content: String },
    /// Tool invocation began.
    ToolCallStarted {
        tool_name: String,
        call_id: String,
        input: serde_json::Value,
    },
    /// Tool invocation succeeded.
    ToolCallFinished {
        tool_name: String,
        call_id: String,
        output: String,
    },
    /// Tool invocation failed. The error is sent back to the model as a
    /// tool-result message; the run continues.
    ToolCallFailed {
        tool_name: String,
        call_id: String,
        message: String,
        kind: ToolFailureKind,
    },
    /// A configured policy was exceeded; the run is about to stop.
    PolicyViolated { kind: PolicyKind, limit: u64 },
    /// A `done`-side schema validation failed; the loop is about to
    /// re-prompt the model with a corrective directive. `attempt` is
    /// 1-based.
    SchemaRetried {
        attempt: u32,
        max_attempts: u32,
        message: String,
    },
    /// Compaction is about to run: the loop is about to call the
    /// summarizer to collapse the message tail. Pairs with
    /// `CompactionFinished` (success) or `CompactionFailed` (the
    /// summarizer call returned a provider error).
    CompactionStarted { reason: CompactReason },
    /// Compaction finished successfully; the message tail has been
    /// replaced with the model's summary.
    CompactionFinished { reason: CompactReason },
    /// Compaction failed: the summarizer call returned a provider
    /// error. The ticket is about to fail via the usual
    /// `RequestFailed` path.
    CompactionFailed {
        reason: CompactReason,
        message: String,
    },
    /// Layer 2 blocking limit tripped: the estimated next-request token
    /// count crossed `window - BLOCKING_HEADROOM_TOKENS`. The loop
    /// synthesizes a `ContextWindowExceeded` and routes it through the
    /// reactive seam without issuing the provider call.
    BlockingLimitExceeded {
        estimated_tokens: u64,
        threshold_tokens: u64,
    },
}

/// Default observer. Prints ticket lifecycle, tool activity, policy
/// violations, and request failures to stderr. Quiet variants
/// (token counts, streaming chunks, request start/finish) are dropped.
pub fn default_logger() -> Arc<dyn Fn(Event) + Send + Sync> {
    Arc::new(|event: Event| {
        let agent = &event.agent_name;
        match &event.kind {
            EventKind::TicketStarted { key } => {
                eprintln!("[{agent}] started {key}");
            }
            EventKind::TicketDone { key } => {
                eprintln!("[{agent}] done {key}");
            }
            EventKind::TicketFailed { key } => {
                eprintln!("[{agent}] failed {key}");
            }
            EventKind::ToolCallStarted {
                tool_name, input, ..
            } => {
                eprintln!("[{agent}] {tool_name}({})", compact_input(input));
            }
            EventKind::ToolCallFailed {
                tool_name,
                message,
                kind,
                ..
            } => {
                eprintln!("[{agent}] {tool_name} failed ({kind:?}): {message}");
            }
            EventKind::RequestFailed { message, .. } => {
                eprintln!("[{agent}] request failed: {message}");
            }
            EventKind::RequestRetried {
                attempt,
                max_attempts,
                message,
                ..
            } => {
                eprintln!("[{agent}] retry {attempt}/{max_attempts}: {message}");
            }
            EventKind::SchemaRetried {
                attempt,
                max_attempts,
                message,
            } => {
                eprintln!("[{agent}] schema retry {attempt}/{max_attempts}: {message}");
            }
            EventKind::PolicyViolated { kind, limit } => {
                eprintln!("[{agent}] policy violated: {kind:?} limit={limit}");
            }
            EventKind::CompactionStarted { reason } => {
                eprintln!("[{agent}] compacting context ({reason:?})");
            }
            EventKind::CompactionFinished { reason } => {
                eprintln!("[{agent}] context compacted ({reason:?})");
            }
            EventKind::CompactionFailed { reason, message } => {
                eprintln!("[{agent}] compaction failed ({reason:?}): {message}");
            }
            EventKind::BlockingLimitExceeded {
                estimated_tokens,
                threshold_tokens,
            } => {
                eprintln!(
                    "[{agent}] blocking limit: estimated {estimated_tokens} tokens >= {threshold_tokens}",
                );
            }
            _ => {}
        }
    })
}

fn compact_input(input: &serde_json::Value) -> String {
    let one_line = input.to_string().replace('\n', " ");
    const MAX: usize = 80;
    if one_line.chars().count() <= MAX {
        one_line
    } else {
        let cut: String = one_line.chars().take(MAX).collect();
        format!("{cut}")
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::providers::TokenUsage;

    fn all_variants() -> Vec<EventKind> {
        vec![
            EventKind::TicketStarted { key: "T-1".into() },
            EventKind::TicketDone { key: "T-1".into() },
            EventKind::TicketFailed { key: "T-1".into() },
            EventKind::RequestStarted { model: "m".into() },
            EventKind::RequestFinished {
                model: "m".into(),
                usage: TokenUsage::default(),
            },
            EventKind::RequestFailed {
                kind: RequestErrorKind::ConnectionFailed,
                message: "timeout".into(),
            },
            EventKind::RequestRetried {
                attempt: 1,
                max_attempts: 10,
                kind: RequestErrorKind::ConnectionFailed,
                message: "transient".into(),
            },
            EventKind::SchemaRetried {
                attempt: 1,
                max_attempts: 5,
                message: "missing required field 'idx'".into(),
            },
            EventKind::TextChunkReceived {
                content: "hello".into(),
            },
            EventKind::ToolCallStarted {
                tool_name: "bash".into(),
                call_id: "c1".into(),
                input: serde_json::json!({"cmd": "ls"}),
            },
            EventKind::ToolCallFinished {
                tool_name: "bash".into(),
                call_id: "c1".into(),
                output: "file.txt".into(),
            },
            EventKind::ToolCallFailed {
                tool_name: "bash".into(),
                call_id: "c1".into(),
                message: "not found".into(),
                kind: ToolFailureKind::ToolNotFound,
            },
            EventKind::ToolCallFailed {
                tool_name: "manage_tickets_tool".into(),
                call_id: "c2".into(),
                message: "Schema validation failed".into(),
                kind: ToolFailureKind::SchemaValidationFailed,
            },
            EventKind::PolicyViolated {
                kind: PolicyKind::Turns,
                limit: 10,
            },
            EventKind::PolicyViolated {
                kind: PolicyKind::MaxSchemaRetries,
                limit: 10,
            },
            EventKind::CompactionStarted {
                reason: CompactReason::Proactive,
            },
            EventKind::CompactionFinished {
                reason: CompactReason::Proactive,
            },
            EventKind::CompactionFailed {
                reason: CompactReason::Reactive,
                message: "summarize call failed".into(),
            },
            EventKind::BlockingLimitExceeded {
                estimated_tokens: 197_500,
                threshold_tokens: 197_000,
            },
        ]
    }

    #[test]
    fn default_logger_handles_every_variant() {
        let logger = default_logger();
        for kind in all_variants() {
            logger(Event::new("agent", kind));
        }
    }
}