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/ROADMAP.md b/ROADMAP.md index 6cbc8a34..abea6c54 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -172,6 +172,19 @@ 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: 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 +> 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 +276,25 @@ 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 + (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 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). +- ✅ 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/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/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()); + } +} diff --git a/crates/code_assistant/src/app/gpui.rs b/crates/code_assistant/src/app/gpui.rs index a03de80d..9f278c86 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' + // 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 + { + 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; 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/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/goals.rs b/crates/code_assistant_core/src/goals.rs new file mode 100644 index 00000000..cb2bcc3d --- /dev/null +++ b/crates/code_assistant_core/src/goals.rs @@ -0,0 +1,797 @@ +//! 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(()); + } + + // 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 { + 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..a7acabe1 100644 --- a/crates/code_assistant_core/src/lib.rs +++ b/crates/code_assistant_core/src/lib.rs @@ -14,6 +14,8 @@ 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; pub mod session; 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..cbb18dc8 100644 --- a/crates/code_assistant_core/src/plugins/system_prompt.rs +++ b/crates/code_assistant_core/src/plugins/system_prompt.rs @@ -1,5 +1,10 @@ //! System prompt construction: model-specific base prompt, project file //! 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; @@ -8,8 +13,15 @@ use std::fs; use std::path::Path; use tracing::warn; +#[derive(Default)] pub struct CodeAssistantSystemPrompt; +impl CodeAssistantSystemPrompt { + pub fn new() -> Self { + Self + } +} + impl SystemPromptProvider for CodeAssistantSystemPrompt { fn build(&self, ctx: &PromptCtx) -> String { let state = AgentAppState::of_ref(ctx.extensions); diff --git a/crates/ui_terminal/src/app.rs b/crates/ui_terminal/src/app.rs index 7f530096..48ee2449 100644 --- a/crates/ui_terminal/src/app.rs +++ b/crates/ui_terminal/src/app.rs @@ -558,6 +558,26 @@ async fn handle_command_result( } } } + CommandResult::Goal { args } => { + 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; + }; + // 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; let message = match state.current_permission_tier { @@ -968,6 +988,15 @@ async fn event_loop( ) .await; } + KeyEventResult::Goal { args } => { + handle_command_result( + crate::commands::CommandResult::Goal { args }, + &app_state, + &renderer, + &actions, + ) + .await; + } KeyEventResult::OpenSessionPicker => { // Inline `/sessions` (popup not active): reuse // the command-result handler to open the picker. @@ -1212,6 +1241,27 @@ impl TerminalTuiApp { )); } + // Goal controller: while the app is open, drives the sessions' + // 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 { + 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..0b058ce0 100644 --- a/crates/ui_terminal/src/commands.rs +++ b/crates/ui_terminal/src/commands.rs @@ -71,6 +71,12 @@ pub fn all_commands() -> &'static [SlashCommand] { aliases: &[], description: "Deny the pending tool permission request", }, + SlashCommand { + name: "goal", + aliases: &[], + description: + "Set a durable goal: /goal (bare /goal lists; show|pause|resume|cancel [id])", + }, SlashCommand { name: "skill", aliases: &[], @@ -118,6 +124,10 @@ pub enum CommandResult { /// `:config:` / `:system:`); `None` means resolve it from the cached /// catalog by name. InvokeSkill { scope: Option, name: String }, + /// 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. @@ -176,6 +186,9 @@ impl CommandProcessor { request_id: None, decision: tools_core::PermissionDecision::Denied, }, + "goal" => CommandResult::Goal { + args: parts[1..].join(" "), + }, "skill" => { if parts.len() > 1 { CommandResult::InvokeSkill { @@ -322,6 +335,19 @@ mod tests { assert!(cmd.aliases.contains(&"resume")); } + #[test] + fn goal_passes_its_raw_arguments_through() { + let processor = test_processor(); + match processor.process_command("/goal ship the widget by friday") { + 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:?}"), + } + } + #[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..391a8119 100644 --- a/crates/ui_terminal/src/input.rs +++ b/crates/ui_terminal/src/input.rs @@ -48,6 +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 the session's durable goals (`/goal`): the raw argument text. + Goal { args: String }, /// Show the current permission tier. ShowPermissionTier, /// Switch the permission tier. @@ -214,6 +216,7 @@ impl InputManager { CommandResult::InvokeSkill { scope, name } => { KeyEventResult::InvokeSkill { scope, name } } + CommandResult::Goal { args } => KeyEventResult::Goal { args }, CommandResult::ShowPermissionTier => KeyEventResult::ShowPermissionTier, CommandResult::SetPermissionTier(tier) => { KeyEventResult::SetPermissionTier(tier)