Skip to main content

autumn_plugin_agent/
policy.rs

1//! Tool policies: decide, per call, whether the agent may act.
2//!
3//! The loop asks the [`ToolPolicy`] about every tool call before it runs.
4//! There are three answers:
5//!
6//! * [`ToolDecision::Allow`] — run the call.
7//! * [`ToolDecision::Deny`] — skip the call; the model sees the reason.
8//! * [`ToolDecision::RequireApproval`] — pause the run. The loop returns
9//!   [`AgentOutcome::AwaitingApproval`](crate::agent::AgentOutcome::AwaitingApproval)
10//!   with a serializable [`RunState`](crate::agent::RunState). Store it, ask a
11//!   person, then continue with
12//!   [`Agent::resume`](crate::agent::Agent::resume).
13//!
14//! [`ToolRules`] covers the common cases: per-tool rules plus a default per
15//! [`ToolEffect`].
16
17use std::collections::HashMap;
18use std::sync::Arc;
19
20use futures::future::BoxFuture;
21use serde::{Deserialize, Serialize};
22
23use crate::hooks::RunInfo;
24use crate::tools::{Tool, ToolCall, ToolEffect};
25
26/// A policy's answer for one tool call.
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(tag = "decision", rename_all = "snake_case")]
29pub enum ToolDecision {
30    /// Run the call.
31    Allow,
32    /// Pause the run until a person approves or rejects the call.
33    RequireApproval {
34        /// Why the call needs a person, shown to the reviewer.
35        reason: String,
36    },
37    /// Do not run the call. The model sees the reason as the tool result.
38    Deny {
39        /// Why the call is refused, shown to the model.
40        reason: String,
41    },
42}
43
44/// Decides whether the agent may run a tool call.
45pub trait ToolPolicy: Send + Sync + std::fmt::Debug {
46    /// Decide on one call. `tool` is `None` when the model named a tool
47    /// that does not exist; the loop reports that to the model either way.
48    fn decide<'a>(
49        &'a self,
50        call: &'a ToolCall,
51        tool: Option<&'a dyn Tool>,
52        info: &'a RunInfo,
53    ) -> BoxFuture<'a, ToolDecision>;
54}
55
56/// Allows every call. The default policy.
57#[derive(Debug, Clone, Copy, Default)]
58pub struct AllowAll;
59
60impl ToolPolicy for AllowAll {
61    fn decide<'a>(
62        &'a self,
63        _call: &'a ToolCall,
64        _tool: Option<&'a dyn Tool>,
65        _info: &'a RunInfo,
66    ) -> BoxFuture<'a, ToolDecision> {
67        Box::pin(std::future::ready(ToolDecision::Allow))
68    }
69}
70
71/// How [`ToolRules`] treats a tool or an effect class.
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub enum Rule {
74    /// Run without asking.
75    Allow,
76    /// Pause for a person.
77    Ask,
78    /// Refuse, with this reason.
79    Deny(String),
80}
81
82/// Per-tool rules plus a default per [`ToolEffect`] — the shape of "custom
83/// rules" in always-on assistants: act freely on reads, ask before acting
84/// on the world.
85///
86/// Lookup order: the rule for the tool's name, then the rule for its
87/// effect, then [`Rule::Allow`].
88///
89/// ```rust
90/// use autumn_plugin_agent::policy::{Rule, ToolRules};
91/// use autumn_plugin_agent::tools::ToolEffect;
92///
93/// let rules = ToolRules::new()
94///     .effect(ToolEffect::External, Rule::Ask)
95///     .tool("delete_account", Rule::Deny("never delete accounts".into()))
96///     .tool("send_digest", Rule::Allow);
97/// ```
98#[derive(Debug, Clone, Default)]
99pub struct ToolRules {
100    by_name: HashMap<String, Rule>,
101    by_effect: HashMap<ToolEffect, Rule>,
102}
103
104impl ToolRules {
105    /// Start with no rules: every call is allowed.
106    #[must_use]
107    pub fn new() -> Self {
108        Self::default()
109    }
110
111    /// Rules for an unattended run: read-only and internal tools run, every
112    /// other tool is refused.
113    #[must_use]
114    pub fn read_only() -> Self {
115        let deny = Rule::Deny("this run may only read data and update its own notes".to_owned());
116        Self::new()
117            .effect(ToolEffect::Write, deny.clone())
118            .effect(ToolEffect::External, deny)
119    }
120
121    /// Ask a person before any [`ToolEffect::Write`] or
122    /// [`ToolEffect::External`] call.
123    #[must_use]
124    pub fn ask_before_acting() -> Self {
125        Self::new()
126            .effect(ToolEffect::Write, Rule::Ask)
127            .effect(ToolEffect::External, Rule::Ask)
128    }
129
130    /// Set the rule for one tool name. It wins over effect rules.
131    #[must_use]
132    pub fn tool(mut self, name: impl Into<String>, rule: Rule) -> Self {
133        self.by_name.insert(name.into(), rule);
134        self
135    }
136
137    /// Set the default rule for every tool with this effect.
138    #[must_use]
139    pub fn effect(mut self, effect: ToolEffect, rule: Rule) -> Self {
140        self.by_effect.insert(effect, rule);
141        self
142    }
143
144    /// The decision for a call, without the async wrapper.
145    #[must_use]
146    pub fn decide_now(&self, call: &ToolCall, tool: Option<&dyn Tool>) -> ToolDecision {
147        let rule = self.by_name.get(&call.name).or_else(|| {
148            tool.map(Tool::effect)
149                .and_then(|effect| self.by_effect.get(&effect))
150        });
151        match rule {
152            None | Some(Rule::Allow) => ToolDecision::Allow,
153            Some(Rule::Ask) => ToolDecision::RequireApproval {
154                reason: format!("{} needs approval before it runs", call.name),
155            },
156            Some(Rule::Deny(reason)) => ToolDecision::Deny {
157                reason: reason.clone(),
158            },
159        }
160    }
161}
162
163impl ToolPolicy for ToolRules {
164    fn decide<'a>(
165        &'a self,
166        call: &'a ToolCall,
167        tool: Option<&'a dyn Tool>,
168        _info: &'a RunInfo,
169    ) -> BoxFuture<'a, ToolDecision> {
170        Box::pin(std::future::ready(self.decide_now(call, tool)))
171    }
172}
173
174/// Combines policies and keeps the strictest answer: any
175/// [`ToolDecision::Deny`] wins, then any [`ToolDecision::RequireApproval`],
176/// else [`ToolDecision::Allow`].
177///
178/// The heartbeat uses it to put read-only rules on top of the app's policy.
179#[derive(Debug, Clone)]
180pub struct Strictest(Vec<Arc<dyn ToolPolicy>>);
181
182impl Strictest {
183    /// Combine these policies.
184    #[must_use]
185    pub const fn new(policies: Vec<Arc<dyn ToolPolicy>>) -> Self {
186        Self(policies)
187    }
188}
189
190impl ToolPolicy for Strictest {
191    fn decide<'a>(
192        &'a self,
193        call: &'a ToolCall,
194        tool: Option<&'a dyn Tool>,
195        info: &'a RunInfo,
196    ) -> BoxFuture<'a, ToolDecision> {
197        Box::pin(async move {
198            let mut verdict = ToolDecision::Allow;
199            for policy in &self.0 {
200                match policy.decide(call, tool, info).await {
201                    deny @ ToolDecision::Deny { .. } => return deny,
202                    ask @ ToolDecision::RequireApproval { .. } => {
203                        if verdict == ToolDecision::Allow {
204                            verdict = ask;
205                        }
206                    }
207                    ToolDecision::Allow => {}
208                }
209            }
210            verdict
211        })
212    }
213}
214
215#[cfg(test)]
216mod tests;