Skip to main content

harness_loop/
boundary_guide.rs

1//! Opt-in `Guide` for the requests an agent *cannot* fulfil.
2//!
3//! An agent is given a tool belt and a goal, and the loop is built to keep
4//! going until one of them runs out. Nothing in that arrangement tells it what
5//! to do when the user asks for something no tool can reach — order dinner,
6//! phone someone, move money — and the observed behaviour is worse than a
7//! refusal.
8//!
9//! Measured on a 50-task assistant benchmark, two different agents on the same
10//! model, asked to book a hotel / place an order / call a client / transfer
11//! funds: both said they could not, and then *did something else anyway*. One
12//! opened with "I've noted down your plan for a two-night stay in Paris!" and
13//! left the limitation as a subordinate clause. Another answered "I have logged
14//! this $500 transfer in your records" — which a person reads as money having
15//! moved. Between them they spent up to six tool calls per refusal.
16//!
17//! The cause is ordinary and worth naming: an app whose prompt says "always
18//! remember what matters" gets that instruction obeyed hardest exactly when
19//! there is nothing else to obey. Filing a note is the only available action,
20//! so the agent takes it, and the refusal ends up buried under activity.
21//!
22//! ```ignore
23//! AgentLoop::new(model)
24//!     .with_guide(std::sync::Arc::new(harness_loop::BoundaryGuide))
25//! ```
26
27use async_trait::async_trait;
28use harness_core::{Block, Context, Execution, Guide, GuideError, GuideId, GuideScope, World};
29use std::sync::OnceLock;
30
31/// Tells the agent to lead with the limitation and stop there.
32///
33/// Deliberately phrased as a test the model applies — "would this act on the
34/// world?" — rather than a list of forbidden verbs. A list is a thing to be
35/// outside of; the question generalises to whatever the app's tool belt does
36/// not cover.
37pub struct BoundaryGuide;
38
39static ID: OnceLock<GuideId> = OnceLock::new();
40static SCOPE: OnceLock<GuideScope> = OnceLock::new();
41
42/// The guidance, exposed so an app can fold it into its own system prompt
43/// instead of attaching the guide.
44pub const BOUNDARY_GUIDANCE: &str = "\
45When a request needs an ability you do not have, say so in your FIRST sentence, \
46then offer what you can actually do. The test is whether fulfilling it would act \
47on the world — placing an order, paying, moving money, contacting someone on the \
48user's behalf — not whether it sounds difficult.\n\
49Never put \"I've noted that down\" or \"I've saved it to your records\" ahead of \
50the limitation: the user reads that as the thing having been done. \"I have \
51logged this transfer in your records\" is the shape to avoid.\n\
52And do not substitute activity for help. Saving a memory, creating an entity or \
53writing a record because you could not do the real thing is noise the user did \
54not ask for. An honest \"I can't do that, but here is what I can do\" is the \
55complete answer.";
56
57#[async_trait]
58impl Guide for BoundaryGuide {
59    fn id(&self) -> &GuideId {
60        ID.get_or_init(|| "capability-boundary".into())
61    }
62    fn kind(&self) -> Execution {
63        Execution::Inferential
64    }
65    fn scope(&self) -> &GuideScope {
66        SCOPE.get_or_init(|| GuideScope::Always)
67    }
68    async fn apply(&self, ctx: &mut Context, _w: &World) -> Result<(), GuideError> {
69        ctx.guides.push(Block::Text(BOUNDARY_GUIDANCE.to_string()));
70        Ok(())
71    }
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77    use harness_core::Task;
78
79    #[tokio::test]
80    async fn it_says_refusal_comes_first_and_alone() {
81        let mut ctx = Context::new(Task {
82            description: "book me a hotel".into(),
83            source: None,
84            deadline: None,
85        });
86        let world = harness_context::default_world(".");
87        BoundaryGuide.apply(&mut ctx, &world).await.unwrap();
88
89        let text = ctx
90            .guides
91            .iter()
92            .filter_map(|b| match b {
93                Block::Text(s) => Some(s.as_str()),
94                _ => None,
95            })
96            .collect::<String>();
97
98        assert!(text.contains("FIRST sentence"), "order matters");
99        // The two halves that the benchmark showed agents getting wrong.
100        assert!(
101            text.contains("noted that down"),
102            "no filing before the refusal"
103        );
104        assert!(
105            text.contains("substitute activity"),
106            "no busywork instead of help"
107        );
108        // A list of banned verbs would be a thing to be outside of; the test
109        // has to generalise past whatever this app's tools happen to cover.
110        assert!(
111            text.contains("act on the world"),
112            "phrased as a test, not a list"
113        );
114    }
115}