diff --git a/docs/SONNY.md b/docs/SONNY.md index 4472d138f..18f1fb71f 100644 --- a/docs/SONNY.md +++ b/docs/SONNY.md @@ -301,6 +301,12 @@ agent's other calls do (`/approvals`). Each goal is `STORE.lookout/goals/BOT.json`; setting the same goal again changes nothing. +In a window, `/goal WHAT` sends the agent `/goal WHAT` as the person's +message, and the goal tool's description asks it to set a goal on itself +for it: like Codex's `/goal`, the agent picks the check. `/goal` shows the +goals of the agent and its tasks, and `/goal clear` clears its own, as the +tool would. + Each time a turn of that agent completes, the lookout runs the check, for up to `timeout_s` (600 when left out, at most 3,600), killing its process group past that. While it fails, it queues the agent a turn (origin diff --git a/sonny/README.md b/sonny/README.md index 22cfa0dfd..48c77c52c 100644 --- a/sonny/README.md +++ b/sonny/README.md @@ -56,7 +56,9 @@ of the first rather than above it. `sonny` opens the one last written to in that folder, else the first. `--lead NAME` opens another (`--lead lead` the first), and `--new-lead NAME` makes one (numbered, as `lead.2`, with no name). In a window, `/lead` lists the folder's leads, `/lead NAME` opens one -and `/lead new [NAME]` makes one. A lead hears of, shows and lists in +and `/lead new [NAME]` makes one. `/goal WHAT` asks the agent to set a goal +on itself and work until WHAT is done (`/goal` shows goals, `/goal clear` +clears its own; see [Goals](../docs/SONNY.md#goals)). A lead hears of, shows and lists in `/status` only its own tasks. It is told to send work for another lead's task to that lead, to leave changes it did not make alone and never to switch the folder's branch, since the leads share one checkout; the instructions, diff --git a/sonny/src/lookout/goals.rs b/sonny/src/lookout/goals.rs index 17ce3d5cb..88e56ea7c 100644 --- a/sonny/src/lookout/goals.rs +++ b/sonny/src/lookout/goals.rs @@ -295,7 +295,8 @@ pub fn tool(name: &str, tag: &str) -> Value { again, and its lead hears nothing of those turns. The goal ends once the check passes, the \ budget is spent, two turns it started call no tool, or the agent gives up with blocked and why, \ and its lead hears that turn with how it ended. Setting the same goal again changes nothing; \ - with nothing given, the result lists your goals and your tasks'.", + with nothing given, the result lists your goals and your tasks'. A person's message that \ + starts with /goal asks you to set one on yourself for what it says.", "parameters": { "type": "object", "properties": { diff --git a/sonny/src/main.rs b/sonny/src/main.rs index 31e565e80..f9013f430 100644 --- a/sonny/src/main.rs +++ b/sonny/src/main.rs @@ -1474,7 +1474,7 @@ fn hold(unsent: &mut Vec, record: String) { /// The slash commands, as the menu lists them: name and what it does. /// `/agents` and `/ps` also open `/status`, `/triggers` and `/lookout` /// open `/background`, and `/settings` opens `/config`, unlisted. -const COMMANDS: [(&str, &str); 19] = [ +const COMMANDS: [(&str, &str); 20] = [ ( "/side", "a side chat with a copy of this agent, which carries on", @@ -1492,6 +1492,10 @@ const COMMANDS: [(&str, &str); 19] = [ ("/instructions", "the lead's, from its folder again"), ("/status", "this project's agents"), ("/lead", "the folder's leads: open another, or make one"), + ( + "/goal", + "ask the agent to work until something is done; show or clear goals", + ), ( "/margin", "the agent's notes beside the conversation, shown or closed", @@ -7742,6 +7746,16 @@ impl App { self.lead_command(rest.trim()).await; return; } + // `/goal WHAT` goes to the agent as typed, which sets its goal. + if let Some(rest) = prompt + .strip_prefix("/goal") + .map(str::trim) + .filter(|rest| matches!(*rest, "" | "clear")) + { + self.input.clear(); + self.notice = Some(self.goal_command(rest == "clear").await); + return; + } if let Some(question) = prompt .strip_prefix("/side") .filter(|rest| rest.is_empty() || rest.starts_with(char::is_whitespace)) @@ -8191,6 +8205,44 @@ impl App { } } + /// `/goal` shows the goals of this agent and its tasks, and `/goal + /// clear` clears its own, as its goal tool would. + async fn goal_command(&mut self, clear: bool) -> String { + let arguments = match clear { + true => json!({"clear": self.bot}), + false => json!({}), + }; + let said = + lookout::goals::call(&self.options.store, &self.client, &self.bot, &arguments).await; + match said { + Err(error) => error, + Ok(said) if clear => match said["cleared"] == true { + true => "goal cleared".into(), + false => "no goal to clear".into(), + }, + Ok(said) => { + let goals = said["goals"].as_array().cloned().unwrap_or_default(); + if goals.is_empty() { + return "no goal; /goal WHAT asks the agent to work until WHAT is done".into(); + } + let shown: Vec = goals + .iter() + .map(|goal| { + format!( + "{}: done when {}", + goal["bot"].as_str().unwrap_or_default(), + goal["done_when"].as_str().unwrap_or_default() + ) + }) + .collect(); + format!( + "goals: {}; /goal clear clears this agent's", + shown.join("; ") + ) + } + } + } + /// `/lead`: the folder's leads; `/lead NAME` opens this window on one, /// and `/lead new [NAME]` makes one and opens it. async fn lead_command(&mut self, rest: &str) { @@ -9701,6 +9753,58 @@ mod tests { ); } + #[tokio::test] + async fn goal_words_go_to_the_agent_as_typed_and_goal_alone_shows_or_clears() { + let asked = Arc::new(std::sync::Mutex::new(Vec::new())); + let seen = asked.clone(); + let client = daemon_answering(move |asked| { + seen.lock().unwrap().push(asked.clone()); + match asked["op"].as_str() { + Some("inspect") => json!({"bot_id": 1, "workspace": "/"}), + Some("submit") => json!({"turn": 1}), + _ => json!({}), + } + }) + .await; + let mut app = app(client); + let store = std::env::temp_dir().join(format!("goal-window-{}", std::process::id())); + app.options.store = store.clone(); + app.input.set("/goal"); + app.submit().await; + assert_eq!( + app.notice.as_deref(), + Some("no goal; /goal WHAT asks the agent to work until WHAT is done") + ); + // The words reach the agent as the person typed them. + app.input.set("/goal the tests pass"); + app.submit().await; + let ops = asked.lock().unwrap().clone(); + let submits: Vec<_> = ops.iter().filter(|op| op["op"] == "submit").collect(); + assert_eq!(submits.len(), 1); + assert!(submits[0].to_string().contains("/goal the tests pass")); + // The agent sets one with its tool; /goal shows it and clears it. + let set = json!({"set": {"done_when": "the tests pass", "check": "true"}}); + lookout::goals::call(&store, &app.client, "b", &set) + .await + .unwrap(); + app.input.set("/goal"); + app.submit().await; + assert_eq!( + app.notice.as_deref(), + Some("goals: b: done when the tests pass; /goal clear clears this agent's") + ); + app.input.set("/goal clear"); + app.submit().await; + assert_eq!(app.notice.as_deref(), Some("goal cleared")); + assert!(lookout::goals::read(&store, "b").is_none()); + app.input.set("/goal clear"); + app.submit().await; + assert_eq!(app.notice.as_deref(), Some("no goal to clear")); + let ops = asked.lock().unwrap().clone(); + assert_eq!(ops.iter().filter(|op| op["op"] == "submit").count(), 1); + std::fs::remove_dir_all(lookout::subs::dir(&store)).unwrap(); + } + #[tokio::test] async fn a_turn_the_person_steered_from_here_is_theirs() { let next = std::sync::atomic::AtomicI64::new(2);