main
rs 109 lines 3.61 KB
Raw
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 }