From eb41f300317ccb8f8501bef6ed2497df5312f4de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20A=C3=9Fmus?= Date: Thu, 16 Jul 2026 05:43:52 +0200 Subject: [PATCH 1/9] feat(goals): host-driven goal controller over the shared orchestration domain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit code-assistant becomes the second consumer of agent_orchestration: a GoalController drives session-owned Running goals one bounded turn at a time through start_turn_if_idle (claim precedes dispatch, Busy abandons without spending budget, failed turns and evaluator errors are ledgered as controller-error attempts), evaluates the typed TurnOutcome evidence, and folds verdicts back through the store's revision/claim semantics. Clock waits (until/timeout) resolve in the same pass; other barriers park until timeout or a deliberate resume. Continuation is host-driven only — the loop lives and dies with the process, no unattended auto-resume after exit (that stronger contract stays with pal). --- Cargo.lock | 2 + crates/code_assistant_core/Cargo.toml | 2 + crates/code_assistant_core/src/goals.rs | 795 ++++++++++++++++++++++++ crates/code_assistant_core/src/lib.rs | 1 + 4 files changed, 800 insertions(+) create mode 100644 crates/code_assistant_core/src/goals.rs diff --git a/Cargo.lock b/Cargo.lock index d62134e8..c33d4f7a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1539,11 +1539,13 @@ name = "code_assistant_core" version = "0.2.14" dependencies = [ "agent_core", + "agent_orchestration", "anyhow", "async-channel 2.5.0", "async-trait", "axum", "base64 0.22.1", + "chrono", "clap", "command_executor", "dirs 5.0.1", diff --git a/crates/code_assistant_core/Cargo.toml b/crates/code_assistant_core/Cargo.toml index 98e08c0f..afb15025 100644 --- a/crates/code_assistant_core/Cargo.toml +++ b/crates/code_assistant_core/Cargo.toml @@ -12,6 +12,7 @@ test-utils = [] [dependencies] agent_core = { path = "../agent_core" } +agent_orchestration = { path = "../agent_orchestration" } command_executor = { path = "../command_executor" } fs_explorer = { path = "../fs_explorer" } git = { path = "../git" } @@ -55,6 +56,7 @@ tracing = "0.1" clap = { version = "4.5", features = ["derive"] } async-trait = "0.1" +chrono = { version = "0.4", features = ["serde"] } dirs = "5.0" md5 = "0.7.0" async-channel = "2.5.0" diff --git a/crates/code_assistant_core/src/goals.rs b/crates/code_assistant_core/src/goals.rs new file mode 100644 index 00000000..18ff3671 --- /dev/null +++ b/crates/code_assistant_core/src/goals.rs @@ -0,0 +1,795 @@ +//! Durable goals for ordinary code-assistant sessions — the host-side +//! consumer of the shared [`agent_orchestration`] goal domain. +//! +//! A goal is owned by the session that created it ([`session_owner`]) and +//! persisted in `goals.json` in the code-assistant data directory, so it +//! survives a session reload. Continuation is host-driven: while the app is +//! open, [`GoalController::pass`] drives each `Running` goal one bounded turn +//! at a time through [`SessionService::start_turn_if_idle`], evaluates the +//! typed [`TurnOutcome`](crate::session::TurnOutcome) with a +//! [`GoalEvaluator`], and folds the verdict back into the store. There is +//! deliberately no unattended auto-resume after the process ends — that +//! stronger contract (startup sweeps, orphan adoption, channel delivery) +//! belongs to hosts like pal. +//! +//! Invariants, enforced by the shared store and honoured here: +//! - claims precede work: [`GoalRepository::claim_attempt`] persists the +//! in-flight marker before any turn is dispatched; +//! - every claimed attempt consumes budget or closes with an explicit +//! abandonment (the sole abandonment path is an atomic `Busy` answer from +//! `start_turn_if_idle`, where no work was dispatched); +//! - a stale in-flight marker from an earlier process is folded as an +//! interrupted attempt — a crash cannot silently refund work; +//! - concurrent pause/cancel and turn completion merge through the store's +//! revision/claim-token semantics instead of overwriting each other. + +use crate::session::{SessionService, TurnDispatch, TurnRequest, TurnStatus}; +use agent_core::ui::ToolStatus; +use agent_orchestration::goals::{ + goal_turn_text, AttemptCompletion, ControllerDecision, GoalRepository, GoalState, GoalStore, + TurnOutcome as GoalTurnOutcome, +}; +use agent_orchestration::waits::{WaitRepository, WaitStore}; +use agent_orchestration::OwnerKey; +use anyhow::Result; +use chrono::NaiveDateTime; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; +use tracing::{debug, warn}; + +// Re-exports so frontends can wire the controller without depending on +// agent_orchestration directly (mirrors pal's shim pattern). +pub use agent_orchestration::goal_eval::LlmGoalEvaluator; +pub use agent_orchestration::goals::{Goal, GoalEvaluator}; + +/// `goals.json` in the code-assistant data directory — next to `sessions/`, +/// because goals reference sessions, not projects. +pub fn default_goals_path() -> PathBuf { + data_dir().join("goals.json") +} + +/// `waits.json` in the code-assistant data directory: durable wait barriers +/// armed by a goal turn's `waiting` verdict. +pub fn default_waits_path() -> PathBuf { + data_dir().join("waits.json") +} + +fn data_dir() -> PathBuf { + dirs::data_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("code-assistant") +} + +/// The owner key of a session's goals. Namespaced so a store shared with +/// other hosts (which key by channel lane) stays unambiguous. +pub fn session_owner(session_id: &str) -> OwnerKey { + OwnerKey::from_parts(&["session", session_id]) +} + +/// The session id behind a [`session_owner`] key; `None` for foreign owners. +pub fn owner_session_id(owner: &OwnerKey) -> Option<&str> { + owner.as_str().strip_prefix("session:") +} + +const MAX_TOOL_EVIDENCE_CHARS: usize = 4_000; + +/// Map the typed session [`TurnOutcome`](crate::session::TurnOutcome) onto +/// the evaluator's structured input. The evidence is independent of the +/// assistant's narration: successful/error tool results, path-like inputs and +/// resource writes become verification and artifact evidence. +pub fn goal_turn_evidence(outcome: &crate::session::TurnOutcome) -> GoalTurnOutcome { + let mut artifacts: Vec = Vec::new(); + for resource in &outcome.resources_written { + let artifact = if resource.project.is_empty() { + resource.path.display().to_string() + } else { + format!("{}:{}", resource.project, resource.path.display()) + }; + if !artifacts.contains(&artifact) { + artifacts.push(artifact); + } + } + let verification = outcome + .tools + .iter() + .filter(|tool| matches!(tool.status, ToolStatus::Success | ToolStatus::Error)) + .map(tool_evidence_line) + .collect(); + GoalTurnOutcome { + assistant_summary: outcome.final_response.trim().to_string(), + artifacts, + verification, + } +} + +/// One completed tool as an evidence line: `name [Status]: message`, its +/// path-like inputs, and a bounded output excerpt. +fn tool_evidence_line(tool: &crate::session::ToolRecord) -> String { + let name = if tool.name.is_empty() { + tool.tool_id.as_str() + } else { + tool.name.as_str() + }; + let mut parts = vec![format!("{name} [{:?}]", tool.status)]; + let mut path_parameters: Vec<_> = tool + .parameters + .iter() + .filter(|(name, value)| is_path_parameter(name) && !value.trim().is_empty()) + .map(|(name, value)| format!("{name}={}", value.trim())) + .collect(); + path_parameters.sort(); + if !path_parameters.is_empty() { + parts.push(format!("inputs: {}", path_parameters.join(", "))); + } + if let Some(message) = tool + .message + .as_deref() + .map(str::trim) + .filter(|text| !text.is_empty()) + { + parts[0].push_str(": "); + parts[0].push_str(message); + } + if let Some(output) = tool + .output + .as_deref() + .map(str::trim) + .filter(|text| !text.is_empty()) + { + parts.push(truncate_chars(output, MAX_TOOL_EVIDENCE_CHARS)); + } + parts.join("\n") +} + +fn is_path_parameter(name: &str) -> bool { + matches!( + name, + "path" | "file" | "file_path" | "filename" | "output_path" + ) +} + +fn truncate_chars(text: &str, max: usize) -> String { + if text.chars().count() <= max { + return text.to_string(); + } + let mut truncated: String = text.chars().take(max).collect(); + truncated.push_str("…[truncated]"); + truncated +} + +/// Drives the session-owned goals of one [`SessionService`]. One `pass` +/// resolves due clock waits, sweeps deadlines, and gives each `Running` goal +/// at most one bounded turn. +pub struct GoalController { + service: SessionService, + goals: Arc, + waits: Arc, + evaluator: Arc, +} + +impl GoalController { + pub fn new( + service: SessionService, + goals: Arc, + waits: Arc, + evaluator: Arc, + ) -> Self { + Self { + service, + goals, + waits, + evaluator, + } + } + + /// Controller over the JSON stores at the given paths. + pub fn with_stores( + service: SessionService, + goals_path: impl Into, + waits_path: impl Into, + evaluator: Arc, + ) -> Self { + Self::new( + service, + Arc::new(GoalStore::new(goals_path.into())), + Arc::new(WaitStore::new(waits_path.into())), + evaluator, + ) + } + + /// One sweep at the local wall clock. + pub async fn pass(&self) -> Result<()> { + self.pass_at(chrono::Local::now().naive_local()).await + } + + /// One sweep at a fixed `now` — deterministic for tests and hosts with + /// their own clock. + pub async fn pass_at(&self, now: NaiveDateTime) -> Result<()> { + self.wait_pass(now)?; + self.goal_pass(now).await + } + + /// Resolve armed wait barriers: clock barriers that came due are + /// satisfied, expired timeouts fire, and barriers whose goal is gone or + /// no longer waiting are cancelled. Non-clock barriers (events, jobs, + /// sub-agents) have no runtime probes in code-assistant — they park the + /// goal until their timeout fires or the user resumes it. + fn wait_pass(&self, now: NaiveDateTime) -> Result<()> { + for mut wait in self.waits.armed()? { + let goal = self.goals.get(&wait.goal_id)?; + let still_waiting = goal + .as_ref() + .is_some_and(|goal| goal.state == GoalState::Waiting); + if !still_waiting { + // The goal moved on (resumed, cancelled, done) — the barrier + // is stale and must not wake anything later. + if wait.cancel("goal no longer waiting", now) { + let _ = self.waits.update(&wait); + } + continue; + } + if wait.timed_out(now) { + wait.time_out(now)?; + } else if wait.due(now) { + wait.satisfy(Some("the time arrived".into()), now)?; + } else { + continue; + } + self.waits.update(&wait)?; + self.goals + .wake_waiting(&wait.goal_id, wait.note.clone(), now)?; + } + Ok(()) + } + + async fn goal_pass(&self, now: NaiveDateTime) -> Result<()> { + for snapshot in self.goals.active()? { + let Some(session_id) = owner_session_id(&snapshot.owner).map(str::to_string) else { + // Owned by another host (e.g. a channel lane) — not ours. + continue; + }; + if let Err(error) = self.drive_goal(snapshot, &session_id, now).await { + warn!("goal pass: {error:#}"); + } + } + Ok(()) + } + + async fn drive_goal(&self, snapshot: Goal, session_id: &str, now: NaiveDateTime) -> Result<()> { + // A leftover in-flight marker means an earlier process died mid-turn: + // close it as an interrupted attempt so the crash cannot refund the + // claimed budget. (Within one process, passes run sequentially and + // every dispatched turn is awaited, so a live claim never appears + // here.) + if snapshot.in_flight.is_some() { + self.goals.finish_attempt( + &snapshot, + AttemptCompletion::ControllerError( + "attempt interrupted (process ended mid-turn)".into(), + ), + now, + )?; + return Ok(()); + } + + // Deadline sweep for goals that are parked and will not take a turn. + if snapshot.state != GoalState::Running { + let mut goal = snapshot; + if goal.enforce_deadline(now) { + self.goals.update(&goal)?; + } + return Ok(()); + } + + // Cheap busy gate to avoid claim/abandon churn; `start_turn_if_idle` + // below stays the authoritative, atomic idle check. + if self.service.is_session_busy(session_id.to_string()).await? { + return Ok(()); + } + + let Some(claim) = self.goals.claim_attempt(&snapshot, now)? else { + // Stale snapshot, or the claim itself resolved the goal + // (deadline/budget) — nothing was dispatched. + return Ok(()); + }; + + let dispatch = self + .service + .start_turn_if_idle( + session_id.to_string(), + TurnRequest::text(goal_turn_text(&claim)), + ) + .await; + let handle = match dispatch { + Ok(TurnDispatch::Started(handle)) => handle, + Ok(TurnDispatch::Busy) => { + // No work was dispatched — the only path that releases a + // claim without spending budget. + self.goals.abandon_attempt(&claim)?; + return Ok(()); + } + Err(error) => { + self.goals.finish_attempt( + &claim, + AttemptCompletion::ControllerError(format!("turn dispatch failed: {error:#}")), + now, + )?; + return Ok(()); + } + }; + + let outcome = match handle.wait().await { + Ok(outcome) => outcome, + Err(error) => { + self.goals.finish_attempt( + &claim, + AttemptCompletion::ControllerError(format!("turn outcome lost: {error:#}")), + now, + )?; + return Ok(()); + } + }; + + if let TurnStatus::Failed { error } = &outcome.status { + self.goals.finish_attempt( + &claim, + AttemptCompletion::ControllerError(format!("turn failed: {error}")), + now, + )?; + return Ok(()); + } + + // The user took the wheel during the goal turn (queued a message that + // was absorbed, or cancelled the run): after accounting the attempt, + // stop driving this session's goals until they deliberately resume. + let user_took_over = outcome.user_preempted || outcome.status == TurnStatus::Cancelled; + + let evidence = goal_turn_evidence(&outcome); + let completion = match self.evaluator.evaluate(&claim, &evidence).await { + Ok(evaluation) => AttemptCompletion::Evaluated(evaluation), + Err(error) => { + AttemptCompletion::ControllerError(format!("goal evaluation failed: {error:#}")) + } + }; + + if let Some((goal, decision)) = self.goals.finish_attempt(&claim, completion, now)? { + debug!("goal {}: {:?}", goal.id, decision); + if let ControllerDecision::Wait(request) = decision { + self.waits.arm( + goal.id.clone(), + goal.owner.clone(), + request.kind, + request.timeout, + now, + )?; + } + } + + if user_took_over { + self.goals.preempt_owner(&claim.owner, now)?; + } + Ok(()) + } +} + +/// Spawn the periodic controller loop (host-driven continuation: it lives and +/// dies with the process). Modeled on +/// [`spawn_wakeup_scheduler`](crate::session::spawn_wakeup_scheduler). +pub fn spawn_goal_controller( + controller: GoalController, + interval: Duration, +) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + let mut ticker = tokio::time::interval(interval); + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + loop { + ticker.tick().await; + if let Err(error) = controller.pass().await { + debug!("goal controller pass failed: {error:#}"); + } + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::persistence::FileSessionPersistence; + use crate::session::service::{ + default_project_manager_factory, AgentRuntimeOptions, LlmClientFactory, + }; + use crate::session::{SessionConfig, SessionManager}; + use agent_orchestration::goals::{ + AttemptVerdict, Budget, CompletionContract, Evaluation, GoalStore, + }; + use agent_orchestration::waits::{WaitKind, WaitRequest, WaitState, WaitStore}; + use chrono::NaiveDate; + use std::collections::VecDeque; + use std::sync::Mutex; + use tokio::sync::Mutex as AsyncMutex; + + fn now() -> NaiveDateTime { + NaiveDate::from_ymd_opt(2026, 7, 16) + .unwrap() + .and_hms_opt(10, 0, 0) + .unwrap() + } + + /// Evaluator that replays a scripted verdict sequence — the deterministic + /// stand-in for the LLM judge. + struct ScriptedEvaluator { + script: Mutex>, + seen: Mutex>, + } + + impl ScriptedEvaluator { + fn new(script: Vec) -> Self { + Self { + script: Mutex::new(script.into()), + seen: Mutex::new(Vec::new()), + } + } + } + + #[async_trait::async_trait] + impl GoalEvaluator for ScriptedEvaluator { + async fn evaluate(&self, _goal: &Goal, turn: &GoalTurnOutcome) -> Result { + self.seen.lock().unwrap().push(turn.clone()); + self.script + .lock() + .unwrap() + .pop_front() + .ok_or_else(|| anyhow::anyhow!("scripted evaluator ran dry")) + } + } + + /// Session service whose agent runs use the injected LLM factory (same + /// wiring as the service's own turn tests). + fn test_service( + root: &std::path::Path, + factory: LlmClientFactory, + ) -> (SessionService, Arc>) { + let events = crate::session::event_stream::EventStream::new(); + let persistence = FileSessionPersistence::new_with_root_dir(root.to_path_buf()); + let manager = Arc::new(AsyncMutex::new(SessionManager::new( + persistence, + SessionConfig::default(), + "test-model".to_string(), + crate::tools::test_registry(), + events.clone(), + ))); + let runtime = Arc::new(AgentRuntimeOptions { + record_path: None, + playback_path: None, + fast_playback: false, + command_executor_factory: Arc::new(|_| { + Box::new(crate::mocks::create_command_executor_mock()) + }), + project_manager_factory: default_project_manager_factory(), + llm_client_factory: Some(factory), + }); + let (service, worker) = SessionService::new(manager.clone(), runtime, events); + tokio::spawn(worker); + (service, manager) + } + + /// Streams its scripted text through the callback like a real provider, + /// so the turn's `final_response` (the evaluator's summary input) is + /// populated. Same shape as the service's own turn-test provider. + struct StreamingScriptedProvider { + text: String, + } + + #[async_trait::async_trait] + impl llm::LLMProvider for StreamingScriptedProvider { + async fn send_message( + &mut self, + _request: llm::LLMRequest, + streaming_callback: Option<&llm::StreamingCallback>, + ) -> Result { + if let Some(callback) = streaming_callback { + callback(&llm::StreamingChunk::Text(self.text.clone()))?; + callback(&llm::StreamingChunk::StreamingComplete)?; + } + Ok(llm::LLMResponse { + content: vec![llm::ContentBlock::new_text(&self.text)], + usage: llm::Usage { + input_tokens: 10, + output_tokens: 5, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + rate_limit_info: None, + }) + } + } + + fn completing_llm(text: &'static str) -> LlmClientFactory { + Arc::new(move |_model| { + Ok(Box::new(StreamingScriptedProvider { + text: text.to_string(), + })) + }) + } + + struct Fixture { + service: SessionService, + manager: Arc>, + goals: Arc, + waits: Arc, + _tmp: tempfile::TempDir, + } + + impl Fixture { + async fn new(factory: LlmClientFactory) -> Self { + let tmp = tempfile::tempdir().unwrap(); + let (service, manager) = test_service(tmp.path(), factory); + let goals = Arc::new(GoalStore::new(tmp.path().join("goals.json"))); + let waits = Arc::new(WaitStore::new(tmp.path().join("waits.json"))); + Self { + service, + manager, + goals, + waits, + _tmp: tmp, + } + } + + fn controller(&self, evaluator: Arc) -> GoalController { + GoalController::new( + self.service.clone(), + self.goals.clone(), + self.waits.clone(), + evaluator, + ) + } + + async fn session_with_goal(&self, max_turns: u32) -> (String, Goal) { + let session_id = self.service.create_session(None, None).await.unwrap(); + let goal = self + .goals + .add_new( + session_owner(&session_id), + "ship the widget", + CompletionContract::new("widget shipped", "check the registry", "give up"), + Budget::turns(max_turns), + now(), + ) + .unwrap(); + (session_id, goal) + } + } + + #[test] + fn owner_key_roundtrips_the_session_id() { + let owner = session_owner("sess-42"); + assert_eq!(owner.as_str(), "session:sess-42"); + assert_eq!(owner_session_id(&owner), Some("sess-42")); + assert_eq!( + owner_session_id(&OwnerKey::from_parts(&["lane", "x"])), + None + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn a_pass_drives_a_running_goal_and_a_satisfied_verdict_completes_it() { + let fx = Fixture::new(completing_llm("Shipped it; registry updated.")).await; + let (_, goal) = fx.session_with_goal(3).await; + let evaluator = Arc::new(ScriptedEvaluator::new(vec![Evaluation::new( + AttemptVerdict::Satisfied, + "the registry lists the widget", + )])); + + fx.controller(evaluator.clone()) + .pass_at(now()) + .await + .unwrap(); + + let goal = fx.goals.get(&goal.id).unwrap().unwrap(); + assert_eq!(goal.state, GoalState::Done); + assert_eq!(goal.attempts.len(), 1); + assert_eq!(goal.attempts[0].verdict, AttemptVerdict::Satisfied); + assert!(goal.in_flight.is_none()); + // The evaluator judged the typed outcome, not an event-stream guess. + let seen = evaluator.seen.lock().unwrap(); + assert_eq!(seen.len(), 1); + assert_eq!(seen[0].assistant_summary, "Shipped it; registry updated."); + } + + #[tokio::test(flavor = "multi_thread")] + async fn a_busy_session_is_skipped_without_spending_budget() { + let fx = Fixture::new(completing_llm("unused")).await; + let (session_id, goal) = fx.session_with_goal(3).await; + { + let mut manager = fx.manager.lock().await; + manager + .get_session_mut(&session_id) + .unwrap() + .set_activity_state(crate::session::instance::SessionActivityState::AgentRunning); + } + let evaluator = Arc::new(ScriptedEvaluator::new(vec![])); + + fx.controller(evaluator).pass_at(now()).await.unwrap(); + + let goal = fx.goals.get(&goal.id).unwrap().unwrap(); + assert_eq!(goal.state, GoalState::Running); + assert!(goal.attempts.is_empty(), "no budget may be spent"); + assert!(goal.in_flight.is_none(), "no claim may linger"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn a_failing_turn_spends_budget_as_a_controller_error() { + let fx = Fixture::new(Arc::new(|_model| { + Ok(Box::new(crate::mocks::MockLLMProvider::new(vec![Err( + anyhow::anyhow!("model exploded"), + )]))) + })) + .await; + let (_, goal) = fx.session_with_goal(3).await; + let evaluator = Arc::new(ScriptedEvaluator::new(vec![])); + + fx.controller(evaluator).pass_at(now()).await.unwrap(); + + let goal = fx.goals.get(&goal.id).unwrap().unwrap(); + assert_eq!( + goal.state, + GoalState::Running, + "budget remains — retry allowed" + ); + assert_eq!(goal.attempts.len(), 1); + assert_eq!(goal.attempts[0].verdict, AttemptVerdict::Error); + assert!(goal.attempts[0].summary.contains("model exploded")); + assert!(goal.in_flight.is_none()); + } + + #[tokio::test(flavor = "multi_thread")] + async fn budget_exhaustion_fails_a_merely_progressing_goal() { + let fx = Fixture::new(completing_llm("made some progress")).await; + let (_, goal) = fx.session_with_goal(1).await; + let evaluator = Arc::new(ScriptedEvaluator::new(vec![Evaluation::new( + AttemptVerdict::Progressed, + "one step further", + )])); + + fx.controller(evaluator).pass_at(now()).await.unwrap(); + + let goal = fx.goals.get(&goal.id).unwrap().unwrap(); + assert_eq!(goal.state, GoalState::Failed); + assert_eq!(goal.note.as_deref(), Some("turn budget exhausted")); + } + + #[tokio::test(flavor = "multi_thread")] + async fn a_stale_in_flight_claim_is_folded_as_an_interrupted_attempt() { + let fx = Fixture::new(completing_llm("unused")).await; + let (_, goal) = fx.session_with_goal(3).await; + // Simulate a process that died mid-turn: the claim is persisted but + // no turn is running. + fx.goals.claim_attempt(&goal, now()).unwrap().unwrap(); + let evaluator = Arc::new(ScriptedEvaluator::new(vec![])); + + fx.controller(evaluator).pass_at(now()).await.unwrap(); + + let goal = fx.goals.get(&goal.id).unwrap().unwrap(); + assert!(goal.in_flight.is_none()); + assert_eq!( + goal.attempts.len(), + 1, + "the crash cannot refund the claimed turn" + ); + assert_eq!(goal.attempts[0].verdict, AttemptVerdict::Error); + assert_eq!(goal.state, GoalState::Running); + } + + #[tokio::test(flavor = "multi_thread")] + async fn a_waiting_verdict_parks_the_goal_and_a_due_clock_barrier_wakes_it() { + let fx = Fixture::new(Arc::new(|_model| { + // One scripted turn per pass that reaches the model. + Ok(Box::new(crate::mocks::MockLLMProvider::new(vec![Ok( + crate::mocks::create_test_response_text("waiting for the window"), + )]))) + })) + .await; + let (_, goal) = fx.session_with_goal(3).await; + let wake_at = now() + chrono::Duration::hours(1); + let evaluator = Arc::new(ScriptedEvaluator::new(vec![ + Evaluation::waiting( + "the deploy window opens later", + WaitRequest { + kind: WaitKind::Until { at: wake_at }, + timeout: None, + }, + ), + Evaluation::new(AttemptVerdict::Satisfied, "window used, contract verified"), + ])); + let controller = fx.controller(evaluator); + + controller.pass_at(now()).await.unwrap(); + let parked = fx.goals.get(&goal.id).unwrap().unwrap(); + assert_eq!(parked.state, GoalState::Waiting); + let armed = fx.waits.armed_for_goal(&goal.id).unwrap(); + assert_eq!(armed.len(), 1); + + // Before the barrier is due nothing moves (and no budget is spent). + controller + .pass_at(now() + chrono::Duration::minutes(30)) + .await + .unwrap(); + assert_eq!( + fx.goals.get(&goal.id).unwrap().unwrap().state, + GoalState::Waiting + ); + + // Past the barrier the wait fires and the same pass drives the goal on. + controller + .pass_at(wake_at + chrono::Duration::minutes(1)) + .await + .unwrap(); + let woken = fx.goals.get(&goal.id).unwrap().unwrap(); + assert_eq!(woken.state, GoalState::Done); + assert_eq!( + fx.waits.get(&armed[0].id).unwrap().unwrap().state, + WaitState::Satisfied + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn a_deadline_fails_a_parked_goal_without_a_turn() { + let fx = Fixture::new(completing_llm("unused")).await; + let session_id = fx.service.create_session(None, None).await.unwrap(); + let deadline = now() + chrono::Duration::hours(1); + let goal = fx + .goals + .add_new( + session_owner(&session_id), + "beat the clock", + CompletionContract::new("done", "check", "stop"), + Budget::turns(3).with_deadline(deadline), + now(), + ) + .unwrap(); + let mut paused = goal.clone(); + paused.pause(now()).unwrap(); + let paused = fx.goals.update(&paused).unwrap(); + let evaluator = Arc::new(ScriptedEvaluator::new(vec![])); + + fx.controller(evaluator) + .pass_at(deadline + chrono::Duration::minutes(1)) + .await + .unwrap(); + + let goal = fx.goals.get(&paused.id).unwrap().unwrap(); + assert_eq!(goal.state, GoalState::Failed); + assert_eq!(goal.note.as_deref(), Some("deadline passed")); + } + + #[test] + fn evidence_maps_tools_and_resources_from_the_typed_outcome() { + let outcome = crate::session::TurnOutcome { + turn_id: 1, + status: TurnStatus::Completed, + final_response: " did the thing ".into(), + tools: vec![crate::session::ToolRecord { + tool_id: "t1".into(), + name: "write_file".into(), + status: ToolStatus::Success, + message: Some("wrote it".into()), + output: Some("ok".into()), + parameters: vec![("path".into(), "src/lib.rs".into())], + }], + resources_written: vec![crate::session::ResourceRef { + project: "widget".into(), + path: PathBuf::from("src/lib.rs"), + }], + user_preempted: false, + usage: Default::default(), + }; + + let evidence = goal_turn_evidence(&outcome); + assert_eq!(evidence.assistant_summary, "did the thing"); + assert_eq!(evidence.artifacts, vec!["widget:src/lib.rs".to_string()]); + assert_eq!(evidence.verification.len(), 1); + assert!(evidence.verification[0].contains("write_file [Success]: wrote it")); + assert!(evidence.verification[0].contains("inputs: path=src/lib.rs")); + } +} diff --git a/crates/code_assistant_core/src/lib.rs b/crates/code_assistant_core/src/lib.rs index 2b0d70d8..332581f4 100644 --- a/crates/code_assistant_core/src/lib.rs +++ b/crates/code_assistant_core/src/lib.rs @@ -14,6 +14,7 @@ pub use fs_explorer; pub mod agent; pub mod config; pub mod config_dir; +pub mod goals; pub mod persistence; pub mod plugins; pub mod session; From e18763a007a640cc8d57f23cefa52fa1a6ab54cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20A=C3=9Fmus?= Date: Thu, 16 Jul 2026 05:49:06 +0200 Subject: [PATCH 2/9] feat(goals): goal tool in the default scopes + durable-goals prompt block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generic subset of pal's goal tool (create/show/list/pause/resume/ cancel — no lane, wait-adoption or child semantics): the owner is the session the tool runs in, every action is scoped to that owner, and the store's revision/claim-token semantics keep a user pause/cancel from clobbering an in-flight controller turn (pinned by a test). The system prompt surfaces the session's active goals, so a reloaded session knows what it is still committed to. --- crates/code_assistant_core/src/plugins/mod.rs | 2 +- .../src/plugins/system_prompt.rs | 123 ++- .../src/tools/impls/goal.rs | 706 ++++++++++++++++++ .../src/tools/impls/mod.rs | 2 + crates/code_assistant_core/src/tools/mod.rs | 10 +- 5 files changed, 835 insertions(+), 8 deletions(-) create mode 100644 crates/code_assistant_core/src/tools/impls/goal.rs diff --git a/crates/code_assistant_core/src/plugins/mod.rs b/crates/code_assistant_core/src/plugins/mod.rs index 04c0c0b9..233a378d 100644 --- a/crates/code_assistant_core/src/plugins/mod.rs +++ b/crates/code_assistant_core/src/plugins/mod.rs @@ -37,6 +37,6 @@ pub fn default_hooks() -> HookRegistry { dispatch: Box::new(SpawnAgentParallelPolicy), compaction: Box::new(TokenRatioCompaction::new(0.8)), recovery: Box::new(DefaultRecovery), - system_prompt: Box::new(CodeAssistantSystemPrompt), + system_prompt: Box::new(CodeAssistantSystemPrompt::new()), } } diff --git a/crates/code_assistant_core/src/plugins/system_prompt.rs b/crates/code_assistant_core/src/plugins/system_prompt.rs index 97bfc6af..884f3b72 100644 --- a/crates/code_assistant_core/src/plugins/system_prompt.rs +++ b/crates/code_assistant_core/src/plugins/system_prompt.rs @@ -1,14 +1,36 @@ //! System prompt construction: model-specific base prompt, project file -//! trees, and repository guidance (AGENTS.md / CLAUDE.md). +//! trees, durable goals, and repository guidance (AGENTS.md / CLAUDE.md). use crate::plugins::AgentAppState; use crate::tool_dialects::system_message::generate_system_message; use agent_core::hooks::{PromptCtx, SystemPromptProvider}; +use agent_orchestration::goals::GoalStore; use std::fs; -use std::path::Path; +use std::path::{Path, PathBuf}; use tracing::warn; -pub struct CodeAssistantSystemPrompt; +pub struct CodeAssistantSystemPrompt { + goals_path: PathBuf, +} + +impl Default for CodeAssistantSystemPrompt { + fn default() -> Self { + Self::new() + } +} + +impl CodeAssistantSystemPrompt { + pub fn new() -> Self { + Self::with_goals_path(crate::goals::default_goals_path()) + } + + /// Provider reading goals from a custom store path (tests, embedders). + pub fn with_goals_path(goals_path: impl Into) -> Self { + Self { + goals_path: goals_path.into(), + } + } +} impl SystemPromptProvider for CodeAssistantSystemPrompt { fn build(&self, ctx: &PromptCtx) -> String { @@ -58,6 +80,14 @@ impl SystemPromptProvider for CodeAssistantSystemPrompt { } } + // Surface the session's durable goals, so a reloaded session knows + // what it is still committed to (the goal controller keeps driving + // them while the app is open). + if let Some(section) = ctx.session_id.and_then(|id| self.render_goals_section(id)) { + system_message.push_str("\n\n"); + system_message.push_str(§ion); + } + // Append guidance files if present. Global AGENTS.md is loaded first so // project-specific guidance can refine or override it in the prompt. let guidance_files = read_guidance_files( @@ -84,6 +114,43 @@ impl SystemPromptProvider for CodeAssistantSystemPrompt { } } +impl CodeAssistantSystemPrompt { + /// The `# Durable Goals` block for this session's active goals; `None` + /// when there are none (or the store is unreadable — the prompt must not + /// fail over an optional block). + fn render_goals_section(&self, session_id: &str) -> Option { + let goals = GoalStore::new(&self.goals_path) + .active_for_owner(&crate::goals::session_owner(session_id)) + .map_err(|error| warn!("failed to read goals for the system prompt: {error:#}")) + .ok()?; + if goals.is_empty() { + return None; + } + let mut section = String::from( + "# Durable Goals\n\nThis session is committed to the following goal(s). They are \ + pursued autonomously while the app is open and survive session reloads; manage \ + them with the `goal` tool.\n", + ); + for goal in goals { + let note = goal + .note + .as_deref() + .map(|n| format!(" — {n}")) + .unwrap_or_default(); + section.push_str(&format!( + "- {} [{}] {}/{} turns: {}{}\n", + goal.id, + goal.state.label(), + goal.turns_used(), + goal.budget.max_turns, + goal.objective, + note, + )); + } + Some(section.trim_end().to_string()) + } +} + /// Attempt to read guidance from the global config directory and project root. /// /// Global `~/.config/code-assistant/AGENTS.md` is included when present. @@ -169,6 +236,56 @@ mod tests { Ok(()) } + #[test] + fn goals_section_lists_only_the_sessions_active_goals() -> Result<()> { + use agent_orchestration::goals::{Budget, CompletionContract}; + + let dir = tempdir()?; + let goals_path = dir.path().join("goals.json"); + let store = GoalStore::new(&goals_path); + let now = chrono::NaiveDate::from_ymd_opt(2026, 7, 16) + .unwrap() + .and_hms_opt(10, 0, 0) + .unwrap(); + let contract = CompletionContract::new("done", "check", "stop"); + store.add_new( + crate::goals::session_owner("sess-1"), + "ship the widget", + contract.clone(), + Budget::turns(5), + now, + )?; + store.add_new( + crate::goals::session_owner("sess-2"), + "other session's goal", + contract.clone(), + Budget::turns(5), + now, + )?; + let mut done = store.add_new( + crate::goals::session_owner("sess-1"), + "already cancelled", + contract, + Budget::turns(5), + now, + )?; + done.fail("cancelled", now)?; + store.update(&done)?; + + let provider = CodeAssistantSystemPrompt::with_goals_path(&goals_path); + let section = provider + .render_goals_section("sess-1") + .expect("active goal should render a section"); + assert!(section.starts_with("# Durable Goals")); + assert!(section.contains("ship the widget")); + assert!(section.contains("0/5 turns")); + assert!(!section.contains("other session's goal")); + assert!(!section.contains("already cancelled")); + + assert!(provider.render_goals_section("sess-3").is_none()); + Ok(()) + } + #[test] fn prefers_agents_md_over_claude_md() -> Result<()> { let dir = tempdir()?; diff --git a/crates/code_assistant_core/src/tools/impls/goal.rs b/crates/code_assistant_core/src/tools/impls/goal.rs new file mode 100644 index 00000000..3410e33e --- /dev/null +++ b/crates/code_assistant_core/src/tools/impls/goal.rs @@ -0,0 +1,706 @@ +//! The `goal` tool: the agent's access to durable goals (see +//! [`crate::goals`]). A goal is an outcome that stays on record across +//! session reloads; while the app is open, the goal controller drives it +//! turn by turn against its completion contract. Owner is the session the +//! tool runs in — every action is scoped to that session's goals. +//! +//! Stateless over `goals.json`: each invocation loads, mutates, saves. The +//! store's optimistic revisions keep concurrent controller turns and user +//! lifecycle edits from overwriting each other. + +use crate::goals::{default_goals_path, session_owner}; +use crate::tools::core::{ + capabilities, Render, ResourcesTracker, Tool, ToolContext, ToolResult, ToolSpec, +}; +use agent_orchestration::goals::{Budget, CompletionContract, Goal, GoalState, GoalStore, Subgoal}; +use anyhow::{anyhow, Result}; +use chrono::NaiveDateTime; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum GoalAction { + /// Commit this session to a new durable goal. + Create, + /// One goal in full detail: contract, budget, attempt ledger. + Show, + /// List this session's goals with their state and progress. + List, + /// Pause an active goal (the controller stops driving it). + Pause, + /// Resume a paused or blocked goal (the controller drives it again). + Resume, + /// Give up on a goal; it becomes terminal but stays on the ledger. + Cancel, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GoalInput { + pub action: GoalAction, + /// For `create`: the user's objective, in their terms. + #[serde(default)] + pub objective: Option, + /// For `create`: what "done" means — the outcome to reach. + #[serde(default)] + pub outcome: Option, + /// For `create`: how success is verified (a command, a check, an + /// artifact's existence). The goal reaches "done" only when this passes. + #[serde(default)] + pub verification: Option, + /// For `create`: when to give up rather than keep trying. + #[serde(default)] + pub stop_condition: Option, + /// For `create`, optional: things that must hold throughout. + #[serde(default)] + pub constraints: Option>, + /// For `create`, optional: hard limits the agent may never cross. + #[serde(default)] + pub boundaries: Option>, + /// For `create`, optional: a starting checklist of steps. + #[serde(default)] + pub subgoals: Option>, + /// For `create`: the maximum number of autonomous turns to spend. + #[serde(default)] + pub max_turns: Option, + /// For `create`, optional: a wall-clock deadline `YYYY-MM-DD HH:MM`; past + /// it the goal fails. + #[serde(default)] + pub deadline: Option, + /// For `show`/`pause`/`resume`/`cancel`: the goal id. + #[serde(default)] + pub id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GoalOutput { + pub success: bool, + pub message: String, +} + +impl Render for GoalOutput { + fn status(&self) -> String { + self.message.lines().next().unwrap_or_default().to_string() + } + + fn render(&self, _tracker: &mut ResourcesTracker) -> String { + self.message.clone() + } +} + +impl ToolResult for GoalOutput { + fn is_success(&self) -> bool { + self.success + } +} + +/// Stateless over `goals.json`; the store's per-path lock and revisions keep +/// invocations and controller turns consistent. +pub struct GoalTool { + goals_path: PathBuf, + /// Fixed "now" for tests; `None` uses the local clock. + now_override: Option, +} + +impl Default for GoalTool { + fn default() -> Self { + Self::new(default_goals_path()) + } +} + +impl GoalTool { + pub fn new(goals_path: impl Into) -> Self { + Self { + goals_path: goals_path.into(), + now_override: None, + } + } + + #[cfg(test)] + fn with_now(mut self, now: NaiveDateTime) -> Self { + self.now_override = Some(now); + self + } + + fn now(&self) -> NaiveDateTime { + self.now_override + .unwrap_or_else(|| chrono::Local::now().naive_local()) + } + + fn store(&self) -> GoalStore { + GoalStore::new(&self.goals_path) + } + + fn create(&self, session_id: &str, input: &GoalInput) -> Result { + let objective = require(&input.objective, "objective")?; + let outcome = require(&input.outcome, "outcome")?; + let verification = require(&input.verification, "verification")?; + let stop_condition = require(&input.stop_condition, "stop_condition")?; + let max_turns = input + .max_turns + .ok_or_else(|| anyhow!("'create' needs 'max_turns' (the autonomous turn budget)"))?; + if max_turns == 0 { + return Err(anyhow!("'max_turns' must be at least 1")); + } + + let mut contract = CompletionContract::new(outcome, verification, stop_condition); + contract.constraints = clean(input.constraints.as_deref()); + contract.boundaries = clean(input.boundaries.as_deref()); + + let mut budget = Budget::turns(max_turns); + if let Some(deadline) = input + .deadline + .as_deref() + .map(str::trim) + .filter(|d| !d.is_empty()) + { + let dl = NaiveDateTime::parse_from_str(deadline, "%Y-%m-%d %H:%M") + .map_err(|_| anyhow!("'deadline' must be local time formatted YYYY-MM-DD HH:MM"))?; + if dl <= self.now() { + return Err(anyhow!("'deadline' is in the past ({dl})")); + } + budget = budget.with_deadline(dl); + } + + let store = self.store(); + let mut goal = store.add_new( + session_owner(session_id), + objective, + contract, + budget, + self.now(), + )?; + + let subgoals = clean(input.subgoals.as_deref()); + if !subgoals.is_empty() { + goal.subgoals = subgoals.into_iter().map(Subgoal::new).collect(); + goal = store.update(&goal)?; + } + + let deadline_note = goal + .budget + .deadline + .map(|dl| format!(", deadline {}", dl.format("%Y-%m-%d %H:%M"))) + .unwrap_or_default(); + Ok(GoalOutput { + success: true, + message: format!( + "Committed goal {} (budget {} turns{}). While the app is open, the goal \ + controller pursues it autonomously against its completion contract until it is \ + satisfied, blocked, or the budget runs out; it stays on record across session \ + reloads (but is not pursued while the app is closed).", + goal.id, goal.budget.max_turns, deadline_note, + ), + }) + } + + fn show(&self, session_id: &str, input: &GoalInput) -> Result { + let goal = self.load_owned(session_id, input)?; + let mut lines = vec![ + format!( + "Goal {} [{}]: {}", + goal.id, + goal.state.label(), + goal.objective + ), + format!("- Done when: {}", goal.contract.outcome), + format!("- Verify by: {}", goal.contract.verification), + format!("- Stop if: {}", goal.contract.stop_condition), + ]; + for c in &goal.contract.constraints { + lines.push(format!("- Constraint: {c}")); + } + for b in &goal.contract.boundaries { + lines.push(format!("- Boundary: {b}")); + } + let deadline = goal + .budget + .deadline + .map(|dl| format!(", deadline {}", dl.format("%Y-%m-%d %H:%M"))) + .unwrap_or_default(); + lines.push(format!( + "- Budget: {}/{} turns used{}", + goal.turns_used(), + goal.budget.max_turns, + deadline, + )); + if !goal.subgoals.is_empty() { + lines.push("Checklist:".to_string()); + for s in &goal.subgoals { + lines.push(format!( + "- [{}] {}", + if s.done { "x" } else { " " }, + s.description + )); + } + } + if let Some(note) = &goal.note { + lines.push(format!("Note: {note}")); + } + if !goal.attempts.is_empty() { + lines.push("Recent attempts:".to_string()); + for attempt in goal.attempts.iter().rev().take(5).rev() { + lines.push(format!( + "- {} [{:?}] {}", + attempt.at.format("%Y-%m-%d %H:%M"), + attempt.verdict, + attempt.summary, + )); + } + } + Ok(GoalOutput { + success: true, + message: lines.join("\n"), + }) + } + + fn list(&self, session_id: &str) -> Result { + let owner = session_owner(session_id); + let mut goals: Vec = self + .store() + .list()? + .into_iter() + .filter(|g| g.owner == owner) + .collect(); + if goals.is_empty() { + return Ok(GoalOutput { + success: true, + message: "No goals on record for this session.".to_string(), + }); + } + // Active goals first, then terminal ones; stable within each group. + goals.sort_by_key(|g| g.state.is_terminal()); + let mut lines = vec![format!("{} goal(s):", goals.len())]; + for g in goals { + let note = g + .note + .as_deref() + .map(|n| format!(" — {n}")) + .unwrap_or_default(); + lines.push(format!( + "- {} [{}] {}/{} turns: {}{}", + g.id, + g.state.label(), + g.turns_used(), + g.budget.max_turns, + g.objective, + note, + )); + } + Ok(GoalOutput { + success: true, + message: lines.join("\n"), + }) + } + + fn pause(&self, session_id: &str, input: &GoalInput) -> Result { + let mut goal = self.load_owned(session_id, input)?; + if goal.state == GoalState::Paused { + return Ok(GoalOutput { + success: true, + message: format!("Goal {} is already paused.", goal.id), + }); + } + goal.pause(self.now()) + .map_err(|_| finished_error(&goal.id, goal.state))?; + self.store().update(&goal)?; + Ok(GoalOutput { + success: true, + message: format!( + "Paused goal {}. Resume it to have the controller drive it again.", + goal.id + ), + }) + } + + fn resume(&self, session_id: &str, input: &GoalInput) -> Result { + let mut goal = self.load_owned(session_id, input)?; + if goal.state == GoalState::Running { + return Ok(GoalOutput { + success: true, + message: format!("Goal {} is already running.", goal.id), + }); + } + goal.resume(self.now()) + .map_err(|_| finished_error(&goal.id, goal.state))?; + self.store().update(&goal)?; + Ok(GoalOutput { + success: true, + message: format!( + "Resumed goal {}; the controller will drive it on the next pass.", + goal.id + ), + }) + } + + fn cancel(&self, session_id: &str, input: &GoalInput) -> Result { + let mut goal = self.load_owned(session_id, input)?; + goal.fail("cancelled by the user", self.now()) + .map_err(|_| finished_error(&goal.id, goal.state))?; + self.store().update(&goal)?; + Ok(GoalOutput { + success: true, + message: format!( + "Cancelled goal {}; it stays on the ledger as failed.", + goal.id + ), + }) + } + + /// Load a goal by id and verify it belongs to this session — one + /// session's agent must not steer another session's goals. + fn load_owned(&self, session_id: &str, input: &GoalInput) -> Result { + let id = require(&input.id, "id")?; + let goal = self + .store() + .get(&id)? + .ok_or_else(|| anyhow!("no goal with id {id}"))?; + if goal.owner != session_owner(session_id) { + return Err(anyhow!("goal {id} belongs to another session")); + } + Ok(goal) + } +} + +fn require(field: &Option, name: &str) -> Result { + field + .as_deref() + .map(str::trim) + .filter(|v| !v.is_empty()) + .map(str::to_string) + .ok_or_else(|| anyhow!("'{name}' is required and must be non-empty")) +} + +fn clean(items: Option<&[String]>) -> Vec { + items + .unwrap_or_default() + .iter() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect() +} + +fn finished_error(id: &str, state: GoalState) -> anyhow::Error { + anyhow!("goal {id} is already {} and cannot change", state.label()) +} + +#[async_trait::async_trait] +impl Tool for GoalTool { + type Input = GoalInput; + type Output = GoalOutput; + + fn spec(&self) -> ToolSpec { + ToolSpec { + name: "goal".into(), + description: "Commit this session to a durable goal: an outcome pursued \ + autonomously, turn by turn, while the app is open — until its completion \ + contract is satisfied, a real obstacle blocks it, or its budget runs out. The \ + goal survives a session reload, but is not pursued while the app is closed. Use \ + it for multi-turn work with a definite result, not for a one-off reply. Every \ + goal needs a completion contract: the outcome, how success is verified (the goal \ + is 'done' only when this passes), and when to give up. Give it a turn budget \ + (and optionally a deadline). Manage goals with show/list/pause/resume/cancel." + .into(), + parameters_schema: serde_json::json!({ + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["create", "show", "list", "pause", "resume", "cancel"], + "description": "What to do." + }, + "objective": { + "type": "string", + "description": "For create: the user's objective, in their terms." + }, + "outcome": { + "type": "string", + "description": "For create: what 'done' means — the concrete outcome to reach." + }, + "verification": { + "type": "string", + "description": "For create: how success is verified (a command, a check, an artifact's existence). The goal is done only when this passes." + }, + "stop_condition": { + "type": "string", + "description": "For create: when to give up rather than keep trying." + }, + "constraints": { + "type": "array", + "items": { "type": "string" }, + "description": "For create, optional: things that must hold throughout." + }, + "boundaries": { + "type": "array", + "items": { "type": "string" }, + "description": "For create, optional: hard limits never to cross (e.g. 'never push, only commit locally')." + }, + "subgoals": { + "type": "array", + "items": { "type": "string" }, + "description": "For create, optional: a starting checklist of steps." + }, + "max_turns": { + "type": "integer", + "minimum": 1, + "description": "For create: the maximum number of autonomous turns to spend." + }, + "deadline": { + "type": "string", + "description": "For create, optional: a wall-clock deadline, YYYY-MM-DD HH:MM; past it the goal fails." + }, + "id": { + "type": "string", + "description": "For show/pause/resume/cancel: the goal id." + } + }, + "required": ["action"] + }), + annotations: None, + capabilities: ToolSpec::capabilities(&[ + capabilities::SCOPE_AGENT, + capabilities::SCOPE_AGENT_DIFF, + ]), + multiline_params: &["objective", "outcome", "verification", "stop_condition"], + hidden: false, + title_template: Some("Managing goal ({action})"), + } + } + + async fn execute<'a>( + &self, + context: &mut ToolContext<'a>, + input: &mut Self::Input, + ) -> Result { + let session_id = context + .session_id + .clone() + .ok_or_else(|| anyhow!("no session id in this context; goals are unavailable here"))?; + match input.action { + GoalAction::Create => self.create(&session_id, input), + GoalAction::Show => self.show(&session_id, input), + GoalAction::List => self.list(&session_id), + GoalAction::Pause => self.pause(&session_id, input), + GoalAction::Resume => self.resume(&session_id, input), + GoalAction::Cancel => self.cancel(&session_id, input), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::NaiveDate; + + fn now() -> NaiveDateTime { + NaiveDate::from_ymd_opt(2026, 7, 16) + .unwrap() + .and_hms_opt(10, 0, 0) + .unwrap() + } + + fn tool(dir: &std::path::Path) -> GoalTool { + GoalTool::new(dir.join("goals.json")).with_now(now()) + } + + async fn run(tool: &GoalTool, session_id: &str, input: GoalInput) -> Result { + let executor = crate::mocks::create_command_executor_mock(); + let mut context = ToolContext { + command_executor: &executor, + tool_id: None, + session_id: Some(session_id.to_string()), + permission_handler: None, + extensions: None, + }; + let mut input = input; + tool.execute(&mut context, &mut input).await + } + + fn create_input(objective: &str) -> GoalInput { + GoalInput { + action: GoalAction::Create, + objective: Some(objective.to_string()), + outcome: Some("it is done".to_string()), + verification: Some("the check passes".to_string()), + stop_condition: Some("three failed strategies".to_string()), + constraints: None, + boundaries: None, + subgoals: Some(vec!["step one".to_string()]), + max_turns: Some(5), + deadline: None, + id: None, + } + } + + fn action_on(action: GoalAction, id: &str) -> GoalInput { + GoalInput { + action, + objective: None, + outcome: None, + verification: None, + stop_condition: None, + constraints: None, + boundaries: None, + subgoals: None, + max_turns: None, + deadline: None, + id: Some(id.to_string()), + } + } + + #[tokio::test] + async fn create_persists_a_session_owned_running_goal() { + let dir = tempfile::tempdir().unwrap(); + let tool = tool(dir.path()); + + let output = run(&tool, "sess-1", create_input("ship it")).await.unwrap(); + assert!(output.success); + + let goals = tool.store().list().unwrap(); + assert_eq!(goals.len(), 1); + assert_eq!(goals[0].owner, session_owner("sess-1")); + assert_eq!(goals[0].state, GoalState::Running); + assert_eq!(goals[0].subgoals.len(), 1); + assert!(output.message.contains(&goals[0].id)); + } + + #[tokio::test] + async fn create_validates_the_contract_fields() { + let dir = tempfile::tempdir().unwrap(); + let tool = tool(dir.path()); + let mut input = create_input("ship it"); + input.verification = None; + + let error = run(&tool, "sess-1", input).await.unwrap_err(); + assert!(error.to_string().contains("verification")); + assert!(tool.store().list().unwrap().is_empty()); + } + + #[tokio::test] + async fn list_and_show_are_scoped_to_the_calling_session() { + let dir = tempfile::tempdir().unwrap(); + let tool = tool(dir.path()); + run(&tool, "sess-1", create_input("mine")).await.unwrap(); + run(&tool, "sess-2", create_input("theirs")).await.unwrap(); + + let listed = run( + &tool, + "sess-1", + GoalInput { + action: GoalAction::List, + ..create_input("") + }, + ) + .await + .unwrap(); + assert!(listed.message.contains("mine")); + assert!(!listed.message.contains("theirs")); + + // Another session's goal is not visible, steerable, or cancellable. + let foreign = tool.store().list().unwrap(); + let foreign_id = &foreign + .iter() + .find(|g| g.owner == session_owner("sess-2")) + .unwrap() + .id; + let error = run(&tool, "sess-1", action_on(GoalAction::Show, foreign_id)) + .await + .unwrap_err(); + assert!(error.to_string().contains("another session")); + let error = run(&tool, "sess-1", action_on(GoalAction::Cancel, foreign_id)) + .await + .unwrap_err(); + assert!(error.to_string().contains("another session")); + } + + #[tokio::test] + async fn show_renders_the_contract_and_ledger() { + let dir = tempfile::tempdir().unwrap(); + let tool = tool(dir.path()); + run(&tool, "sess-1", create_input("ship it")).await.unwrap(); + let id = tool.store().list().unwrap()[0].id.clone(); + + let shown = run(&tool, "sess-1", action_on(GoalAction::Show, &id)) + .await + .unwrap(); + assert!(shown.message.contains("Done when: it is done")); + assert!(shown.message.contains("Verify by: the check passes")); + assert!(shown.message.contains("0/5 turns used")); + assert!(shown.message.contains("[ ] step one")); + } + + #[tokio::test] + async fn pause_resume_cancel_walk_the_lifecycle() { + let dir = tempfile::tempdir().unwrap(); + let tool = tool(dir.path()); + run(&tool, "sess-1", create_input("ship it")).await.unwrap(); + let id = tool.store().list().unwrap()[0].id.clone(); + + run(&tool, "sess-1", action_on(GoalAction::Pause, &id)) + .await + .unwrap(); + assert_eq!( + tool.store().get(&id).unwrap().unwrap().state, + GoalState::Paused + ); + + run(&tool, "sess-1", action_on(GoalAction::Resume, &id)) + .await + .unwrap(); + assert_eq!( + tool.store().get(&id).unwrap().unwrap().state, + GoalState::Running + ); + + run(&tool, "sess-1", action_on(GoalAction::Cancel, &id)) + .await + .unwrap(); + let goal = tool.store().get(&id).unwrap().unwrap(); + assert_eq!(goal.state, GoalState::Failed); + assert_eq!(goal.note.as_deref(), Some("cancelled by the user")); + + // Terminal goals cannot change any more. + let error = run(&tool, "sess-1", action_on(GoalAction::Resume, &id)) + .await + .unwrap_err(); + assert!(error.to_string().contains("already failed")); + } + + #[tokio::test] + async fn pause_during_an_in_flight_turn_does_not_clobber_the_claim() { + let dir = tempfile::tempdir().unwrap(); + let tool = tool(dir.path()); + run(&tool, "sess-1", create_input("ship it")).await.unwrap(); + let store = tool.store(); + let snapshot = store.list().unwrap().remove(0); + let claim = store.claim_attempt(&snapshot, now()).unwrap().unwrap(); + + // The user pauses while the controller's turn is in flight. + run(&tool, "sess-1", action_on(GoalAction::Pause, &claim.id)) + .await + .unwrap(); + + // The pause advanced the revision but preserved the claim token, so + // the completed turn still merges into the ledger (as Preempted). + let paused = store.get(&claim.id).unwrap().unwrap(); + assert_eq!(paused.state, GoalState::Paused); + assert!(paused.in_flight.is_some(), "pause must not erase the claim"); + use agent_orchestration::goals::{ + AttemptCompletion, AttemptVerdict, ControllerDecision, Evaluation, + }; + let (merged, decision) = store + .finish_attempt( + &claim, + AttemptCompletion::Evaluated(Evaluation::new( + AttemptVerdict::Progressed, + "made progress", + )), + now(), + ) + .unwrap() + .unwrap(); + assert_eq!(decision, ControllerDecision::Preempted); + assert_eq!(merged.state, GoalState::Paused, "user stop wins"); + assert_eq!(merged.attempts.len(), 1, "the turn still spent its budget"); + } +} diff --git a/crates/code_assistant_core/src/tools/impls/mod.rs b/crates/code_assistant_core/src/tools/impls/mod.rs index cdf7a802..a790a27c 100644 --- a/crates/code_assistant_core/src/tools/impls/mod.rs +++ b/crates/code_assistant_core/src/tools/impls/mod.rs @@ -4,6 +4,7 @@ pub mod delete_files; pub mod edit; pub mod execute_command; pub mod glob_files; +pub mod goal; pub mod list_files; pub mod list_projects; pub mod list_skills; @@ -32,6 +33,7 @@ pub use delete_files::DeleteFilesTool; pub use edit::EditTool; pub use execute_command::ExecuteCommandTool; pub use glob_files::GlobFilesTool; +pub use goal::GoalTool; pub use list_files::ListFilesTool; pub use list_projects::ListProjectsTool; pub use list_skills::ListSkillsTool; diff --git a/crates/code_assistant_core/src/tools/mod.rs b/crates/code_assistant_core/src/tools/mod.rs index a74708eb..feef6223 100644 --- a/crates/code_assistant_core/src/tools/mod.rs +++ b/crates/code_assistant_core/src/tools/mod.rs @@ -79,10 +79,11 @@ pub fn register_default_tools(registry: &mut ToolRegistry, config: &ToolsConfig) use impls::{ BrowserActTool, BrowserCloseTool, BrowserLoginTool, BrowserNavigateTool, BrowserProfilesTool, BrowserReadTool, CancelWakeupTool, DeleteFilesTool, EditTool, - ExecuteCommandTool, GlobFilesTool, ListFilesTool, ListProjectsTool, ListSkillsTool, - NameSessionTool, PerplexityAskTool, ReadFilesTool, ReadSkillTool, ReplaceInFileTool, - ScheduleWakeupTool, SearchFilesTool, SpawnAgentTool, UpdatePlanTool, ViewDocumentsTool, - ViewImagesTool, WebFetchTool, WebSearchTool, WriteFileTool, WriteStdinTool, + ExecuteCommandTool, GlobFilesTool, GoalTool, ListFilesTool, ListProjectsTool, + ListSkillsTool, NameSessionTool, PerplexityAskTool, ReadFilesTool, ReadSkillTool, + ReplaceInFileTool, ScheduleWakeupTool, SearchFilesTool, SpawnAgentTool, UpdatePlanTool, + ViewDocumentsTool, ViewImagesTool, WebFetchTool, WebSearchTool, WriteFileTool, + WriteStdinTool, }; registry.register(Box::new(BrowserNavigateTool)); @@ -95,6 +96,7 @@ pub fn register_default_tools(registry: &mut ToolRegistry, config: &ToolsConfig) registry.register(Box::new(EditTool)); registry.register(Box::new(ExecuteCommandTool)); registry.register(Box::new(GlobFilesTool)); + registry.register(Box::new(GoalTool::default())); registry.register(Box::new(ListFilesTool)); registry.register(Box::new(ListProjectsTool)); From 86549900426e8eb7b1f2e76591cb6d5e1eab2dd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20A=C3=9Fmus?= Date: Thu, 16 Jul 2026 05:52:19 +0200 Subject: [PATCH 3/9] feat(ui_terminal): /goal slash command + goal controller loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /goal expands Claude-Code-style into a prompt that has the agent derive the completion contract through the goal tool; bare /goal lists the session's goals. Alongside the wakeup scheduler the app spawns the goal controller (30s passes, LlmGoalEvaluator on the configured model) — host-driven continuation that lives and dies with the app. --- crates/ui_terminal/src/app.rs | 55 ++++++++++++++++++++++++++++++ crates/ui_terminal/src/commands.rs | 30 ++++++++++++++++ crates/ui_terminal/src/input.rs | 4 +++ 3 files changed, 89 insertions(+) diff --git a/crates/ui_terminal/src/app.rs b/crates/ui_terminal/src/app.rs index 7f530096..d0c8a508 100644 --- a/crates/ui_terminal/src/app.rs +++ b/crates/ui_terminal/src/app.rs @@ -558,6 +558,31 @@ async fn handle_command_result( } } } + CommandResult::Goal { objective } => { + let session_id = app_state.lock().await.current_session_id.clone(); + let Some(session_id) = session_id else { + app_state + .lock() + .await + .set_info_message(Some("No active session to manage goals".to_string())); + return; + }; + // Claude-Code-style expansion: the command becomes a prompt and + // the agent does the actual work through the `goal` tool. + let message = match objective { + Some(objective) => format!( + "Use the `goal` tool to commit this session to a durable goal for:\n\ + {objective}\n\n\ + Derive a sensible completion contract (outcome, verification, stop \ + condition) and turn budget from that objective; ask first only if \ + something essential is missing." + ), + None => "Use the `goal` tool to list this session's goals and summarize \ + their state and progress." + .to_string(), + }; + actions.send_user_message(session_id, message, Vec::new()); + } CommandResult::ShowPermissionTier => { let mut state = app_state.lock().await; let message = match state.current_permission_tier { @@ -968,6 +993,15 @@ async fn event_loop( ) .await; } + KeyEventResult::Goal { objective } => { + handle_command_result( + crate::commands::CommandResult::Goal { objective }, + &app_state, + &renderer, + &actions, + ) + .await; + } KeyEventResult::OpenSessionPicker => { // Inline `/sessions` (popup not active): reuse // the command-result handler to open the picker. @@ -1212,6 +1246,27 @@ impl TerminalTuiApp { )); } + // Goal controller: while the app is open, drives the sessions' + // durable goals (goal tool) one bounded turn at a time. The verdicts + // come from an LLM evaluator on the configured model; without a + // usable provider the goals simply stay parked. + match llm::factory::create_llm_client_from_model(&config.model, None, false, None).await { + Ok(provider) => { + code_assistant_core::goals::spawn_goal_controller( + code_assistant_core::goals::GoalController::with_stores( + service.clone(), + code_assistant_core::goals::default_goals_path(), + code_assistant_core::goals::default_waits_path(), + Arc::new(code_assistant_core::goals::LlmGoalEvaluator::new(provider)), + ), + std::time::Duration::from_secs(30), + ); + } + Err(e) => { + tracing::warn!("goal controller disabled (no evaluator provider): {e:#}"); + } + } + // Bridge: subscribe to the core→UI broadcast stream and feed the // terminal's rendering pipeline. Single-session app, so everything // scoped to the current session (or app-scoped) passes. diff --git a/crates/ui_terminal/src/commands.rs b/crates/ui_terminal/src/commands.rs index 94186a21..013ae945 100644 --- a/crates/ui_terminal/src/commands.rs +++ b/crates/ui_terminal/src/commands.rs @@ -71,6 +71,11 @@ pub fn all_commands() -> &'static [SlashCommand] { aliases: &[], description: "Deny the pending tool permission request", }, + SlashCommand { + name: "goal", + aliases: &[], + description: "Commit to a durable goal: /goal (bare /goal lists them)", + }, SlashCommand { name: "skill", aliases: &[], @@ -118,6 +123,10 @@ pub enum CommandResult { /// `:config:` / `:system:`); `None` means resolve it from the cached /// catalog by name. InvokeSkill { scope: Option, name: String }, + /// Ask the agent to manage a durable goal: `Some(objective)` commits to a + /// new goal, `None` lists the session's goals. Expands to a prompt so the + /// agent derives the completion contract through the `goal` tool. + Goal { objective: Option }, /// Show the current permission tier. ShowPermissionTier, /// Switch the permission tier. @@ -176,6 +185,12 @@ impl CommandProcessor { request_id: None, decision: tools_core::PermissionDecision::Denied, }, + "goal" => { + let objective = parts[1..].join(" "); + CommandResult::Goal { + objective: (!objective.is_empty()).then_some(objective), + } + } "skill" => { if parts.len() > 1 { CommandResult::InvokeSkill { @@ -322,6 +337,21 @@ mod tests { assert!(cmd.aliases.contains(&"resume")); } + #[test] + fn goal_parses_the_objective_or_falls_back_to_listing() { + let processor = test_processor(); + match processor.process_command("/goal ship the widget by friday") { + CommandResult::Goal { + objective: Some(objective), + } => assert_eq!(objective, "ship the widget by friday"), + other => panic!("expected a goal objective, got {other:?}"), + } + assert!(matches!( + processor.process_command("/goal"), + CommandResult::Goal { objective: None } + )); + } + #[test] fn sessions_and_resume_open_the_session_picker() { let processor = test_processor(); diff --git a/crates/ui_terminal/src/input.rs b/crates/ui_terminal/src/input.rs index 3053f025..67163834 100644 --- a/crates/ui_terminal/src/input.rs +++ b/crates/ui_terminal/src/input.rs @@ -48,6 +48,9 @@ pub enum KeyEventResult { /// Activate a skill. `scope` is the scope token, or `None` to resolve it /// from the cached catalog by name. InvokeSkill { scope: Option, name: String }, + /// Manage a durable goal (`/goal`): commit to `Some(objective)` or list + /// the session's goals. + Goal { objective: Option }, /// Show the current permission tier. ShowPermissionTier, /// Switch the permission tier. @@ -214,6 +217,7 @@ impl InputManager { CommandResult::InvokeSkill { scope, name } => { KeyEventResult::InvokeSkill { scope, name } } + CommandResult::Goal { objective } => KeyEventResult::Goal { objective }, CommandResult::ShowPermissionTier => KeyEventResult::ShowPermissionTier, CommandResult::SetPermissionTier(tier) => { KeyEventResult::SetPermissionTier(tier) From b2fb92d2bad5bcb07e873a806d4f1c1089be1e68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20A=C3=9Fmus?= Date: Thu, 16 Jul 2026 05:55:37 +0200 Subject: [PATCH 4/9] docs(roadmap) + types: record the landed goal consumer; runs skeleton MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ROADMAP: the 'Now: converge goals' section gets a dated status note and the goal-migration acceptance criteria are marked met (both consumers). agent_orchestration grows the vocabulary-only RunSpec/RunBudget/ RunRecord/RunAttempt/RunStatus skeleton from 'Next: unify runs' — the run/attempt split pinned as types + serde roundtrips, no behavior. --- ROADMAP.md | 37 +++- crates/agent_orchestration/src/lib.rs | 1 + crates/agent_orchestration/src/runs.rs | 228 +++++++++++++++++++++++++ 3 files changed, 258 insertions(+), 8 deletions(-) create mode 100644 crates/agent_orchestration/src/runs.rs diff --git a/ROADMAP.md b/ROADMAP.md index 6cbc8a34..771a1e9d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -172,6 +172,17 @@ clean. ## Now: converge goals and exact turn ownership +> **Status 2026-07-16.** Migration steps 1 and 2 are done: the exact turn +> handle (`start_turn_if_idle` → `TurnHandle` → typed `TurnOutcome`) landed in +> `SessionService`, and the goal/wait domain below lives in +> `agent_orchestration` (PAL consumes it through re-export shims). +> code-assistant is now the second consumer: a `goal` tool in the default +> scopes (owner = session), a `# Durable Goals` prompt block, and a +> host-driven `GoalController` (`code_assistant_core::goals`) that drives +> `Running` goals through `start_turn_if_idle` while the app is open — +> deliberately no unattended auto-resume after process end. Remaining from +> this section: step 3 (run/delegation convergence, see Next). + The highest-value shared migration is the generic part of PAL's durable goal feature. Its state machine already separates deterministic controller policy from the model-backed evaluator and records bounded attempts instead of model @@ -263,14 +274,24 @@ reasoning. ### Goal migration acceptance criteria -- The deterministic goal/controller tests run without an LLM or frontend. -- code-assistant can create and continue a goal in an ordinary session. -- A goal may survive session reload without promising unattended auto-resume. -- PAL uses the shared model/controller while retaining its stronger restart and - channel semantics. -- Concurrent pause/cancel and turn completion cannot overwrite each other. -- Every claimed attempt consumes budget or closes with an explicit abandonment - reason; crashes cannot silently refund work. +All met as of 2026-07-16: + +- ✅ The deterministic goal/controller tests run without an LLM or frontend + (`agent_orchestration`'s suite plus `code_assistant_core::goals` controller + tests against scripted providers/evaluators). +- ✅ code-assistant can create and continue a goal in an ordinary session + (`goal` tool + `GoalController`; `/goal` in the terminal UI). +- ✅ A goal may survive session reload without promising unattended + auto-resume (store-persisted; surfaced via the durable-goals prompt block; + the controller loop lives and dies with the process). +- ✅ PAL uses the shared model/controller while retaining its stronger restart + and channel semantics (re-export shims since 2026-07-16). +- ✅ Concurrent pause/cancel and turn completion cannot overwrite each other + (revision/claim-token merge in the shared store, pinned by tests in both + consumers). +- ✅ Every claimed attempt consumes budget or closes with an explicit + abandonment reason; crashes cannot silently refund work (Busy-abandonment is + the sole refund path; stale in-flight claims fold as interrupted attempts). ## Next: unify runs and delegation diff --git a/crates/agent_orchestration/src/lib.rs b/crates/agent_orchestration/src/lib.rs index d9f79903..dfbb6d6a 100644 --- a/crates/agent_orchestration/src/lib.rs +++ b/crates/agent_orchestration/src/lib.rs @@ -18,6 +18,7 @@ pub mod goal_eval; pub mod goals; +pub mod runs; pub mod waits; use serde::{Deserialize, Serialize}; diff --git a/crates/agent_orchestration/src/runs.rs b/crates/agent_orchestration/src/runs.rs new file mode 100644 index 00000000..1e75034a --- /dev/null +++ b/crates/agent_orchestration/src/runs.rs @@ -0,0 +1,228 @@ +//! Run/delegation convergence — the type skeleton for the ROADMAP's +//! "Next: unify runs and delegation". +//! +//! This module is deliberately vocabulary only: no store, no runner trait +//! implementation, no policy. It pins the run/attempt split — a logical run +//! ([`RunRecord`]) is distinct from each claim-and-execution ([`RunAttempt`]), +//! so a retry never rewrites history — and the description shape +//! ([`RunSpec`]) that inline fork/join, background session runs, durable +//! supervised runs, and future work-graph workers are meant to share. +//! Behavior lands with the first converged consumer; fields here may still be +//! sharpened then (they are serialized nowhere yet). +//! +//! Budgets are owner policy, not a platform mandate: every envelope field is +//! optional so a host may deliberately launch unbounded runs (PAL's +//! supervised children run to completion, limited only at launch time). + +use crate::OwnerKey; +use chrono::NaiveDateTime; +use serde::{Deserialize, Serialize}; + +/// What to run, for whom, and inside which envelope. The owner-facing +/// description a policy (inline, background, durable, work-graph) executes. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RunSpec { + /// Who the run belongs to (host-defined identity, e.g. a session or lane). + pub owner: OwnerKey, + /// The role the run plays for its owner (e.g. `sub_agent`, `goal_turn`). + pub role: String, + /// The instructions the run executes. + pub instructions: String, + /// Model override; `None` inherits the host default. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + /// Tool/capability profile name, resolved by the host. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool_profile: Option, + /// Permission tier/policy name, resolved by the host. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub permissions: Option, + /// Project or working directory the run is scoped to. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workdir: Option, + /// Where the run executes (local process, sandbox, remote target). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub execution_target: Option, + /// Optional envelope; all-`None` means run to completion (owner policy). + #[serde(default)] + pub budget: RunBudget, + /// What the caller expects back (shapes the final report). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expected_output: Option, +} + +/// Optional launch-time envelope for a run. Distinct from the goal `Budget`: +/// a goal bounds *attempts across turns*, a run envelope bounds one +/// delegated execution. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct RunBudget { + /// Wall-clock deadline after which the run should be stopped. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub deadline: Option, + /// Maximum delegation depth below this run. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_depth: Option, + /// Maximum concurrently running delegations of the same owner. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_concurrency: Option, +} + +/// Lifecycle of a logical run. Terminal states are dead ends; `Interrupted` +/// marks a run whose process died mid-execution and is what a supervisor's +/// retry policy acts on (spawning a new [`RunAttempt`], never rewriting one). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RunStatus { + Pending, + Running, + Done, + Failed, + Cancelled, + Interrupted, +} + +impl RunStatus { + pub fn is_terminal(&self) -> bool { + matches!( + self, + RunStatus::Done | RunStatus::Failed | RunStatus::Cancelled + ) + } +} + +/// The durable identity and ledger of one logical run. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RunRecord { + pub id: String, + /// Optimistic-concurrency revision, same semantics as `Goal::revision`. + pub revision: u64, + pub spec: RunSpec, + /// The run this one was delegated from, when nested. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_run_id: Option, + /// The goal this run works toward, when goal-driven. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub goal_id: Option, + /// The work-graph item this run claims, when graph-driven. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub work_item_id: Option, + pub status: RunStatus, + /// Attempt history; the latest entry is the authoritative execution. + #[serde(default)] + pub attempts: Vec, + /// Bounded final result (the summary handed back to the caller). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub result: Option, + pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, +} + +/// One claim and execution of a run. A retry appends a new attempt; it never +/// rewrites an earlier one — the same crash-cannot-refund-work invariant the +/// goal ledger enforces. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RunAttempt { + /// 1-based attempt number within the run. + pub number: u32, + pub started_at: NaiveDateTime, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub finished_at: Option, + /// Terminal status of this attempt; `None` while executing. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status: Option, + /// Bounded factual account of what the attempt did. + #[serde(default)] + pub summary: String, + /// Artifacts produced (paths, references). + #[serde(default)] + pub artifacts: Vec, + /// Structured evidence lines (tool status, checks, resource writes). + #[serde(default)] + pub evidence: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::NaiveDate; + + fn now() -> NaiveDateTime { + NaiveDate::from_ymd_opt(2026, 7, 16) + .unwrap() + .and_hms_opt(12, 0, 0) + .unwrap() + } + + #[test] + fn a_minimal_spec_serializes_without_optional_noise() { + let spec = RunSpec { + owner: OwnerKey::from_parts(&["session", "s1"]), + role: "sub_agent".into(), + instructions: "do the thing".into(), + model: None, + tool_profile: None, + permissions: None, + workdir: None, + execution_target: None, + budget: RunBudget::default(), + expected_output: None, + }; + let json = serde_json::to_value(&spec).unwrap(); + assert_eq!( + json, + serde_json::json!({ + "owner": "session:s1", + "role": "sub_agent", + "instructions": "do the thing", + "budget": {}, + }) + ); + let roundtrip: RunSpec = serde_json::from_value(json).unwrap(); + assert_eq!(roundtrip, spec); + } + + #[test] + fn a_record_roundtrips_with_its_attempt_ledger() { + let record = RunRecord { + id: "run-1".into(), + revision: 3, + spec: RunSpec { + owner: OwnerKey::from_parts(&["session", "s1"]), + role: "goal_turn".into(), + instructions: "pursue the goal".into(), + model: Some("gpt-test".into()), + tool_profile: None, + permissions: None, + workdir: None, + execution_target: None, + budget: RunBudget { + deadline: Some(now()), + max_depth: Some(1), + max_concurrency: None, + }, + expected_output: None, + }, + parent_run_id: None, + goal_id: Some("goal-1".into()), + work_item_id: None, + status: RunStatus::Interrupted, + attempts: vec![RunAttempt { + number: 1, + started_at: now(), + finished_at: None, + status: None, + summary: "process died mid-execution".into(), + artifacts: vec![], + evidence: vec![], + }], + result: None, + created_at: now(), + updated_at: now(), + }; + let json = serde_json::to_string(&record).unwrap(); + let roundtrip: RunRecord = serde_json::from_str(&json).unwrap(); + assert_eq!(roundtrip, record); + assert!(!RunStatus::Interrupted.is_terminal(), "retryable by policy"); + assert!(RunStatus::Done.is_terminal()); + } +} From 9d8a365d3d8c74cc831a557fed281057b80e98e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20A=C3=9Fmus?= Date: Thu, 16 Jul 2026 07:32:48 +0200 Subject: [PATCH 5/9] feat(gpui): spawn the goal controller in the GPUI backend runtime Same host-driven continuation as the terminal frontend: alongside the wakeup scheduler, the backend runtime drives the sessions' durable goals (30s passes, LlmGoalEvaluator on the configured model) while the app is open. --- crates/code_assistant/src/app/gpui.rs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/crates/code_assistant/src/app/gpui.rs b/crates/code_assistant/src/app/gpui.rs index a03de80d..0fadc880 100644 --- a/crates/code_assistant/src/app/gpui.rs +++ b/crates/code_assistant/src/app/gpui.rs @@ -97,6 +97,28 @@ pub fn run(config: AgentRunConfig) -> Result<()> { .await .set_wakeup_handle(wakeup_handle); + // Goal controller: while the app is open, drives the sessions' + // durable goals (goal tool) one bounded turn at a time. The + // verdicts come from an LLM evaluator on the configured model; + // without a usable provider the goals simply stay parked. + match llm::factory::create_llm_client_from_model(&config.model, None, false, None).await + { + Ok(provider) => { + code_assistant_core::goals::spawn_goal_controller( + code_assistant_core::goals::GoalController::with_stores( + service.clone(), + code_assistant_core::goals::default_goals_path(), + code_assistant_core::goals::default_waits_path(), + Arc::new(code_assistant_core::goals::LlmGoalEvaluator::new(provider)), + ), + std::time::Duration::from_secs(30), + ); + } + Err(e) => { + warn!("goal controller disabled (no evaluator provider): {e:#}"); + } + } + let worker = tokio::spawn(service_worker); startup(&service, &gui_for_thread, task).await; From 487e7d9b8ff4f1ef1b2916b8e119254367074399 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20A=C3=9Fmus?= Date: Thu, 16 Jul 2026 23:12:25 +0200 Subject: [PATCH 6/9] =?UTF-8?q?feat(goals):=20only=20the=20user=20sets=20g?= =?UTF-8?q?oals=20=E2=80=94=20/goal=20command=20replaces=20the=20goal=20to?= =?UTF-8?q?ol?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The model-facing goal tool is gone: it competed with update_plan for the agent's attention, and no reference implementation lets the model commit itself to a goal (hermes' /goal is gateway-side, codex prompt-gates create_goal to explicit user requests, Claude Code has no tool at all). /goal now writes the goal directly through the new goal_commands layer: the user's condition, verbatim, is the whole completion contract (default budget 10 turns); bare /goal lists, and show/pause/resume/cancel [id] manage the lifecycle (id optional while a single goal is open). The terminal UI passes the raw argument text through and shows the outcome as an info message — no agent round-trip. --- crates/agent_orchestration/src/goals.rs | 2 +- crates/code_assistant/src/app/gpui.rs | 2 +- .../code_assistant_core/src/goal_commands.rs | 477 ++++++++++++ crates/code_assistant_core/src/lib.rs | 1 + .../src/tools/impls/goal.rs | 706 ------------------ .../src/tools/impls/mod.rs | 2 - crates/code_assistant_core/src/tools/mod.rs | 10 +- crates/ui_terminal/src/app.rs | 33 +- crates/ui_terminal/src/commands.rs | 36 +- crates/ui_terminal/src/input.rs | 7 +- 10 files changed, 517 insertions(+), 759 deletions(-) create mode 100644 crates/code_assistant_core/src/goal_commands.rs delete mode 100644 crates/code_assistant_core/src/tools/impls/goal.rs diff --git a/crates/agent_orchestration/src/goals.rs b/crates/agent_orchestration/src/goals.rs index b96973ec..18b052ee 100644 --- a/crates/agent_orchestration/src/goals.rs +++ b/crates/agent_orchestration/src/goals.rs @@ -907,7 +907,7 @@ impl GoalStore { /// `Running` goals are affected: a `Waiting`/`Blocked` goal is already /// stopped, and a `Paused` one stays paused. Returns the ids paused (empty /// when there was nothing to preempt). A paused goal is resumed - /// deliberately, through the `goal` tool. + /// deliberately, through the host's goal commands. pub fn preempt_owner( &self, owner: &OwnerKey, diff --git a/crates/code_assistant/src/app/gpui.rs b/crates/code_assistant/src/app/gpui.rs index 0fadc880..9f278c86 100644 --- a/crates/code_assistant/src/app/gpui.rs +++ b/crates/code_assistant/src/app/gpui.rs @@ -98,7 +98,7 @@ pub fn run(config: AgentRunConfig) -> Result<()> { .set_wakeup_handle(wakeup_handle); // Goal controller: while the app is open, drives the sessions' - // durable goals (goal tool) one bounded turn at a time. The + // user-set durable goals (/goal) one bounded turn at a time. The // verdicts come from an LLM evaluator on the configured model; // without a usable provider the goals simply stay parked. match llm::factory::create_llm_client_from_model(&config.model, None, false, None).await diff --git a/crates/code_assistant_core/src/goal_commands.rs b/crates/code_assistant_core/src/goal_commands.rs new file mode 100644 index 00000000..5529d317 --- /dev/null +++ b/crates/code_assistant_core/src/goal_commands.rs @@ -0,0 +1,477 @@ +//! The `/goal` command — the only way a goal is created or steered. +//! +//! Goals are deliberately **user-set**: there is no model-facing goal tool, +//! so the agent cannot commit the session to a goal (or steer one) on its +//! own. The user states the condition in their own words; the goal +//! controller (see [`crate::goals`]) drives the session against it and the +//! LLM judge decides when the condition holds. This mirrors hermes' `/goal` +//! and Claude Code's stop-hook goals, and keeps `update_plan` (the agent's +//! own checklist) free of competition from a second self-management tool. +//! +//! Frontends pass the raw text after `/goal` to [`GoalCommand::parse`] and +//! display whatever [`run_goal_command`] returns — parsing and store access +//! live here so every frontend gets the same behavior. + +use crate::goals::session_owner; +use agent_orchestration::goals::{Budget, CompletionContract, Goal, GoalState, GoalStore}; +use anyhow::{anyhow, Result}; +use chrono::NaiveDateTime; +use std::path::Path; + +/// Turn budget for user-committed goals. An honest bound: the controller +/// stops burning turns on a goal that is not converging, instead of looping +/// until the user notices. +pub const USER_GOAL_MAX_TURNS: u32 = 10; + +/// A parsed `/goal` invocation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GoalCommand { + /// Bare `/goal` (or `/goal list`): the session's goals and their state. + List, + /// `/goal show [id]`: one goal in full detail. + Show { id: Option }, + /// `/goal pause [id]`: the controller stops driving the goal. + Pause { id: Option }, + /// `/goal resume [id]`: the controller drives the goal again. + Resume { id: Option }, + /// `/goal cancel [id]`: give up; the goal stays on the ledger as failed. + Cancel { id: Option }, + /// `/goal `: commit the session to a new goal whose contract + /// is the user's condition, verbatim. + Commit { condition: String }, +} + +impl GoalCommand { + /// Parse the raw text after `/goal`. A leading lifecycle keyword with at + /// most one trailing token is a lifecycle command; everything else is the + /// condition of a new goal (so prose that merely starts with "cancel …" + /// still commits a goal). + pub fn parse(input: &str) -> GoalCommand { + let input = input.trim(); + let tokens: Vec<&str> = input.split_whitespace().collect(); + let id = (tokens.len() == 2).then(|| tokens[1].to_string()); + match (tokens.first().map(|t| t.to_lowercase()), tokens.len()) { + (None, _) => GoalCommand::List, + (Some(word), 1) => match word.as_str() { + "list" | "status" => GoalCommand::List, + "show" => GoalCommand::Show { id: None }, + "pause" => GoalCommand::Pause { id: None }, + "resume" => GoalCommand::Resume { id: None }, + "cancel" => GoalCommand::Cancel { id: None }, + _ => GoalCommand::Commit { + condition: input.to_string(), + }, + }, + (Some(word), 2) => match word.as_str() { + "show" => GoalCommand::Show { id }, + "pause" => GoalCommand::Pause { id }, + "resume" => GoalCommand::Resume { id }, + "cancel" => GoalCommand::Cancel { id }, + _ => GoalCommand::Commit { + condition: input.to_string(), + }, + }, + _ => GoalCommand::Commit { + condition: input.to_string(), + }, + } + } +} + +/// [`run_goal_command`] at the local wall clock. +pub fn run_goal_command_now( + goals_path: &Path, + session_id: &str, + command: &GoalCommand, +) -> Result { + run_goal_command( + goals_path, + session_id, + command, + chrono::Local::now().naive_local(), + ) +} + +/// Execute a `/goal` command for the given session against the store at +/// `goals_path`. Returns the text the frontend should show; errors are +/// user-facing too (unknown id, ambiguous target, …). +pub fn run_goal_command( + goals_path: &Path, + session_id: &str, + command: &GoalCommand, + now: NaiveDateTime, +) -> Result { + let store = GoalStore::new(goals_path); + match command { + GoalCommand::List => list(&store, session_id), + GoalCommand::Show { id } => show(&store, session_id, id.as_deref()), + GoalCommand::Pause { id } => pause(&store, session_id, id.as_deref(), now), + GoalCommand::Resume { id } => resume(&store, session_id, id.as_deref(), now), + GoalCommand::Cancel { id } => cancel(&store, session_id, id.as_deref(), now), + GoalCommand::Commit { condition } => commit(&store, session_id, condition, now), + } +} + +fn commit( + store: &GoalStore, + session_id: &str, + condition: &str, + now: NaiveDateTime, +) -> Result { + let condition = condition.trim(); + if condition.is_empty() { + return Err(anyhow!("the goal needs a condition: /goal ")); + } + // The user's condition is the whole contract: it is both the objective + // and the outcome, verified against the session's own turn evidence. + let contract = CompletionContract::new( + condition, + "evidence from the session's turns (commands run and their results, artifacts \ + produced) shows the condition holds", + "the user pauses or cancels the goal", + ); + let goal = store.add_new( + session_owner(session_id), + condition, + contract, + Budget::turns(USER_GOAL_MAX_TURNS), + now, + )?; + Ok(format!( + "Goal {} set: {}\nThe controller pursues it while the app is open (up to {} turns); \ + /goal shows progress, /goal cancel gives it up.", + goal.id, condition, goal.budget.max_turns, + )) +} + +fn list(store: &GoalStore, session_id: &str) -> Result { + let mut goals = session_goals(store, session_id)?; + if goals.is_empty() { + return Ok("No goals on record for this session. Set one with /goal .".into()); + } + // Active goals first, then terminal ones; stable within each group. + goals.sort_by_key(|g| g.state.is_terminal()); + let mut lines = vec![format!("{} goal(s):", goals.len())]; + for g in goals { + let note = g + .note + .as_deref() + .map(|n| format!(" — {n}")) + .unwrap_or_default(); + lines.push(format!( + "- {} [{}] {}/{} turns: {}{}", + g.id, + g.state.label(), + g.turns_used(), + g.budget.max_turns, + g.objective, + note, + )); + } + Ok(lines.join("\n")) +} + +fn show(store: &GoalStore, session_id: &str, id: Option<&str>) -> Result { + let goal = target_goal(store, session_id, id)?; + let mut lines = vec![ + format!( + "Goal {} [{}]: {}", + goal.id, + goal.state.label(), + goal.objective + ), + format!("- Done when: {}", goal.contract.outcome), + format!("- Verify by: {}", goal.contract.verification), + format!("- Stop if: {}", goal.contract.stop_condition), + format!( + "- Budget: {}/{} turns used", + goal.turns_used(), + goal.budget.max_turns, + ), + ]; + if let Some(note) = &goal.note { + lines.push(format!("Note: {note}")); + } + if !goal.attempts.is_empty() { + lines.push("Recent attempts:".to_string()); + for attempt in goal.attempts.iter().rev().take(5).rev() { + lines.push(format!( + "- {} [{:?}] {}", + attempt.at.format("%Y-%m-%d %H:%M"), + attempt.verdict, + attempt.summary, + )); + } + } + Ok(lines.join("\n")) +} + +fn pause( + store: &GoalStore, + session_id: &str, + id: Option<&str>, + now: NaiveDateTime, +) -> Result { + let mut goal = target_goal(store, session_id, id)?; + if goal.state == GoalState::Paused { + return Ok(format!("Goal {} is already paused.", goal.id)); + } + goal.pause(now) + .map_err(|_| finished_error(&goal.id, goal.state))?; + store.update(&goal)?; + Ok(format!( + "Paused goal {}. /goal resume drives it again.", + goal.id + )) +} + +fn resume( + store: &GoalStore, + session_id: &str, + id: Option<&str>, + now: NaiveDateTime, +) -> Result { + let mut goal = target_goal(store, session_id, id)?; + if goal.state == GoalState::Running { + return Ok(format!("Goal {} is already running.", goal.id)); + } + goal.resume(now) + .map_err(|_| finished_error(&goal.id, goal.state))?; + store.update(&goal)?; + Ok(format!( + "Resumed goal {}; the controller drives it on the next pass.", + goal.id + )) +} + +fn cancel( + store: &GoalStore, + session_id: &str, + id: Option<&str>, + now: NaiveDateTime, +) -> Result { + let mut goal = target_goal(store, session_id, id)?; + goal.fail("cancelled by the user", now) + .map_err(|_| finished_error(&goal.id, goal.state))?; + store.update(&goal)?; + Ok(format!( + "Cancelled goal {}; it stays on the ledger as failed.", + goal.id + )) +} + +fn session_goals(store: &GoalStore, session_id: &str) -> Result> { + let owner = session_owner(session_id); + Ok(store + .list()? + .into_iter() + .filter(|g| g.owner == owner) + .collect()) +} + +/// Resolve the goal a lifecycle command targets: an explicit id (which must +/// belong to this session), or — when omitted — the session's single +/// non-terminal goal. +fn target_goal(store: &GoalStore, session_id: &str, id: Option<&str>) -> Result { + if let Some(id) = id { + let goal = store + .get(id)? + .ok_or_else(|| anyhow!("no goal with id {id}"))?; + if goal.owner != session_owner(session_id) { + return Err(anyhow!("goal {id} belongs to another session")); + } + return Ok(goal); + } + let mut open: Vec = session_goals(store, session_id)? + .into_iter() + .filter(|g| !g.state.is_terminal()) + .collect(); + match open.len() { + 0 => Err(anyhow!("this session has no open goal")), + 1 => Ok(open.remove(0)), + _ => Err(anyhow!( + "this session has {} open goals — name one: {}", + open.len(), + open.iter() + .map(|g| g.id.as_str()) + .collect::>() + .join(", "), + )), + } +} + +fn finished_error(id: &str, state: GoalState) -> anyhow::Error { + anyhow!("goal {id} is already {} and cannot change", state.label()) +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::NaiveDate; + + fn now() -> NaiveDateTime { + NaiveDate::from_ymd_opt(2026, 7, 16) + .unwrap() + .and_hms_opt(10, 0, 0) + .unwrap() + } + + fn run(dir: &std::path::Path, session_id: &str, input: &str) -> Result { + run_goal_command( + &dir.join("goals.json"), + session_id, + &GoalCommand::parse(input), + now(), + ) + } + + fn store(dir: &std::path::Path) -> GoalStore { + GoalStore::new(dir.join("goals.json")) + } + + #[test] + fn parse_separates_lifecycle_keywords_from_conditions() { + assert_eq!(GoalCommand::parse(""), GoalCommand::List); + assert_eq!(GoalCommand::parse(" list "), GoalCommand::List); + assert_eq!(GoalCommand::parse("status"), GoalCommand::List); + assert_eq!(GoalCommand::parse("show"), GoalCommand::Show { id: None }); + assert_eq!( + GoalCommand::parse("pause g-1"), + GoalCommand::Pause { + id: Some("g-1".into()) + } + ); + assert_eq!( + GoalCommand::parse("all tests pass"), + GoalCommand::Commit { + condition: "all tests pass".into() + } + ); + // A three-word phrase starting with a keyword is a condition, not a + // lifecycle command with a two-token id. + assert_eq!( + GoalCommand::parse("cancel the subscription in the billing portal"), + GoalCommand::Commit { + condition: "cancel the subscription in the billing portal".into() + } + ); + } + + #[test] + fn commit_persists_a_session_owned_running_goal_with_the_condition_as_contract() { + let dir = tempfile::tempdir().unwrap(); + let message = run(dir.path(), "sess-1", "the CI badge is green").unwrap(); + + let goals = store(dir.path()).list().unwrap(); + assert_eq!(goals.len(), 1); + assert_eq!(goals[0].owner, session_owner("sess-1")); + assert_eq!(goals[0].state, GoalState::Running); + assert_eq!(goals[0].objective, "the CI badge is green"); + assert_eq!(goals[0].contract.outcome, "the CI badge is green"); + assert_eq!(goals[0].budget.max_turns, USER_GOAL_MAX_TURNS); + assert!(message.contains(&goals[0].id)); + } + + #[test] + fn list_and_lifecycle_are_scoped_to_the_session() { + let dir = tempfile::tempdir().unwrap(); + run(dir.path(), "sess-1", "mine holds").unwrap(); + run(dir.path(), "sess-2", "theirs holds").unwrap(); + + let listed = run(dir.path(), "sess-1", "").unwrap(); + assert!(listed.contains("mine holds")); + assert!(!listed.contains("theirs holds")); + + let foreign_id = store(dir.path()) + .list() + .unwrap() + .into_iter() + .find(|g| g.owner == session_owner("sess-2")) + .unwrap() + .id; + let error = run(dir.path(), "sess-1", &format!("cancel {foreign_id}")).unwrap_err(); + assert!(error.to_string().contains("another session")); + } + + #[test] + fn lifecycle_without_an_id_targets_the_single_open_goal() { + let dir = tempfile::tempdir().unwrap(); + run(dir.path(), "sess-1", "the widget ships").unwrap(); + + run(dir.path(), "sess-1", "pause").unwrap(); + assert_eq!( + store(dir.path()).list().unwrap()[0].state, + GoalState::Paused + ); + + run(dir.path(), "sess-1", "resume").unwrap(); + assert_eq!( + store(dir.path()).list().unwrap()[0].state, + GoalState::Running + ); + + run(dir.path(), "sess-1", "cancel").unwrap(); + let goal = &store(dir.path()).list().unwrap()[0]; + assert_eq!(goal.state, GoalState::Failed); + assert_eq!(goal.note.as_deref(), Some("cancelled by the user")); + + // No open goal left: lifecycle commands now need nothing to act on. + let error = run(dir.path(), "sess-1", "pause").unwrap_err(); + assert!(error.to_string().contains("no open goal")); + } + + #[test] + fn lifecycle_without_an_id_refuses_an_ambiguous_target() { + let dir = tempfile::tempdir().unwrap(); + run(dir.path(), "sess-1", "goal one holds").unwrap(); + run(dir.path(), "sess-1", "goal two holds").unwrap(); + + let error = run(dir.path(), "sess-1", "cancel").unwrap_err(); + assert!(error.to_string().contains("2 open goals")); + } + + #[test] + fn show_renders_the_contract_and_ledger() { + let dir = tempfile::tempdir().unwrap(); + run(dir.path(), "sess-1", "the check passes").unwrap(); + + let shown = run(dir.path(), "sess-1", "show").unwrap(); + assert!(shown.contains("Done when: the check passes")); + assert!(shown.contains(&format!("0/{USER_GOAL_MAX_TURNS} turns used"))); + } + + #[test] + fn pause_during_an_in_flight_turn_does_not_clobber_the_claim() { + let dir = tempfile::tempdir().unwrap(); + run(dir.path(), "sess-1", "ship it").unwrap(); + let store = store(dir.path()); + let snapshot = store.list().unwrap().remove(0); + let claim = store.claim_attempt(&snapshot, now()).unwrap().unwrap(); + + // The user pauses while the controller's turn is in flight. + run(dir.path(), "sess-1", &format!("pause {}", claim.id)).unwrap(); + + // The pause advanced the revision but preserved the claim token, so + // the completed turn still merges into the ledger (as Preempted). + let paused = store.get(&claim.id).unwrap().unwrap(); + assert_eq!(paused.state, GoalState::Paused); + assert!(paused.in_flight.is_some(), "pause must not erase the claim"); + use agent_orchestration::goals::{ + AttemptCompletion, AttemptVerdict, ControllerDecision, Evaluation, + }; + let (merged, decision) = store + .finish_attempt( + &claim, + AttemptCompletion::Evaluated(Evaluation::new( + AttemptVerdict::Progressed, + "made progress", + )), + now(), + ) + .unwrap() + .unwrap(); + assert_eq!(decision, ControllerDecision::Preempted); + assert_eq!(merged.state, GoalState::Paused, "user stop wins"); + assert_eq!(merged.attempts.len(), 1, "the turn still spent its budget"); + } +} diff --git a/crates/code_assistant_core/src/lib.rs b/crates/code_assistant_core/src/lib.rs index 332581f4..a7acabe1 100644 --- a/crates/code_assistant_core/src/lib.rs +++ b/crates/code_assistant_core/src/lib.rs @@ -14,6 +14,7 @@ pub use fs_explorer; pub mod agent; pub mod config; pub mod config_dir; +pub mod goal_commands; pub mod goals; pub mod persistence; pub mod plugins; diff --git a/crates/code_assistant_core/src/tools/impls/goal.rs b/crates/code_assistant_core/src/tools/impls/goal.rs deleted file mode 100644 index 3410e33e..00000000 --- a/crates/code_assistant_core/src/tools/impls/goal.rs +++ /dev/null @@ -1,706 +0,0 @@ -//! The `goal` tool: the agent's access to durable goals (see -//! [`crate::goals`]). A goal is an outcome that stays on record across -//! session reloads; while the app is open, the goal controller drives it -//! turn by turn against its completion contract. Owner is the session the -//! tool runs in — every action is scoped to that session's goals. -//! -//! Stateless over `goals.json`: each invocation loads, mutates, saves. The -//! store's optimistic revisions keep concurrent controller turns and user -//! lifecycle edits from overwriting each other. - -use crate::goals::{default_goals_path, session_owner}; -use crate::tools::core::{ - capabilities, Render, ResourcesTracker, Tool, ToolContext, ToolResult, ToolSpec, -}; -use agent_orchestration::goals::{Budget, CompletionContract, Goal, GoalState, GoalStore, Subgoal}; -use anyhow::{anyhow, Result}; -use chrono::NaiveDateTime; -use serde::{Deserialize, Serialize}; -use std::path::PathBuf; - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum GoalAction { - /// Commit this session to a new durable goal. - Create, - /// One goal in full detail: contract, budget, attempt ledger. - Show, - /// List this session's goals with their state and progress. - List, - /// Pause an active goal (the controller stops driving it). - Pause, - /// Resume a paused or blocked goal (the controller drives it again). - Resume, - /// Give up on a goal; it becomes terminal but stays on the ledger. - Cancel, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct GoalInput { - pub action: GoalAction, - /// For `create`: the user's objective, in their terms. - #[serde(default)] - pub objective: Option, - /// For `create`: what "done" means — the outcome to reach. - #[serde(default)] - pub outcome: Option, - /// For `create`: how success is verified (a command, a check, an - /// artifact's existence). The goal reaches "done" only when this passes. - #[serde(default)] - pub verification: Option, - /// For `create`: when to give up rather than keep trying. - #[serde(default)] - pub stop_condition: Option, - /// For `create`, optional: things that must hold throughout. - #[serde(default)] - pub constraints: Option>, - /// For `create`, optional: hard limits the agent may never cross. - #[serde(default)] - pub boundaries: Option>, - /// For `create`, optional: a starting checklist of steps. - #[serde(default)] - pub subgoals: Option>, - /// For `create`: the maximum number of autonomous turns to spend. - #[serde(default)] - pub max_turns: Option, - /// For `create`, optional: a wall-clock deadline `YYYY-MM-DD HH:MM`; past - /// it the goal fails. - #[serde(default)] - pub deadline: Option, - /// For `show`/`pause`/`resume`/`cancel`: the goal id. - #[serde(default)] - pub id: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct GoalOutput { - pub success: bool, - pub message: String, -} - -impl Render for GoalOutput { - fn status(&self) -> String { - self.message.lines().next().unwrap_or_default().to_string() - } - - fn render(&self, _tracker: &mut ResourcesTracker) -> String { - self.message.clone() - } -} - -impl ToolResult for GoalOutput { - fn is_success(&self) -> bool { - self.success - } -} - -/// Stateless over `goals.json`; the store's per-path lock and revisions keep -/// invocations and controller turns consistent. -pub struct GoalTool { - goals_path: PathBuf, - /// Fixed "now" for tests; `None` uses the local clock. - now_override: Option, -} - -impl Default for GoalTool { - fn default() -> Self { - Self::new(default_goals_path()) - } -} - -impl GoalTool { - pub fn new(goals_path: impl Into) -> Self { - Self { - goals_path: goals_path.into(), - now_override: None, - } - } - - #[cfg(test)] - fn with_now(mut self, now: NaiveDateTime) -> Self { - self.now_override = Some(now); - self - } - - fn now(&self) -> NaiveDateTime { - self.now_override - .unwrap_or_else(|| chrono::Local::now().naive_local()) - } - - fn store(&self) -> GoalStore { - GoalStore::new(&self.goals_path) - } - - fn create(&self, session_id: &str, input: &GoalInput) -> Result { - let objective = require(&input.objective, "objective")?; - let outcome = require(&input.outcome, "outcome")?; - let verification = require(&input.verification, "verification")?; - let stop_condition = require(&input.stop_condition, "stop_condition")?; - let max_turns = input - .max_turns - .ok_or_else(|| anyhow!("'create' needs 'max_turns' (the autonomous turn budget)"))?; - if max_turns == 0 { - return Err(anyhow!("'max_turns' must be at least 1")); - } - - let mut contract = CompletionContract::new(outcome, verification, stop_condition); - contract.constraints = clean(input.constraints.as_deref()); - contract.boundaries = clean(input.boundaries.as_deref()); - - let mut budget = Budget::turns(max_turns); - if let Some(deadline) = input - .deadline - .as_deref() - .map(str::trim) - .filter(|d| !d.is_empty()) - { - let dl = NaiveDateTime::parse_from_str(deadline, "%Y-%m-%d %H:%M") - .map_err(|_| anyhow!("'deadline' must be local time formatted YYYY-MM-DD HH:MM"))?; - if dl <= self.now() { - return Err(anyhow!("'deadline' is in the past ({dl})")); - } - budget = budget.with_deadline(dl); - } - - let store = self.store(); - let mut goal = store.add_new( - session_owner(session_id), - objective, - contract, - budget, - self.now(), - )?; - - let subgoals = clean(input.subgoals.as_deref()); - if !subgoals.is_empty() { - goal.subgoals = subgoals.into_iter().map(Subgoal::new).collect(); - goal = store.update(&goal)?; - } - - let deadline_note = goal - .budget - .deadline - .map(|dl| format!(", deadline {}", dl.format("%Y-%m-%d %H:%M"))) - .unwrap_or_default(); - Ok(GoalOutput { - success: true, - message: format!( - "Committed goal {} (budget {} turns{}). While the app is open, the goal \ - controller pursues it autonomously against its completion contract until it is \ - satisfied, blocked, or the budget runs out; it stays on record across session \ - reloads (but is not pursued while the app is closed).", - goal.id, goal.budget.max_turns, deadline_note, - ), - }) - } - - fn show(&self, session_id: &str, input: &GoalInput) -> Result { - let goal = self.load_owned(session_id, input)?; - let mut lines = vec![ - format!( - "Goal {} [{}]: {}", - goal.id, - goal.state.label(), - goal.objective - ), - format!("- Done when: {}", goal.contract.outcome), - format!("- Verify by: {}", goal.contract.verification), - format!("- Stop if: {}", goal.contract.stop_condition), - ]; - for c in &goal.contract.constraints { - lines.push(format!("- Constraint: {c}")); - } - for b in &goal.contract.boundaries { - lines.push(format!("- Boundary: {b}")); - } - let deadline = goal - .budget - .deadline - .map(|dl| format!(", deadline {}", dl.format("%Y-%m-%d %H:%M"))) - .unwrap_or_default(); - lines.push(format!( - "- Budget: {}/{} turns used{}", - goal.turns_used(), - goal.budget.max_turns, - deadline, - )); - if !goal.subgoals.is_empty() { - lines.push("Checklist:".to_string()); - for s in &goal.subgoals { - lines.push(format!( - "- [{}] {}", - if s.done { "x" } else { " " }, - s.description - )); - } - } - if let Some(note) = &goal.note { - lines.push(format!("Note: {note}")); - } - if !goal.attempts.is_empty() { - lines.push("Recent attempts:".to_string()); - for attempt in goal.attempts.iter().rev().take(5).rev() { - lines.push(format!( - "- {} [{:?}] {}", - attempt.at.format("%Y-%m-%d %H:%M"), - attempt.verdict, - attempt.summary, - )); - } - } - Ok(GoalOutput { - success: true, - message: lines.join("\n"), - }) - } - - fn list(&self, session_id: &str) -> Result { - let owner = session_owner(session_id); - let mut goals: Vec = self - .store() - .list()? - .into_iter() - .filter(|g| g.owner == owner) - .collect(); - if goals.is_empty() { - return Ok(GoalOutput { - success: true, - message: "No goals on record for this session.".to_string(), - }); - } - // Active goals first, then terminal ones; stable within each group. - goals.sort_by_key(|g| g.state.is_terminal()); - let mut lines = vec![format!("{} goal(s):", goals.len())]; - for g in goals { - let note = g - .note - .as_deref() - .map(|n| format!(" — {n}")) - .unwrap_or_default(); - lines.push(format!( - "- {} [{}] {}/{} turns: {}{}", - g.id, - g.state.label(), - g.turns_used(), - g.budget.max_turns, - g.objective, - note, - )); - } - Ok(GoalOutput { - success: true, - message: lines.join("\n"), - }) - } - - fn pause(&self, session_id: &str, input: &GoalInput) -> Result { - let mut goal = self.load_owned(session_id, input)?; - if goal.state == GoalState::Paused { - return Ok(GoalOutput { - success: true, - message: format!("Goal {} is already paused.", goal.id), - }); - } - goal.pause(self.now()) - .map_err(|_| finished_error(&goal.id, goal.state))?; - self.store().update(&goal)?; - Ok(GoalOutput { - success: true, - message: format!( - "Paused goal {}. Resume it to have the controller drive it again.", - goal.id - ), - }) - } - - fn resume(&self, session_id: &str, input: &GoalInput) -> Result { - let mut goal = self.load_owned(session_id, input)?; - if goal.state == GoalState::Running { - return Ok(GoalOutput { - success: true, - message: format!("Goal {} is already running.", goal.id), - }); - } - goal.resume(self.now()) - .map_err(|_| finished_error(&goal.id, goal.state))?; - self.store().update(&goal)?; - Ok(GoalOutput { - success: true, - message: format!( - "Resumed goal {}; the controller will drive it on the next pass.", - goal.id - ), - }) - } - - fn cancel(&self, session_id: &str, input: &GoalInput) -> Result { - let mut goal = self.load_owned(session_id, input)?; - goal.fail("cancelled by the user", self.now()) - .map_err(|_| finished_error(&goal.id, goal.state))?; - self.store().update(&goal)?; - Ok(GoalOutput { - success: true, - message: format!( - "Cancelled goal {}; it stays on the ledger as failed.", - goal.id - ), - }) - } - - /// Load a goal by id and verify it belongs to this session — one - /// session's agent must not steer another session's goals. - fn load_owned(&self, session_id: &str, input: &GoalInput) -> Result { - let id = require(&input.id, "id")?; - let goal = self - .store() - .get(&id)? - .ok_or_else(|| anyhow!("no goal with id {id}"))?; - if goal.owner != session_owner(session_id) { - return Err(anyhow!("goal {id} belongs to another session")); - } - Ok(goal) - } -} - -fn require(field: &Option, name: &str) -> Result { - field - .as_deref() - .map(str::trim) - .filter(|v| !v.is_empty()) - .map(str::to_string) - .ok_or_else(|| anyhow!("'{name}' is required and must be non-empty")) -} - -fn clean(items: Option<&[String]>) -> Vec { - items - .unwrap_or_default() - .iter() - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .collect() -} - -fn finished_error(id: &str, state: GoalState) -> anyhow::Error { - anyhow!("goal {id} is already {} and cannot change", state.label()) -} - -#[async_trait::async_trait] -impl Tool for GoalTool { - type Input = GoalInput; - type Output = GoalOutput; - - fn spec(&self) -> ToolSpec { - ToolSpec { - name: "goal".into(), - description: "Commit this session to a durable goal: an outcome pursued \ - autonomously, turn by turn, while the app is open — until its completion \ - contract is satisfied, a real obstacle blocks it, or its budget runs out. The \ - goal survives a session reload, but is not pursued while the app is closed. Use \ - it for multi-turn work with a definite result, not for a one-off reply. Every \ - goal needs a completion contract: the outcome, how success is verified (the goal \ - is 'done' only when this passes), and when to give up. Give it a turn budget \ - (and optionally a deadline). Manage goals with show/list/pause/resume/cancel." - .into(), - parameters_schema: serde_json::json!({ - "type": "object", - "properties": { - "action": { - "type": "string", - "enum": ["create", "show", "list", "pause", "resume", "cancel"], - "description": "What to do." - }, - "objective": { - "type": "string", - "description": "For create: the user's objective, in their terms." - }, - "outcome": { - "type": "string", - "description": "For create: what 'done' means — the concrete outcome to reach." - }, - "verification": { - "type": "string", - "description": "For create: how success is verified (a command, a check, an artifact's existence). The goal is done only when this passes." - }, - "stop_condition": { - "type": "string", - "description": "For create: when to give up rather than keep trying." - }, - "constraints": { - "type": "array", - "items": { "type": "string" }, - "description": "For create, optional: things that must hold throughout." - }, - "boundaries": { - "type": "array", - "items": { "type": "string" }, - "description": "For create, optional: hard limits never to cross (e.g. 'never push, only commit locally')." - }, - "subgoals": { - "type": "array", - "items": { "type": "string" }, - "description": "For create, optional: a starting checklist of steps." - }, - "max_turns": { - "type": "integer", - "minimum": 1, - "description": "For create: the maximum number of autonomous turns to spend." - }, - "deadline": { - "type": "string", - "description": "For create, optional: a wall-clock deadline, YYYY-MM-DD HH:MM; past it the goal fails." - }, - "id": { - "type": "string", - "description": "For show/pause/resume/cancel: the goal id." - } - }, - "required": ["action"] - }), - annotations: None, - capabilities: ToolSpec::capabilities(&[ - capabilities::SCOPE_AGENT, - capabilities::SCOPE_AGENT_DIFF, - ]), - multiline_params: &["objective", "outcome", "verification", "stop_condition"], - hidden: false, - title_template: Some("Managing goal ({action})"), - } - } - - async fn execute<'a>( - &self, - context: &mut ToolContext<'a>, - input: &mut Self::Input, - ) -> Result { - let session_id = context - .session_id - .clone() - .ok_or_else(|| anyhow!("no session id in this context; goals are unavailable here"))?; - match input.action { - GoalAction::Create => self.create(&session_id, input), - GoalAction::Show => self.show(&session_id, input), - GoalAction::List => self.list(&session_id), - GoalAction::Pause => self.pause(&session_id, input), - GoalAction::Resume => self.resume(&session_id, input), - GoalAction::Cancel => self.cancel(&session_id, input), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use chrono::NaiveDate; - - fn now() -> NaiveDateTime { - NaiveDate::from_ymd_opt(2026, 7, 16) - .unwrap() - .and_hms_opt(10, 0, 0) - .unwrap() - } - - fn tool(dir: &std::path::Path) -> GoalTool { - GoalTool::new(dir.join("goals.json")).with_now(now()) - } - - async fn run(tool: &GoalTool, session_id: &str, input: GoalInput) -> Result { - let executor = crate::mocks::create_command_executor_mock(); - let mut context = ToolContext { - command_executor: &executor, - tool_id: None, - session_id: Some(session_id.to_string()), - permission_handler: None, - extensions: None, - }; - let mut input = input; - tool.execute(&mut context, &mut input).await - } - - fn create_input(objective: &str) -> GoalInput { - GoalInput { - action: GoalAction::Create, - objective: Some(objective.to_string()), - outcome: Some("it is done".to_string()), - verification: Some("the check passes".to_string()), - stop_condition: Some("three failed strategies".to_string()), - constraints: None, - boundaries: None, - subgoals: Some(vec!["step one".to_string()]), - max_turns: Some(5), - deadline: None, - id: None, - } - } - - fn action_on(action: GoalAction, id: &str) -> GoalInput { - GoalInput { - action, - objective: None, - outcome: None, - verification: None, - stop_condition: None, - constraints: None, - boundaries: None, - subgoals: None, - max_turns: None, - deadline: None, - id: Some(id.to_string()), - } - } - - #[tokio::test] - async fn create_persists_a_session_owned_running_goal() { - let dir = tempfile::tempdir().unwrap(); - let tool = tool(dir.path()); - - let output = run(&tool, "sess-1", create_input("ship it")).await.unwrap(); - assert!(output.success); - - let goals = tool.store().list().unwrap(); - assert_eq!(goals.len(), 1); - assert_eq!(goals[0].owner, session_owner("sess-1")); - assert_eq!(goals[0].state, GoalState::Running); - assert_eq!(goals[0].subgoals.len(), 1); - assert!(output.message.contains(&goals[0].id)); - } - - #[tokio::test] - async fn create_validates_the_contract_fields() { - let dir = tempfile::tempdir().unwrap(); - let tool = tool(dir.path()); - let mut input = create_input("ship it"); - input.verification = None; - - let error = run(&tool, "sess-1", input).await.unwrap_err(); - assert!(error.to_string().contains("verification")); - assert!(tool.store().list().unwrap().is_empty()); - } - - #[tokio::test] - async fn list_and_show_are_scoped_to_the_calling_session() { - let dir = tempfile::tempdir().unwrap(); - let tool = tool(dir.path()); - run(&tool, "sess-1", create_input("mine")).await.unwrap(); - run(&tool, "sess-2", create_input("theirs")).await.unwrap(); - - let listed = run( - &tool, - "sess-1", - GoalInput { - action: GoalAction::List, - ..create_input("") - }, - ) - .await - .unwrap(); - assert!(listed.message.contains("mine")); - assert!(!listed.message.contains("theirs")); - - // Another session's goal is not visible, steerable, or cancellable. - let foreign = tool.store().list().unwrap(); - let foreign_id = &foreign - .iter() - .find(|g| g.owner == session_owner("sess-2")) - .unwrap() - .id; - let error = run(&tool, "sess-1", action_on(GoalAction::Show, foreign_id)) - .await - .unwrap_err(); - assert!(error.to_string().contains("another session")); - let error = run(&tool, "sess-1", action_on(GoalAction::Cancel, foreign_id)) - .await - .unwrap_err(); - assert!(error.to_string().contains("another session")); - } - - #[tokio::test] - async fn show_renders_the_contract_and_ledger() { - let dir = tempfile::tempdir().unwrap(); - let tool = tool(dir.path()); - run(&tool, "sess-1", create_input("ship it")).await.unwrap(); - let id = tool.store().list().unwrap()[0].id.clone(); - - let shown = run(&tool, "sess-1", action_on(GoalAction::Show, &id)) - .await - .unwrap(); - assert!(shown.message.contains("Done when: it is done")); - assert!(shown.message.contains("Verify by: the check passes")); - assert!(shown.message.contains("0/5 turns used")); - assert!(shown.message.contains("[ ] step one")); - } - - #[tokio::test] - async fn pause_resume_cancel_walk_the_lifecycle() { - let dir = tempfile::tempdir().unwrap(); - let tool = tool(dir.path()); - run(&tool, "sess-1", create_input("ship it")).await.unwrap(); - let id = tool.store().list().unwrap()[0].id.clone(); - - run(&tool, "sess-1", action_on(GoalAction::Pause, &id)) - .await - .unwrap(); - assert_eq!( - tool.store().get(&id).unwrap().unwrap().state, - GoalState::Paused - ); - - run(&tool, "sess-1", action_on(GoalAction::Resume, &id)) - .await - .unwrap(); - assert_eq!( - tool.store().get(&id).unwrap().unwrap().state, - GoalState::Running - ); - - run(&tool, "sess-1", action_on(GoalAction::Cancel, &id)) - .await - .unwrap(); - let goal = tool.store().get(&id).unwrap().unwrap(); - assert_eq!(goal.state, GoalState::Failed); - assert_eq!(goal.note.as_deref(), Some("cancelled by the user")); - - // Terminal goals cannot change any more. - let error = run(&tool, "sess-1", action_on(GoalAction::Resume, &id)) - .await - .unwrap_err(); - assert!(error.to_string().contains("already failed")); - } - - #[tokio::test] - async fn pause_during_an_in_flight_turn_does_not_clobber_the_claim() { - let dir = tempfile::tempdir().unwrap(); - let tool = tool(dir.path()); - run(&tool, "sess-1", create_input("ship it")).await.unwrap(); - let store = tool.store(); - let snapshot = store.list().unwrap().remove(0); - let claim = store.claim_attempt(&snapshot, now()).unwrap().unwrap(); - - // The user pauses while the controller's turn is in flight. - run(&tool, "sess-1", action_on(GoalAction::Pause, &claim.id)) - .await - .unwrap(); - - // The pause advanced the revision but preserved the claim token, so - // the completed turn still merges into the ledger (as Preempted). - let paused = store.get(&claim.id).unwrap().unwrap(); - assert_eq!(paused.state, GoalState::Paused); - assert!(paused.in_flight.is_some(), "pause must not erase the claim"); - use agent_orchestration::goals::{ - AttemptCompletion, AttemptVerdict, ControllerDecision, Evaluation, - }; - let (merged, decision) = store - .finish_attempt( - &claim, - AttemptCompletion::Evaluated(Evaluation::new( - AttemptVerdict::Progressed, - "made progress", - )), - now(), - ) - .unwrap() - .unwrap(); - assert_eq!(decision, ControllerDecision::Preempted); - assert_eq!(merged.state, GoalState::Paused, "user stop wins"); - assert_eq!(merged.attempts.len(), 1, "the turn still spent its budget"); - } -} diff --git a/crates/code_assistant_core/src/tools/impls/mod.rs b/crates/code_assistant_core/src/tools/impls/mod.rs index a790a27c..cdf7a802 100644 --- a/crates/code_assistant_core/src/tools/impls/mod.rs +++ b/crates/code_assistant_core/src/tools/impls/mod.rs @@ -4,7 +4,6 @@ pub mod delete_files; pub mod edit; pub mod execute_command; pub mod glob_files; -pub mod goal; pub mod list_files; pub mod list_projects; pub mod list_skills; @@ -33,7 +32,6 @@ pub use delete_files::DeleteFilesTool; pub use edit::EditTool; pub use execute_command::ExecuteCommandTool; pub use glob_files::GlobFilesTool; -pub use goal::GoalTool; pub use list_files::ListFilesTool; pub use list_projects::ListProjectsTool; pub use list_skills::ListSkillsTool; diff --git a/crates/code_assistant_core/src/tools/mod.rs b/crates/code_assistant_core/src/tools/mod.rs index feef6223..a74708eb 100644 --- a/crates/code_assistant_core/src/tools/mod.rs +++ b/crates/code_assistant_core/src/tools/mod.rs @@ -79,11 +79,10 @@ pub fn register_default_tools(registry: &mut ToolRegistry, config: &ToolsConfig) use impls::{ BrowserActTool, BrowserCloseTool, BrowserLoginTool, BrowserNavigateTool, BrowserProfilesTool, BrowserReadTool, CancelWakeupTool, DeleteFilesTool, EditTool, - ExecuteCommandTool, GlobFilesTool, GoalTool, ListFilesTool, ListProjectsTool, - ListSkillsTool, NameSessionTool, PerplexityAskTool, ReadFilesTool, ReadSkillTool, - ReplaceInFileTool, ScheduleWakeupTool, SearchFilesTool, SpawnAgentTool, UpdatePlanTool, - ViewDocumentsTool, ViewImagesTool, WebFetchTool, WebSearchTool, WriteFileTool, - WriteStdinTool, + ExecuteCommandTool, GlobFilesTool, ListFilesTool, ListProjectsTool, ListSkillsTool, + NameSessionTool, PerplexityAskTool, ReadFilesTool, ReadSkillTool, ReplaceInFileTool, + ScheduleWakeupTool, SearchFilesTool, SpawnAgentTool, UpdatePlanTool, ViewDocumentsTool, + ViewImagesTool, WebFetchTool, WebSearchTool, WriteFileTool, WriteStdinTool, }; registry.register(Box::new(BrowserNavigateTool)); @@ -96,7 +95,6 @@ pub fn register_default_tools(registry: &mut ToolRegistry, config: &ToolsConfig) registry.register(Box::new(EditTool)); registry.register(Box::new(ExecuteCommandTool)); registry.register(Box::new(GlobFilesTool)); - registry.register(Box::new(GoalTool::default())); registry.register(Box::new(ListFilesTool)); registry.register(Box::new(ListProjectsTool)); diff --git a/crates/ui_terminal/src/app.rs b/crates/ui_terminal/src/app.rs index d0c8a508..48ee2449 100644 --- a/crates/ui_terminal/src/app.rs +++ b/crates/ui_terminal/src/app.rs @@ -558,7 +558,7 @@ async fn handle_command_result( } } } - CommandResult::Goal { objective } => { + CommandResult::Goal { args } => { let session_id = app_state.lock().await.current_session_id.clone(); let Some(session_id) = session_id else { app_state @@ -567,21 +567,16 @@ async fn handle_command_result( .set_info_message(Some("No active session to manage goals".to_string())); return; }; - // Claude-Code-style expansion: the command becomes a prompt and - // the agent does the actual work through the `goal` tool. - let message = match objective { - Some(objective) => format!( - "Use the `goal` tool to commit this session to a durable goal for:\n\ - {objective}\n\n\ - Derive a sensible completion contract (outcome, verification, stop \ - condition) and turn budget from that objective; ask first only if \ - something essential is missing." - ), - None => "Use the `goal` tool to list this session's goals and summarize \ - their state and progress." - .to_string(), - }; - actions.send_user_message(session_id, message, Vec::new()); + // Goals are user-set: the command works the store directly, the + // agent is never asked to create or steer one. + let command = code_assistant_core::goal_commands::GoalCommand::parse(&args); + let message = code_assistant_core::goal_commands::run_goal_command_now( + &code_assistant_core::goals::default_goals_path(), + &session_id, + &command, + ) + .unwrap_or_else(|error| format!("/goal: {error:#}")); + app_state.lock().await.set_info_message(Some(message)); } CommandResult::ShowPermissionTier => { let mut state = app_state.lock().await; @@ -993,9 +988,9 @@ async fn event_loop( ) .await; } - KeyEventResult::Goal { objective } => { + KeyEventResult::Goal { args } => { handle_command_result( - crate::commands::CommandResult::Goal { objective }, + crate::commands::CommandResult::Goal { args }, &app_state, &renderer, &actions, @@ -1247,7 +1242,7 @@ impl TerminalTuiApp { } // Goal controller: while the app is open, drives the sessions' - // durable goals (goal tool) one bounded turn at a time. The verdicts + // user-set durable goals (/goal) one bounded turn at a time. The verdicts // come from an LLM evaluator on the configured model; without a // usable provider the goals simply stay parked. match llm::factory::create_llm_client_from_model(&config.model, None, false, None).await { diff --git a/crates/ui_terminal/src/commands.rs b/crates/ui_terminal/src/commands.rs index 013ae945..0b058ce0 100644 --- a/crates/ui_terminal/src/commands.rs +++ b/crates/ui_terminal/src/commands.rs @@ -74,7 +74,8 @@ pub fn all_commands() -> &'static [SlashCommand] { SlashCommand { name: "goal", aliases: &[], - description: "Commit to a durable goal: /goal (bare /goal lists them)", + description: + "Set a durable goal: /goal (bare /goal lists; show|pause|resume|cancel [id])", }, SlashCommand { name: "skill", @@ -123,10 +124,10 @@ pub enum CommandResult { /// `:config:` / `:system:`); `None` means resolve it from the cached /// catalog by name. InvokeSkill { scope: Option, name: String }, - /// Ask the agent to manage a durable goal: `Some(objective)` commits to a - /// new goal, `None` lists the session's goals. Expands to a prompt so the - /// agent derives the completion contract through the `goal` tool. - Goal { objective: Option }, + /// Manage the session's durable goals directly (never through the agent): + /// the raw text after `/goal`, parsed by + /// `code_assistant_core::goal_commands::GoalCommand`. + Goal { args: String }, /// Show the current permission tier. ShowPermissionTier, /// Switch the permission tier. @@ -185,12 +186,9 @@ impl CommandProcessor { request_id: None, decision: tools_core::PermissionDecision::Denied, }, - "goal" => { - let objective = parts[1..].join(" "); - CommandResult::Goal { - objective: (!objective.is_empty()).then_some(objective), - } - } + "goal" => CommandResult::Goal { + args: parts[1..].join(" "), + }, "skill" => { if parts.len() > 1 { CommandResult::InvokeSkill { @@ -338,18 +336,16 @@ mod tests { } #[test] - fn goal_parses_the_objective_or_falls_back_to_listing() { + fn goal_passes_its_raw_arguments_through() { let processor = test_processor(); match processor.process_command("/goal ship the widget by friday") { - CommandResult::Goal { - objective: Some(objective), - } => assert_eq!(objective, "ship the widget by friday"), - other => panic!("expected a goal objective, got {other:?}"), + CommandResult::Goal { args } => assert_eq!(args, "ship the widget by friday"), + other => panic!("expected a goal command, got {other:?}"), + } + match processor.process_command("/goal") { + CommandResult::Goal { args } => assert!(args.is_empty()), + other => panic!("expected a goal command, got {other:?}"), } - assert!(matches!( - processor.process_command("/goal"), - CommandResult::Goal { objective: None } - )); } #[test] diff --git a/crates/ui_terminal/src/input.rs b/crates/ui_terminal/src/input.rs index 67163834..391a8119 100644 --- a/crates/ui_terminal/src/input.rs +++ b/crates/ui_terminal/src/input.rs @@ -48,9 +48,8 @@ pub enum KeyEventResult { /// Activate a skill. `scope` is the scope token, or `None` to resolve it /// from the cached catalog by name. InvokeSkill { scope: Option, name: String }, - /// Manage a durable goal (`/goal`): commit to `Some(objective)` or list - /// the session's goals. - Goal { objective: Option }, + /// Manage the session's durable goals (`/goal`): the raw argument text. + Goal { args: String }, /// Show the current permission tier. ShowPermissionTier, /// Switch the permission tier. @@ -217,7 +216,7 @@ impl InputManager { CommandResult::InvokeSkill { scope, name } => { KeyEventResult::InvokeSkill { scope, name } } - CommandResult::Goal { objective } => KeyEventResult::Goal { objective }, + CommandResult::Goal { args } => KeyEventResult::Goal { args }, CommandResult::ShowPermissionTier => KeyEventResult::ShowPermissionTier, CommandResult::SetPermissionTier(tier) => { KeyEventResult::SetPermissionTier(tier) From ad02d72baabdf926b8874db54ec4b9b96987d779 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20A=C3=9Fmus?= Date: Thu, 16 Jul 2026 23:12:35 +0200 Subject: [PATCH 7/9] refactor(prompt): goals leave the system prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A goal reaches the model only as the framed goal-turn message (goal_turn_text), which arrives like any user message and carries the full contract — the same shape as hermes' continuation prompt and Claude Code's stop-hook feedback; codex likewise steers via hidden user-role context fragments, never the system prompt. The ambient block was redundant with that, and stale by construction. --- .../src/plugins/system_prompt.rs | 125 ++---------------- 1 file changed, 10 insertions(+), 115 deletions(-) diff --git a/crates/code_assistant_core/src/plugins/system_prompt.rs b/crates/code_assistant_core/src/plugins/system_prompt.rs index 884f3b72..cbb18dc8 100644 --- a/crates/code_assistant_core/src/plugins/system_prompt.rs +++ b/crates/code_assistant_core/src/plugins/system_prompt.rs @@ -1,34 +1,24 @@ //! System prompt construction: model-specific base prompt, project file -//! trees, durable goals, and repository guidance (AGENTS.md / CLAUDE.md). +//! trees, and repository guidance (AGENTS.md / CLAUDE.md). +//! +//! Durable goals deliberately do NOT appear here: a goal reaches the model +//! only as the framed goal-turn message (`goal_turn_text`), which arrives +//! like any user message and carries the full contract — the same shape as +//! hermes' continuation prompt and Claude Code's stop-hook feedback. use crate::plugins::AgentAppState; use crate::tool_dialects::system_message::generate_system_message; use agent_core::hooks::{PromptCtx, SystemPromptProvider}; -use agent_orchestration::goals::GoalStore; use std::fs; -use std::path::{Path, PathBuf}; +use std::path::Path; use tracing::warn; -pub struct CodeAssistantSystemPrompt { - goals_path: PathBuf, -} - -impl Default for CodeAssistantSystemPrompt { - fn default() -> Self { - Self::new() - } -} +#[derive(Default)] +pub struct CodeAssistantSystemPrompt; impl CodeAssistantSystemPrompt { pub fn new() -> Self { - Self::with_goals_path(crate::goals::default_goals_path()) - } - - /// Provider reading goals from a custom store path (tests, embedders). - pub fn with_goals_path(goals_path: impl Into) -> Self { - Self { - goals_path: goals_path.into(), - } + Self } } @@ -80,14 +70,6 @@ impl SystemPromptProvider for CodeAssistantSystemPrompt { } } - // Surface the session's durable goals, so a reloaded session knows - // what it is still committed to (the goal controller keeps driving - // them while the app is open). - if let Some(section) = ctx.session_id.and_then(|id| self.render_goals_section(id)) { - system_message.push_str("\n\n"); - system_message.push_str(§ion); - } - // Append guidance files if present. Global AGENTS.md is loaded first so // project-specific guidance can refine or override it in the prompt. let guidance_files = read_guidance_files( @@ -114,43 +96,6 @@ impl SystemPromptProvider for CodeAssistantSystemPrompt { } } -impl CodeAssistantSystemPrompt { - /// The `# Durable Goals` block for this session's active goals; `None` - /// when there are none (or the store is unreadable — the prompt must not - /// fail over an optional block). - fn render_goals_section(&self, session_id: &str) -> Option { - let goals = GoalStore::new(&self.goals_path) - .active_for_owner(&crate::goals::session_owner(session_id)) - .map_err(|error| warn!("failed to read goals for the system prompt: {error:#}")) - .ok()?; - if goals.is_empty() { - return None; - } - let mut section = String::from( - "# Durable Goals\n\nThis session is committed to the following goal(s). They are \ - pursued autonomously while the app is open and survive session reloads; manage \ - them with the `goal` tool.\n", - ); - for goal in goals { - let note = goal - .note - .as_deref() - .map(|n| format!(" — {n}")) - .unwrap_or_default(); - section.push_str(&format!( - "- {} [{}] {}/{} turns: {}{}\n", - goal.id, - goal.state.label(), - goal.turns_used(), - goal.budget.max_turns, - goal.objective, - note, - )); - } - Some(section.trim_end().to_string()) - } -} - /// Attempt to read guidance from the global config directory and project root. /// /// Global `~/.config/code-assistant/AGENTS.md` is included when present. @@ -236,56 +181,6 @@ mod tests { Ok(()) } - #[test] - fn goals_section_lists_only_the_sessions_active_goals() -> Result<()> { - use agent_orchestration::goals::{Budget, CompletionContract}; - - let dir = tempdir()?; - let goals_path = dir.path().join("goals.json"); - let store = GoalStore::new(&goals_path); - let now = chrono::NaiveDate::from_ymd_opt(2026, 7, 16) - .unwrap() - .and_hms_opt(10, 0, 0) - .unwrap(); - let contract = CompletionContract::new("done", "check", "stop"); - store.add_new( - crate::goals::session_owner("sess-1"), - "ship the widget", - contract.clone(), - Budget::turns(5), - now, - )?; - store.add_new( - crate::goals::session_owner("sess-2"), - "other session's goal", - contract.clone(), - Budget::turns(5), - now, - )?; - let mut done = store.add_new( - crate::goals::session_owner("sess-1"), - "already cancelled", - contract, - Budget::turns(5), - now, - )?; - done.fail("cancelled", now)?; - store.update(&done)?; - - let provider = CodeAssistantSystemPrompt::with_goals_path(&goals_path); - let section = provider - .render_goals_section("sess-1") - .expect("active goal should render a section"); - assert!(section.starts_with("# Durable Goals")); - assert!(section.contains("ship the widget")); - assert!(section.contains("0/5 turns")); - assert!(!section.contains("other session's goal")); - assert!(!section.contains("already cancelled")); - - assert!(provider.render_goals_section("sess-3").is_none()); - Ok(()) - } - #[test] fn prefers_agents_md_over_claude_md() -> Result<()> { let dir = tempdir()?; From 69b8cefe6efceeb3a7ae9ef9f2151fea8ed848e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20A=C3=9Fmus?= Date: Thu, 16 Jul 2026 23:12:35 +0200 Subject: [PATCH 8/9] refactor(goals): an absorbed user message no longer pauses goals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A message the user sends during a goal turn takes natural priority — the goal continues after their turn; /goal pause|cancel is the deliberate stop. Only an explicit run cancel still parks the session's goals: the user actively stopped the autonomous work. --- crates/code_assistant_core/src/goals.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/code_assistant_core/src/goals.rs b/crates/code_assistant_core/src/goals.rs index 18ff3671..cb2bcc3d 100644 --- a/crates/code_assistant_core/src/goals.rs +++ b/crates/code_assistant_core/src/goals.rs @@ -340,10 +340,12 @@ impl GoalController { return Ok(()); } - // The user took the wheel during the goal turn (queued a message that - // was absorbed, or cancelled the run): after accounting the attempt, - // stop driving this session's goals until they deliberately resume. - let user_took_over = outcome.user_preempted || outcome.status == TurnStatus::Cancelled; + // A user message absorbed mid-turn does NOT pause the goal — the + // user's turn simply takes natural priority and the goal continues + // afterwards (`/goal pause|cancel` is the deliberate stop). Only an + // explicit run cancel is a stop signal strong enough to park the + // session's goals until the user resumes them. + let user_took_over = outcome.status == TurnStatus::Cancelled; let evidence = goal_turn_evidence(&outcome); let completion = match self.evaluator.evaluate(&claim, &evidence).await { From f1658c7a742ad20c4b8a8b0a68dd7c738734b047 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20A=C3=9Fmus?= Date: Thu, 16 Jul 2026 23:12:35 +0200 Subject: [PATCH 9/9] docs(roadmap): record the user-set goal redesign --- ROADMAP.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 771a1e9d..abea6c54 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -176,8 +176,10 @@ clean. > handle (`start_turn_if_idle` → `TurnHandle` → typed `TurnOutcome`) landed in > `SessionService`, and the goal/wait domain below lives in > `agent_orchestration` (PAL consumes it through re-export shims). -> code-assistant is now the second consumer: a `goal` tool in the default -> scopes (owner = session), a `# Durable Goals` prompt block, and a +> code-assistant is now the second consumer: user-set goals via the `/goal` +> command (`goal_commands`, owner = session; there is deliberately no +> model-facing goal tool, and no system-prompt goal block — a goal reaches +> the model only as the framed goal-turn message), and a > host-driven `GoalController` (`code_assistant_core::goals`) that drives > `Running` goals through `start_turn_if_idle` while the app is open — > deliberately no unattended auto-resume after process end. Remaining from @@ -280,9 +282,10 @@ All met as of 2026-07-16: (`agent_orchestration`'s suite plus `code_assistant_core::goals` controller tests against scripted providers/evaluators). - ✅ code-assistant can create and continue a goal in an ordinary session - (`goal` tool + `GoalController`; `/goal` in the terminal UI). + (user-set via `/goal` → `goal_commands` + `GoalController`; the earlier + model-facing `goal` tool was removed — only the user sets goals). - ✅ A goal may survive session reload without promising unattended - auto-resume (store-persisted; surfaced via the durable-goals prompt block; + auto-resume (store-persisted; surfaced on demand via `/goal` list/show; the controller loop lives and dies with the process). - ✅ PAL uses the shared model/controller while retaining its stronger restart and channel semantics (re-export shims since 2026-07-16).