af-agent-runtime 0.3.0

Recoverable Turn/Step loop, tool pipeline, retry and context compaction.
Documentation
use std::collections::BTreeSet;
use std::sync::Arc;

use af_agent::{
    ChatModel, ContextContributor, Hook, MountedPlugins, PluginError, PromptAuthority,
    PromptSection, ToolRegistry,
};

use crate::{
    AgentRuntime, ApproximateTokenMeter, Compactor, ModelCompactor, RuntimeLimits, TokenMeter,
};

impl AgentRuntime {
    /// Runtime for `model` under `model_name` with `system_prompt` as the product prompt section and default limits.
    pub fn new(
        model: Arc<dyn ChatModel>,
        model_name: impl Into<String>,
        system_prompt: impl Into<String>,
    ) -> Self {
        let mut prompts = af_agent::PromptRegistry::default();
        prompts
            .insert(PromptSection {
                id: "product.base".into(),
                order: 100,
                authority: PromptAuthority::Product,
                source: "product".into(),
                version: env!("CARGO_PKG_VERSION").into(),
                content: system_prompt.into(),
            })
            .expect("base prompt is validated by ProductRuntime");
        Self {
            compactor: Arc::new(ModelCompactor::new(Arc::clone(&model))),
            model,
            model_name: model_name.into(),
            prompts,
            contexts: Vec::new(),
            tools: ToolRegistry::new(),
            hooks: Vec::new(),
            limits: RuntimeLimits::default(),
            meter: Arc::new(ApproximateTokenMeter),
            plugins: None,
            reasoning_effort: None,
            output_policy: None,
        }
    }

    /// Replace the tool registry.
    pub fn with_tools(mut self, tools: ToolRegistry) -> Self {
        self.tools = tools;
        self
    }

    /// Install hooks; they run in this order.
    pub fn with_hooks(mut self, hooks: Vec<Arc<dyn Hook>>) -> Self {
        self.hooks = hooks;
        self
    }

    /// Replace the runtime limits.
    pub fn with_limits(mut self, limits: RuntimeLimits) -> Self {
        self.limits = limits;
        self
    }

    /// Replace the token meter.
    pub fn with_token_meter(mut self, meter: Arc<dyn TokenMeter>) -> Self {
        self.meter = meter;
        self
    }

    /// Replace the compactor.
    pub fn with_compactor(mut self, compactor: Arc<dyn Compactor>) -> Self {
        self.compactor = compactor;
        self
    }

    /// Pin the reasoning effort for every model request.
    pub fn with_reasoning_effort(mut self, effort: af_llm::ReasoningEffort) -> Self {
        self.reasoning_effort = Some(effort);
        self
    }

    /// Validate assistant output against this JSON Schema before it is persisted.
    pub fn with_output_policy(mut self, schema: serde_json::Value) -> Result<Self, PluginError> {
        af_agent::validate_json_schema_definition(&schema).map_err(PluginError::Conflict)?;
        self.output_policy = Some(schema);
        Ok(self)
    }

    /// Add a prompt section; duplicate ids are rejected.
    pub fn with_prompt_section(mut self, section: PromptSection) -> Result<Self, PluginError> {
        self.prompts
            .insert(section)
            .map_err(PluginError::Conflict)?;
        Ok(self)
    }

    /// Add a per-step context contributor.
    pub fn with_context_contributor(mut self, context: Arc<dyn ContextContributor>) -> Self {
        self.contexts.push(context);
        self
    }

    /// Adopt the prompts, tools, hooks and contributors of mounted plugins.
    pub fn with_plugins(mut self, mounted: MountedPlugins) -> Result<Self, PluginError> {
        self.tools
            .extend(mounted.tools())
            .map_err(PluginError::Conflict)?;
        self.hooks.extend(mounted.hooks().iter().cloned());
        self.contexts.extend(mounted.contexts().iter().cloned());
        for prompt in mounted.prompts() {
            self.prompts
                .insert(prompt.clone())
                .map_err(PluginError::Conflict)?;
        }
        self.plugins = Some(mounted);
        Ok(self)
    }

    /// Narrow the tool registry to `allowed`; `None` keeps every tool.
    pub fn with_allowed_tools(mut self, allowed: Option<&BTreeSet<String>>) -> Self {
        if let Some(allowed) = allowed {
            self.tools = self.tools.filtered(allowed.iter().map(String::as_str));
        }
        self
    }
}