Skip to main content

everruns_core/capabilities/
btw.rs

1// BTW capability
2//
3// Decisions:
4// - `/btw` is its own capability so harnesses and agents can opt into the
5//   side-question command explicitly instead of depending on a generic bucket.
6// - The command executes entirely through the `CommandHost` facilities so
7//   every host (server full/dev mode, in-process runtime) gets the same
8//   behavior from this one implementation (see specs/commands.md, EVE-543).
9// - The side answer reuses the session's merged context, disables tools, and
10//   persists nothing, behaving like Claude Code's ephemeral overlay answer.
11
12use super::{Capability, CapabilityLocalization, CapabilityStatus};
13use crate::command::{
14    CommandArg, CommandDescriptor, CommandExecutionContext, CommandResult, CommandSource,
15    ExecuteCommandRequest,
16};
17use crate::command_host::SessionCompletionRequest;
18use crate::error::AgentLoopError;
19use crate::message::Message;
20use async_trait::async_trait;
21use std::collections::HashMap;
22
23pub const BTW_CAPABILITY_ID: &str = "btw";
24
25const BTW_COMMAND_NAME: &str = "btw";
26const BTW_SYSTEM_PROMPT: &str = "You are answering an ephemeral side question about the current session. Use the existing conversation as context, answer exactly once, and do not call tools or ask follow-up questions.";
27
28pub struct BtwCapability;
29
30#[async_trait]
31impl Capability for BtwCapability {
32    fn id(&self) -> &str {
33        BTW_CAPABILITY_ID
34    }
35
36    fn name(&self) -> &str {
37        "BTW"
38    }
39
40    fn description(&self) -> &str {
41        "Ephemeral side-question command for the current session."
42    }
43
44    fn localizations(&self) -> Vec<CapabilityLocalization> {
45        vec![CapabilityLocalization::text(
46            "uk",
47            "BTW",
48            "Ефемерна команда для побічних запитань у поточній сесії.",
49        )]
50    }
51
52    fn status(&self) -> CapabilityStatus {
53        CapabilityStatus::Available
54    }
55
56    fn icon(&self) -> Option<&str> {
57        Some("message-circle")
58    }
59
60    fn category(&self) -> Option<&str> {
61        Some("System")
62    }
63
64    fn commands(&self) -> Vec<CommandDescriptor> {
65        vec![CommandDescriptor {
66            name: BTW_COMMAND_NAME.to_string(),
67            description:
68                "Ask a side question about the current session without interrupting the main task."
69                    .to_string(),
70            source: CommandSource::System,
71            args: vec![CommandArg {
72                name: "question".to_string(),
73                description: "The side question to answer.".to_string(),
74                required: true,
75                suggestions: vec![],
76            }],
77        }]
78    }
79
80    async fn execute_command(
81        &self,
82        request: &ExecuteCommandRequest,
83        ctx: &CommandExecutionContext,
84    ) -> crate::error::Result<CommandResult> {
85        if request.name != BTW_COMMAND_NAME {
86            return Err(AgentLoopError::config(format!(
87                "{} cannot execute /{}",
88                self.id(),
89                request.name
90            )));
91        }
92        let question = request
93            .arguments
94            .as_deref()
95            .map(str::trim)
96            .filter(|value| !value.is_empty())
97            .ok_or_else(|| AgentLoopError::config("/btw requires a question"))?;
98
99        let turn = ctx.host.turn_context().await?;
100
101        let mut messages = turn.messages;
102        let mut side_question = Message::user(question.to_string());
103        side_question.controls = request.controls.clone();
104        messages.push(side_question);
105
106        let completion_request = SessionCompletionRequest {
107            system_prompts: vec![turn.system_prompt, BTW_SYSTEM_PROMPT.to_string()],
108            messages,
109            controls: request.controls.clone(),
110            metadata: HashMap::from([("command".to_string(), BTW_COMMAND_NAME.to_string())]),
111        };
112
113        // Provider/runtime errors are classified and returned as
114        // `success: false` (the frontend localizes via `error_code`) instead
115        // of bubbling up as hard errors.
116        match ctx.host.completion(completion_request).await {
117            Ok(completion) => Ok(CommandResult {
118                success: true,
119                message: completion.text,
120                error_code: None,
121                error_fields: None,
122            }),
123            Err(error) => error.into_command_result(),
124        }
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use crate::command_host::{
132        CommandHost, CommandTurnContext, SessionCompletion, SessionCompletionError,
133    };
134    use crate::session::{Session, SessionStatus};
135    use crate::typed_id::{HarnessId, SessionId};
136    use crate::user_facing_error::UserFacingErrorContext;
137    use std::sync::{Arc, Mutex};
138
139    // Metadata constants covered by builtin_capabilities_satisfy_registry_invariants.
140
141    #[test]
142    fn test_btw_capability_registers_command() {
143        let cap = BtwCapability;
144        let commands = cap.commands();
145        assert_eq!(commands.len(), 1);
146        assert_eq!(commands[0].name, "btw");
147        assert_eq!(commands[0].source, CommandSource::System);
148        assert_eq!(commands[0].args.len(), 1);
149        assert_eq!(commands[0].args[0].name, "question");
150        assert!(commands[0].args[0].required);
151    }
152
153    fn test_session(session_id: SessionId) -> Session {
154        Session {
155            id: session_id,
156            workspace_id: crate::WorkspaceId::from_uuid((session_id).uuid()),
157            organization_id: crate::DEFAULT_ORG_PUBLIC_ID.to_string(),
158            harness_id: HarnessId::new(),
159            agent_id: None,
160            agent_version_id: None,
161            agent_identity_id: None,
162            owner_principal_id: crate::PrincipalId::from_seed(1),
163            resolved_owner_user_id: None,
164            owner: None,
165            effective_owner: None,
166            title: None,
167            goal: None,
168            locale: None,
169            preview: None,
170            output_preview: None,
171            tags: vec![],
172            model_id: None,
173            capabilities: vec![],
174            tools: vec![],
175            mcp_servers: Default::default(),
176            system_prompt: None,
177            initial_files: vec![],
178            hints: None,
179            network_access: None,
180            max_iterations: None,
181            parallel_tool_calls: None,
182            status: SessionStatus::Started,
183            created_at: chrono::Utc::now(),
184            updated_at: chrono::Utc::now(),
185            started_at: None,
186            finished_at: None,
187            usage: None,
188            is_pinned: None,
189            active_schedule_count: None,
190            features: vec![],
191            parent_session_id: None,
192            forked_from_session_id: None,
193            forked_from_sequence: None,
194            blueprint_id: None,
195            blueprint_config: None,
196        }
197    }
198
199    struct StubHost {
200        completion_result:
201            Mutex<Option<std::result::Result<SessionCompletion, SessionCompletionError>>>,
202        seen_request: Mutex<Option<SessionCompletionRequest>>,
203    }
204
205    impl StubHost {
206        fn returning(
207            result: std::result::Result<SessionCompletion, SessionCompletionError>,
208        ) -> Arc<Self> {
209            Arc::new(Self {
210                completion_result: Mutex::new(Some(result)),
211                seen_request: Mutex::new(None),
212            })
213        }
214    }
215
216    #[async_trait]
217    impl CommandHost for StubHost {
218        async fn turn_context(&self) -> crate::error::Result<CommandTurnContext> {
219            let session_id = SessionId::new();
220            Ok(CommandTurnContext {
221                session: test_session(session_id),
222                messages: vec![Message::user("earlier message")],
223                system_prompt: "merged system prompt".to_string(),
224                model: "llmsim-model".to_string(),
225                provider_type: "llmsim".to_string(),
226                resolved_locale: None,
227            })
228        }
229
230        async fn completion(
231            &self,
232            request: SessionCompletionRequest,
233        ) -> std::result::Result<SessionCompletion, SessionCompletionError> {
234            *self.seen_request.lock().unwrap() = Some(request);
235            self.completion_result.lock().unwrap().take().unwrap()
236        }
237    }
238
239    fn execution_context(host: Arc<StubHost>) -> CommandExecutionContext {
240        CommandExecutionContext::new(SessionId::new(), host)
241    }
242
243    fn btw_request(arguments: Option<&str>) -> ExecuteCommandRequest {
244        ExecuteCommandRequest {
245            name: "btw".to_string(),
246            arguments: arguments.map(str::to_string),
247            controls: None,
248        }
249    }
250
251    #[tokio::test]
252    async fn execute_command_answers_with_session_context() {
253        let host = StubHost::returning(Ok(SessionCompletion {
254            text: "the side answer".to_string(),
255        }));
256        let ctx = execution_context(host.clone());
257
258        let result = BtwCapability
259            .execute_command(&btw_request(Some("what changed?")), &ctx)
260            .await
261            .unwrap();
262
263        assert!(result.success);
264        assert_eq!(result.message, "the side answer");
265
266        let request = host.seen_request.lock().unwrap().take().unwrap();
267        // Merged session prompt first, then the btw instruction.
268        assert_eq!(request.system_prompts.len(), 2);
269        assert_eq!(request.system_prompts[0], "merged system prompt");
270        assert!(request.system_prompts[1].contains("ephemeral side question"));
271        // History plus the appended side question.
272        assert_eq!(request.messages.len(), 2);
273        assert_eq!(request.messages[1].text(), Some("what changed?"));
274        assert_eq!(
275            request.metadata.get("command").map(String::as_str),
276            Some("btw")
277        );
278    }
279
280    #[tokio::test]
281    async fn execute_command_requires_a_question() {
282        let host = StubHost::returning(Ok(SessionCompletion {
283            text: "unused".to_string(),
284        }));
285        let ctx = execution_context(host);
286
287        for arguments in [None, Some(""), Some("   ")] {
288            let error = BtwCapability
289                .execute_command(&btw_request(arguments), &ctx)
290                .await
291                .unwrap_err();
292            assert!(error.to_string().contains("requires a question"));
293        }
294    }
295
296    #[tokio::test]
297    async fn execute_command_classifies_provider_errors() {
298        let host = StubHost::returning(Err(SessionCompletionError::Completion {
299            error: "OpenAI API error (429): rate limit exceeded".to_string(),
300            context: UserFacingErrorContext::default().with_provider("openai"),
301        }));
302        let ctx = execution_context(host);
303
304        let result = BtwCapability
305            .execute_command(&btw_request(Some("what changed?")), &ctx)
306            .await
307            .unwrap();
308
309        assert!(!result.success);
310        assert_eq!(result.error_code.as_deref(), Some("provider_rate_limited"));
311    }
312
313    #[tokio::test]
314    async fn execute_command_bubbles_invalid_completion_requests() {
315        let host = StubHost::returning(Err(SessionCompletionError::InvalidRequest(
316            AgentLoopError::config("Model not found: model_123"),
317        )));
318        let ctx = execution_context(host);
319
320        let error = BtwCapability
321            .execute_command(&btw_request(Some("what changed?")), &ctx)
322            .await
323            .unwrap_err();
324        assert!(error.to_string().contains("Model not found"));
325    }
326
327    #[tokio::test]
328    async fn execute_command_fails_without_host_facilities() {
329        let ctx = CommandExecutionContext::without_host(SessionId::new());
330
331        let error = BtwCapability
332            .execute_command(&btw_request(Some("what changed?")), &ctx)
333            .await
334            .unwrap_err();
335        assert!(error.to_string().contains("turn-context"));
336    }
337}