| 1 | //! What a host takes out of the agent on a platform that can't start processes, |
| 2 | //! and the stems paragraph an app swaps in when it has its own answer. |
| 3 | //! |
| 4 | //! iOS apps can't start other programs, so `run_command` would only ever fail |
| 5 | //! there and the stock "install demucs" advice is no use. Each app's iOS |
| 6 | //! profile wraps its desktop one with these. |
| 7 | |
| 8 | use std::sync::Arc; |
| 9 | |
| 10 | use async_trait::async_trait; |
| 11 | use ed_acp::agent_client_protocol::schema::v1::{ToolCallLocation, ToolKind}; |
| 12 | use ed_acp::{DescribeCtx, ToolCtx, ToolOutcome, Toolset}; |
| 13 | use serde_json::Value; |
| 14 | |
| 15 | // The markers the "no stems" paragraph starts and ends with live in `prompt.rs`, which writes |
| 16 | // the paragraph, so the prompt and the replacement below can't drift apart. |
| 17 | pub use crate::prompt::{NO_STEMS_END, NO_STEMS_START}; |
| 18 | |
| 19 | /// The workspace tool that starts a process. |
| 20 | pub const RUN_COMMAND: &str = "run_command"; |
| 21 | |
| 22 | /// Swap the stock "no stems" paragraph for `note`; if the stock prompt is |
| 23 | /// worded differently, append the note rather than lose it. |
| 24 | pub fn replace_no_stems(prompt: String, note: &str) -> String { |
| 25 | if let Some(start) = prompt.find(NO_STEMS_START) |
| 26 | && let Some(rel) = prompt[start..].find(NO_STEMS_END) |
| 27 | { |
| 28 | let end = start + rel + NO_STEMS_END.len(); |
| 29 | return format!("{}{}{}", &prompt[..start], note, &prompt[end..]); |
| 30 | } |
| 31 | format!("{prompt}\n\n{note}") |
| 32 | } |
| 33 | |
| 34 | /// The name of a tool definition, as the model is offered it. |
| 35 | pub fn tool_name(definition: &Value) -> Option<&str> { |
| 36 | definition.pointer("/function/name").and_then(Value::as_str) |
| 37 | } |
| 38 | |
| 39 | /// `toolsets` with `run_command` taken out of what the model is offered. |
| 40 | pub fn without_commands(toolsets: Vec<Arc<dyn Toolset>>) -> Vec<Arc<dyn Toolset>> { |
| 41 | toolsets |
| 42 | .into_iter() |
| 43 | .map(|inner| Arc::new(NoCommands(inner)) as Arc<dyn Toolset>) |
| 44 | .collect() |
| 45 | } |
| 46 | |
| 47 | struct NoCommands(Arc<dyn Toolset>); |
| 48 | |
| 49 | #[async_trait] |
| 50 | impl Toolset for NoCommands { |
| 51 | fn definitions(&self) -> Vec<Value> { |
| 52 | self.0 |
| 53 | .definitions() |
| 54 | .into_iter() |
| 55 | .filter(|d| tool_name(d) != Some(RUN_COMMAND)) |
| 56 | .collect() |
| 57 | } |
| 58 | |
| 59 | fn handles(&self, name: &str) -> bool { |
| 60 | name != RUN_COMMAND && self.0.handles(name) |
| 61 | } |
| 62 | |
| 63 | fn describe( |
| 64 | &self, |
| 65 | name: &str, |
| 66 | args: &Value, |
| 67 | ctx: &DescribeCtx<'_>, |
| 68 | ) -> (String, ToolKind, Vec<ToolCallLocation>) { |
| 69 | self.0.describe(name, args, ctx) |
| 70 | } |
| 71 | |
| 72 | async fn execute( |
| 73 | &self, |
| 74 | ctx: &ToolCtx, |
| 75 | tool_call_id: &str, |
| 76 | name: &str, |
| 77 | args: Value, |
| 78 | ) -> anyhow::Result<ToolOutcome> { |
| 79 | self.0.execute(ctx, tool_call_id, name, args).await |
| 80 | } |
| 81 | } |
| 82 | |
| 83 | #[cfg(test)] |
| 84 | mod tests { |
| 85 | use super::*; |
| 86 | |
| 87 | #[test] |
| 88 | fn replaces_the_stock_paragraph() { |
| 89 | let stock = |
| 90 | format!("before\n{NO_STEMS_START} tell them to install demucs, {NO_STEMS_END}\nafter"); |
| 91 | let out = replace_no_stems(stock, "NOTE"); |
| 92 | assert_eq!(out, "before\nNOTE\nafter"); |
| 93 | } |
| 94 | |
| 95 | #[test] |
| 96 | fn appends_when_the_wording_changed() { |
| 97 | let out = replace_no_stems("something else".to_string(), "NOTE"); |
| 98 | assert_eq!(out, "something else\n\nNOTE"); |
| 99 | } |
| 100 | |
| 101 | #[test] |
| 102 | fn the_markers_bound_the_real_prompt_paragraph() { |
| 103 | // The prompt the agent ships must still contain the paragraph these markers point at, |
| 104 | // or a host's `replace_no_stems` would silently append instead of replacing. |
| 105 | let prompt = crate::prompt::system_prompt("acp", std::path::Path::new("/tmp"), &[], false); |
| 106 | let start = prompt.find(NO_STEMS_START).expect("start marker present"); |
| 107 | assert!(prompt[start..].contains(NO_STEMS_END), "end marker present"); |
| 108 | } |
| 109 | } |