diff --git a/src/agent.rs b/src/agent.rs index da283ab..391ccc2 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -10,7 +10,7 @@ use crate::model::ModelChoice; use crate::runtime::{CheckpointTarget, GenerationEvent, GenerationService}; #[cfg(target_os = "macos")] use crate::settings::{EngineSettings, ReasoningMode, TurnSettings}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use std::collections::HashMap; use std::ffi::{OsStr, OsString}; @@ -27,6 +27,9 @@ use std::thread; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; const MAX_FILE_BYTES: u64 = 16 * 1024 * 1024; +const MAX_RALPH_REPORT_BYTES: usize = 16 * 1024; +const DEFAULT_RALPH_ROUNDS: usize = 8; +const MAX_RALPH_STEPS_PER_ROUND: usize = 64; const SHELL_ENV_TIMEOUT: Duration = Duration::from_secs(5); const SHELL_ENV_SENTINEL: &[u8] = b"\0DS4_ENV\0"; static USER_SHELL_ENVIRONMENT: OnceLock> = OnceLock::new(); @@ -203,11 +206,14 @@ enum ToolHandler { DevBrainInfo, DevBrainSearch, DevBrainValidate, + Ralph, + RalphReport, } #[derive(Clone, Copy)] enum ParameterKind { String, + NonEmptyString, Integer { min: u64, max: u64 }, Boolean, Enum(&'static [&'static str]), @@ -236,6 +242,7 @@ struct ToolSpec { } const STRING: ParameterKind = ParameterKind::String; +const NON_EMPTY: ParameterKind = ParameterKind::NonEmptyString; const BOOL: ParameterKind = ParameterKind::Boolean; const U32: ParameterKind = ParameterKind::Integer { min: 0, @@ -530,6 +537,59 @@ const TOOLS: &[ToolSpec] = &[ handler: ToolHandler::DevBrainValidate, dev_brain: true, }, + ToolSpec { + name: "ralph", + description: "Run foreground fresh-agent rounds toward one immutable coding objective in the current workspace. Each round sees only the objective, its round number, applicable project instructions, and the previous validated report. Use this only when bounded autonomous iteration is useful; it stops on worker-reported completion/blocker, failure, cancellation, or max_rounds (default 8, maximum 64).", + parameters: &[ + ToolParameter { + name: "objective", + kind: NON_EMPTY, + required: true, + }, + ToolParameter { + name: "max_rounds", + kind: ParameterKind::Integer { min: 1, max: 64 }, + required: false, + }, + ], + rule: ToolRule::None, + handler: ToolHandler::Ralph, + dev_brain: false, + }, + ToolSpec { + name: "ralph_report", + description: "Finish the current Ralph round with exactly one structured handoff. status=continue needs non-empty next_steps and an empty blocker; status=complete needs non-empty evidence and empty next_steps/blocker; status=blocked needs a non-empty blocker. Completion and blockers are worker reports, not independent certification.", + parameters: &[ + ToolParameter { + name: "status", + kind: ParameterKind::Enum(&["continue", "complete", "blocked"]), + required: true, + }, + ToolParameter { + name: "summary", + kind: NON_EMPTY, + required: true, + }, + ToolParameter { + name: "evidence", + kind: STRING, + required: true, + }, + ToolParameter { + name: "next_steps", + kind: STRING, + required: true, + }, + ToolParameter { + name: "blocker", + kind: STRING, + required: true, + }, + ], + rule: ToolRule::None, + handler: ToolHandler::RalphReport, + dev_brain: false, + }, ]; fn tool_spec(name: &str) -> Option<&'static ToolSpec> { @@ -539,8 +599,12 @@ fn tool_spec(name: &str) -> Option<&'static ToolSpec> { fn parameter_schema(kind: ParameterKind) -> Value { let mut schema = Map::new(); match kind { - ParameterKind::String => { + ParameterKind::String | ParameterKind::NonEmptyString => { schema.insert("type".into(), Value::String("string".into())); + if matches!(kind, ParameterKind::NonEmptyString) { + schema.insert("minLength".into(), Value::from(1)); + schema.insert("pattern".into(), Value::String("\\S".into())); + } } ParameterKind::Integer { min, max } => { schema.insert("type".into(), Value::String("integer".into())); @@ -614,15 +678,23 @@ fn tool_schema(tool: &ToolSpec) -> Value { ])) } -fn tool_schemas(dev_brain: bool) -> String { +fn tool_schemas(dev_brain: bool, ralph_child: bool) -> String { TOOLS .iter() - .filter(|tool| !tool.dev_brain || dev_brain) + .filter(|tool| { + (!tool.dev_brain || dev_brain) + && match tool.handler { + ToolHandler::Ralph => !ralph_child, + ToolHandler::RalphReport => ralph_child, + _ => true, + } + }) .map(|tool| serde_json::to_string(&tool_schema(tool)).expect("tool schema is serializable")) .collect::>() .join("\n") } +#[derive(Debug)] struct ToolFailure { tool: String, code: &'static str, @@ -721,6 +793,9 @@ fn validate_tool_call(call: &ToolCall) -> Result<&'static ToolSpec, ToolFailure> }; let valid = match parameter.kind { ParameterKind::String => value.is_string(), + ParameterKind::NonEmptyString => { + value.as_str().is_some_and(|value| !value.trim().is_empty()) + } ParameterKind::Boolean => value.is_boolean(), ParameterKind::Enum(values) => { value.as_str().is_some_and(|value| values.contains(&value)) @@ -762,8 +837,12 @@ fn validate_tool_call(call: &ToolCall) -> Result<&'static ToolSpec, ToolFailure> } }; if !valid { - let code = if matches!(parameter.kind, ParameterKind::Enum(_)) && value.is_string() { - "invalid_enum" + let code = if value.is_string() { + match parameter.kind { + ParameterKind::Enum(_) => "invalid_enum", + ParameterKind::NonEmptyString => "invalid_value", + _ => "invalid_type", + } } else { "invalid_type" }; @@ -795,6 +874,7 @@ impl ParameterKind { fn expected(self) -> String { match self { Self::String => "string".into(), + Self::NonEmptyString => "non-empty string".into(), Self::Integer { min, max } => format!("integer {min}..={max}"), Self::Boolean => "boolean".into(), Self::Enum(values) => format!("one of {}", values.join(",")), @@ -802,6 +882,248 @@ impl ParameterKind { } } +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +enum RalphStatus { + Continue, + Complete, + Blocked, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +struct RalphReport { + status: RalphStatus, + summary: String, + evidence: String, + next_steps: String, + blocker: String, +} + +fn ralph_report(call: &ToolCall) -> Result { + let tool = validate_tool_call(call)?; + if tool.handler != ToolHandler::RalphReport { + return Err(ToolFailure::new( + &call.name, + "invalid_report_tool", + "$", + "ralph_report", + &call.name, + )); + } + let status = match string(call, "status") { + Some("continue") => RalphStatus::Continue, + Some("complete") => RalphStatus::Complete, + Some("blocked") => RalphStatus::Blocked, + _ => unreachable!("the shared validator checked the report status"), + }; + let report = RalphReport { + status, + summary: string(call, "summary").unwrap().trim().to_owned(), + evidence: string(call, "evidence").unwrap().trim().to_owned(), + next_steps: string(call, "next_steps").unwrap().trim().to_owned(), + blocker: string(call, "blocker").unwrap().trim().to_owned(), + }; + let semantic_error = match report.status { + RalphStatus::Continue if report.next_steps.is_empty() => Some(( + "$.next_steps", + "non-empty string when status=continue", + "empty", + )), + RalphStatus::Continue if !report.blocker.is_empty() => Some(( + "$.blocker", + "empty string when status=continue", + "non-empty", + )), + RalphStatus::Complete if report.evidence.is_empty() => Some(( + "$.evidence", + "non-empty string when status=complete", + "empty", + )), + RalphStatus::Complete if !report.next_steps.is_empty() => Some(( + "$.next_steps", + "empty string when status=complete", + "non-empty", + )), + RalphStatus::Complete if !report.blocker.is_empty() => Some(( + "$.blocker", + "empty string when status=complete", + "non-empty", + )), + RalphStatus::Blocked if report.blocker.is_empty() => { + Some(("$.blocker", "non-empty string when status=blocked", "empty")) + } + _ => None, + }; + if let Some((field, expected, received)) = semantic_error { + return Err(ToolFailure::new( + "ralph_report", + "invalid_report", + field, + expected, + received, + )); + } + let bytes = serde_json::to_vec(&report).expect("Ralph reports are serializable"); + if bytes.len() > MAX_RALPH_REPORT_BYTES { + return Err(ToolFailure::new( + "ralph_report", + "report_too_large", + "$", + format!("serialized report <= {MAX_RALPH_REPORT_BYTES} bytes"), + format!("{} bytes", bytes.len()), + )); + } + Ok(report) +} + +fn ralph_round_prompt( + objective: &str, + round: usize, + max_rounds: usize, + previous: Option<&RalphReport>, +) -> String { + let objective = serde_json::to_string(objective).expect("objectives are serializable"); + let previous = previous.map_or_else( + || "null".into(), + |report| serde_json::to_string(report).expect("validated reports are serializable"), + ); + format!( + "You are one fresh Ralph worker. Work only toward immutable_objective. Inspect the current workspace as the source of truth, perform concrete in-scope work, and verify it. You have no parent or earlier worker conversation; previous_report is the only conversational handoff. Do not call ralph. End this round with exactly one ralph_report call.\n\nimmutable_objective={objective}\nround={round}\nround_cap={max_rounds}\nprevious_report={previous}" + ) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum RalphOutcomeStatus { + WorkerReportedComplete, + WorkerReportedBlocked, + RoundLimit, + Cancelled, + ChildFailed, +} + +struct RalphOutcome { + rounds: usize, + status: RalphOutcomeStatus, + report: Option, + error: Option, +} + +impl RalphOutcome { + fn render(&self) -> String { + let status = match self.status { + RalphOutcomeStatus::WorkerReportedComplete => "worker_reported_complete", + RalphOutcomeStatus::WorkerReportedBlocked => "worker_reported_blocked", + RalphOutcomeStatus::RoundLimit => "round_limit", + RalphOutcomeStatus::Cancelled => "cancelled", + RalphOutcomeStatus::ChildFailed => "child_failed", + }; + let report = self.report.as_ref().map_or_else( + || "null".into(), + |report| serde_json::to_string(report).expect("validated reports are serializable"), + ); + let mut result = if self.status == RalphOutcomeStatus::ChildFailed { + ToolFailure::new( + "ralph", + "child_failed", + "$", + "valid worker report", + bounded_tool_text( + &self + .error + .as_deref() + .unwrap_or("unknown child failure") + .replace(['\n', '\r'], " "), + 512, + ), + ) + .render() + } else { + String::new() + }; + result.push_str(&format!( + "Ralph terminal result (worker-reported; not independently certified)\nrounds={} status={status}\nlast_valid_report={report}\n", + self.rounds + )); + if let Some(error) = &self.error { + result.push_str(&format!( + "error={}\n", + bounded_tool_text(&error.replace(['\n', '\r'], " "), 512) + )); + } + result + } +} + +fn run_ralph_loop( + max_rounds: usize, + cancel: &AtomicBool, + mut progress: impl FnMut(usize), + mut run_round: impl FnMut(usize, Option<&RalphReport>) -> Result, +) -> RalphOutcome { + let mut last = None; + for round in 1..=max_rounds { + if cancel.load(Ordering::Relaxed) { + return RalphOutcome { + rounds: round - 1, + status: RalphOutcomeStatus::Cancelled, + report: last, + error: Some("interrupted".into()), + }; + } + progress(round); + if cancel.load(Ordering::Relaxed) { + return RalphOutcome { + rounds: round - 1, + status: RalphOutcomeStatus::Cancelled, + report: last, + error: Some("interrupted".into()), + }; + } + let report = match run_round(round, last.as_ref()) { + Ok(report) => report, + Err(error) => { + return RalphOutcome { + rounds: round, + status: if cancel.load(Ordering::Relaxed) || error == "interrupted" { + RalphOutcomeStatus::Cancelled + } else { + RalphOutcomeStatus::ChildFailed + }, + report: last, + error: Some(error), + }; + } + }; + let status = report.status; + last = Some(report); + match status { + RalphStatus::Complete => { + return RalphOutcome { + rounds: round, + status: RalphOutcomeStatus::WorkerReportedComplete, + report: last, + error: None, + }; + } + RalphStatus::Blocked => { + return RalphOutcome { + rounds: round, + status: RalphOutcomeStatus::WorkerReportedBlocked, + report: last, + error: None, + }; + } + RalphStatus::Continue => {} + } + } + RalphOutcome { + rounds: max_rounds, + status: RalphOutcomeStatus::RoundLimit, + report: last, + error: None, + } +} + #[derive(Clone, Debug, PartialEq)] pub(crate) struct ToolCall { pub(crate) name: String, @@ -1089,6 +1411,44 @@ struct BashJob { observed: usize, } +#[cfg(target_os = "macos")] +#[derive(Clone)] +struct RalphRuntime { + service: GenerationService, + model: ModelChoice, + engine: EngineSettings, + turn: TurnSettings, + idle_timeout: Duration, +} + +#[cfg(target_os = "macos")] +struct RalphRoundDirectory(PathBuf); + +#[cfg(target_os = "macos")] +impl RalphRoundDirectory { + fn create(round: usize) -> Result { + let path = std::env::temp_dir().join(format!( + "ds4_ralph_{}_{}_{}", + std::process::id(), + round, + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + )); + fs::create_dir(&path) + .map_err(|error| format!("could not create fresh Ralph context: {error}"))?; + Ok(Self(path)) + } +} + +#[cfg(target_os = "macos")] +impl Drop for RalphRoundDirectory { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + pub(crate) struct Tools { root: PathBuf, context_tokens: i32, @@ -1099,6 +1459,8 @@ pub(crate) struct Tools { browser: Browser, dev_brain: Option, repeated_calls: HashMap, + #[cfg(target_os = "macos")] + ralph: Option, } impl Tools { @@ -1115,6 +1477,8 @@ impl Tools { browser: Browser::new(), dev_brain: None, repeated_calls: HashMap::new(), + #[cfg(target_os = "macos")] + ralph: None, }) } @@ -1127,6 +1491,24 @@ impl Tools { Ok(()) } + #[cfg(target_os = "macos")] + pub(crate) fn enable_ralph( + &mut self, + service: GenerationService, + model: ModelChoice, + engine: EngineSettings, + turn: TurnSettings, + idle_timeout: Duration, + ) { + self.ralph = Some(RalphRuntime { + service, + model, + engine, + turn, + idle_timeout, + }); + } + #[cfg(test)] fn execute(&mut self, call: &ToolCall, cancel: &AtomicBool) -> String { let tool = match validate_tool_call(call) { @@ -1157,6 +1539,9 @@ impl Tools { ToolHandler::DevBrainInfo => self.dev_brain_info(), ToolHandler::DevBrainSearch => self.dev_brain_search(call), ToolHandler::DevBrainValidate => self.dev_brain_validate(), + ToolHandler::Ralph | ToolHandler::RalphReport => { + Err(format!("{} requires its owning agent context", call.name)) + } }; match result { Ok(result) if result.len() <= self.result_limit() => result, @@ -1195,6 +1580,305 @@ impl Tools { } } + #[cfg(target_os = "macos")] + fn run_ralph( + &mut self, + call: &ToolCall, + cancel: &AtomicBool, + events: &Sender, + index: usize, + approval_mode: &ShellApprovalMode, + ) -> String { + let Some(runtime) = self.ralph.clone() else { + return execution_failure(call, "Ralph runtime is unavailable"); + }; + let objective = string(call, "objective").unwrap().to_owned(); + let max_rounds = integer(call, "max_rounds", DEFAULT_RALPH_ROUNDS, 1, 64); + let existing_jobs = self.jobs.keys().copied().collect::>(); + let saved_more = self.more.take(); + let saved_more_text = self.more_text.take(); + let saved_repeats = std::mem::take(&mut self.repeated_calls); + let outcome = run_ralph_loop( + max_rounds, + cancel, + |round| { + send_state( + events, + index, + ToolLifecycle::Running, + Some(format!( + "Ralph round {round}/{max_rounds} · starting fresh context\n" + )), + ); + }, + |round, previous| { + self.more = None; + self.more_text = None; + self.repeated_calls.clear(); + let round_jobs = self.jobs.keys().copied().collect::>(); + let result = self.run_ralph_round( + &runtime, + &objective, + round, + max_rounds, + previous, + cancel, + events, + index, + approval_mode, + ); + let cleanup = self.stop_jobs_except(&round_jobs); + if cleanup.is_empty() { + result + } else { + Err(format!( + "Ralph round cleanup failed: {}", + cleanup.join("; ") + )) + } + }, + ); + self.more = saved_more; + self.more_text = saved_more_text; + self.repeated_calls = saved_repeats; + let mut result = outcome.render(); + for warning in self.stop_jobs_except(&existing_jobs) { + result.push_str(&format!("cleanup_warning={warning}\n")); + } + result + } + + #[cfg(target_os = "macos")] + #[allow(clippy::too_many_arguments)] + fn run_ralph_round( + &mut self, + runtime: &RalphRuntime, + objective: &str, + round: usize, + max_rounds: usize, + previous: Option<&RalphReport>, + cancel: &AtomicBool, + events: &Sender, + index: usize, + approval_mode: &ShellApprovalMode, + ) -> Result { + // A unique cache namespace preserves tool continuations inside this + // round while preventing parent or earlier-round KV restoration. + let directory = RalphRoundDirectory::create(round)?; + let mut messages = vec![ChatTurn { + user: true, + tool: false, + system: false, + skip_previous_eos: false, + reasoning: None, + reasoning_complete: true, + content: ralph_round_prompt(objective, round, max_rounds, previous), + }]; + for step in 1..=MAX_RALPH_STEPS_PER_ROUND { + send_state( + events, + index, + ToolLifecycle::Running, + Some(format!( + "Ralph round {round}/{max_rounds} · child generation {step}\n" + )), + ); + let output = run_ralph_generation(runtime, &messages, &directory.0, cancel)?; + let calls = parse_tool_calls(runtime.model, &output.message.content) + .map_err(|error| format!("malformed child tool syntax: {error}"))? + .1; + if calls.is_empty() { + return Err("child finished without a ralph_report call".into()); + } + let report_calls = calls + .iter() + .filter(|call| call.name == "ralph_report") + .count(); + if report_calls > 0 { + if report_calls != 1 || calls.len() != 1 { + return Err( + "a Ralph round must end with exactly one standalone ralph_report call" + .into(), + ); + } + return ralph_report(&calls[0]).map_err(|error| error.render()); + } + messages.push(output.message); + let result = self.execute_ralph_calls( + &calls, + round, + max_rounds, + cancel, + events, + index, + approval_mode, + ); + messages.push(ChatTurn { + user: false, + tool: true, + system: false, + skip_previous_eos: false, + reasoning: None, + reasoning_complete: true, + content: result, + }); + } + Err(format!( + "child exceeded {MAX_RALPH_STEPS_PER_ROUND} tool continuations without a valid report" + )) + } + + #[cfg(target_os = "macos")] + #[allow(clippy::too_many_arguments)] + fn execute_ralph_calls( + &mut self, + calls: &[ToolCall], + round: usize, + max_rounds: usize, + cancel: &AtomicBool, + events: &Sender, + index: usize, + approval_mode: &ShellApprovalMode, + ) -> String { + let mut output = String::new(); + for (call_index, call) in calls.iter().enumerate() { + if cancel.load(Ordering::Relaxed) { + output.push_str( + &ToolFailure::new( + &call.name, + "interrupted", + "$", + "active Ralph child session", + "session cancelled", + ) + .render(), + ); + break; + } + let advisory = self.repeat_advisory(call); + let tool = match validate_tool_call(call) { + Ok(tool) if tool.handler == ToolHandler::Ralph => { + let result = with_advisory( + ToolFailure::new( + "ralph", + "recursive_call_denied", + "$", + "non-Ralph child tool", + "ralph", + ) + .render(), + advisory, + ); + output.push_str(&format!( + "Tool result {} (ralph):\n{result}", + call_index + 1 + )); + continue; + } + Ok(tool) if tool.handler == ToolHandler::RalphReport => { + unreachable!("standalone report calls are captured before execution") + } + Ok(tool) => tool, + Err(error) => { + let result = with_advisory(error.render(), advisory); + output.push_str(&format!( + "Tool result {} ({}):\n{result}", + call_index + 1, + call.name + )); + continue; + } + }; + send_state( + events, + index, + ToolLifecycle::Running, + Some(format!( + "Ralph round {round}/{max_rounds} · child tool {} ({})\n", + call_index + 1, + call.name + )), + ); + let browser_prompt = self.browser_approval(call); + let (shell_prompt, _) = if browser_prompt.is_none() { + approval_mode.approval( + call, + &self.root, + self.dev_brain.as_ref().map(|brain| brain.folder()), + cancel, + ) + } else { + (None, None) + }; + let result = if let Some(prompt) = browser_prompt.or(shell_prompt) { + match request_approval(events, index, prompt, cancel) { + Ok(()) => { + send_state( + events, + index, + ToolLifecycle::Running, + Some(format!( + "Ralph round {round}/{max_rounds} · child tool {} ({})\n", + call_index + 1, + call.name + )), + ); + self.execute_validated(tool, call, cancel) + } + Err(error) => ToolFailure::new( + &call.name, + if cancel.load(Ordering::Relaxed) { + "interrupted" + } else { + "policy_denied" + }, + "$", + "approved tool execution", + error, + ) + .render(), + } + } else { + self.execute_validated(tool, call, cancel) + }; + let result = with_advisory(result, advisory); + output.push_str(&format!( + "Tool result {} ({}):\n{result}", + call_index + 1, + call.name + )); + if !output.ends_with('\n') { + output.push('\n'); + } + } + output + } + + #[cfg(target_os = "macos")] + fn stop_jobs_except(&mut self, keep: &[u32]) -> Vec { + let stop = self + .jobs + .keys() + .filter(|id| !keep.contains(id)) + .copied() + .collect::>(); + let mut failures = Vec::new(); + for id in stop { + if let Some(mut job) = self.jobs.remove(&id) { + stop_job(&mut job); + if let Err(error) = fs::remove_file(&job.output) + && error.kind() != std::io::ErrorKind::NotFound + { + failures.push(format!( + "could not remove {}: {error}", + job.output.display() + )); + } + } + } + failures + } + fn dev_brain_search(&mut self, call: &ToolCall) -> Result { let query = required_string(call, "query")?; let limit = integer(call, "limit", 8, 1, 50); @@ -1796,6 +2480,43 @@ impl Tools { } } +#[cfg(target_os = "macos")] +fn run_ralph_generation( + runtime: &RalphRuntime, + messages: &[ChatTurn], + checkpoint: &Path, + cancel: &AtomicBool, +) -> Result { + let active = runtime.service.generate( + runtime.engine.clone(), + runtime.turn.clone(), + messages.to_vec(), + CheckpointTarget::Transient(checkpoint.to_owned()), + WorkSource::LocalChat, + runtime.idle_timeout, + )?; + loop { + if cancel.load(Ordering::Relaxed) { + active.cancel.store(true, Ordering::Relaxed); + return Err("interrupted".into()); + } + match active.events.recv_timeout(Duration::from_millis(50)) { + Ok(GenerationEvent::Finished(result)) => return result, + Ok(GenerationEvent::Chunk { .. }) + | Ok(GenerationEvent::Loading) + | Ok(GenerationEvent::Activity(_)) + | Ok(GenerationEvent::Context { .. }) => {} + Ok(GenerationEvent::Compacted(_)) | Ok(GenerationEvent::Measured(_)) => { + return Err("child runtime returned an unexpected event".into()); + } + Err(RecvTimeoutError::Timeout) => {} + Err(RecvTimeoutError::Disconnected) => { + return Err("child model runtime stopped unexpectedly".into()); + } + } + } +} + struct SearchOptions<'a> { query: &'a str, glob: Option<&'a str>, @@ -2149,10 +2870,28 @@ pub(crate) fn execute_async( } send_state(&event_sender, index, ToolLifecycle::Running, None); output.push_str(&format!("Tool result {} ({}):\n", index + 1, call.name)); - let result = with_advisory( - tools.execute_validated(tool, call, &worker_cancel), - advisory, - ); + let result = if tool.handler == ToolHandler::Ralph { + #[cfg(target_os = "macos")] + { + tools.run_ralph(call, &worker_cancel, &event_sender, index, &approval_mode) + } + #[cfg(not(target_os = "macos"))] + { + execution_failure(call, "Ralph requires the local macOS model runtime") + } + } else if tool.handler == ToolHandler::RalphReport { + ToolFailure::new( + "ralph_report", + "internal_tool_denied", + "$", + "ralph_report inside an active Ralph child round", + "ordinary parent tool call", + ) + .render() + } else { + tools.execute_validated(tool, call, &worker_cancel) + }; + let result = with_advisory(result, advisory); let state = if worker_cancel.load(Ordering::Relaxed) || result.contains("Tool error: interrupted") { @@ -2269,7 +3008,21 @@ pub(crate) fn parse_tool_calls( } pub(crate) fn system_prompt(model: ModelChoice, extra: &str, dev_brain: bool) -> String { - let schemas = tool_schemas(dev_brain); + system_prompt_with_tools(model, extra, dev_brain, false) +} + +#[cfg(target_os = "macos")] +pub(crate) fn ralph_system_prompt(model: ModelChoice, extra: &str, dev_brain: bool) -> String { + system_prompt_with_tools(model, extra, dev_brain, true) +} + +fn system_prompt_with_tools( + model: ModelChoice, + extra: &str, + dev_brain: bool, + ralph_child: bool, +) -> String { + let schemas = tool_schemas(dev_brain, ralph_child); let tools = if model == ModelChoice::Glm52 { format!( "You are a coding agent running in a local workspace. Use tools for local file and system work. Avoid printing large file contents or code blocks as answers; edit files with tools, then summarize briefly.\n\n# Tools\n\n\n{schemas}\n\n\nFor a function call, output exactly: function-namekeyvalue\nTool calls are not allowed inside . Pass numbers and booleans as JSON primitives, not quoted strings. When a tool fails validation or execution, use its code, field, expected, and received feedback to correct the next call. Preserve the current system configuration unless the user explicitly asks otherwise." @@ -2755,12 +3508,16 @@ mod tests { #[test] fn generated_schemas_match_the_executable_tool_contracts() { - let schemas = tool_schemas(true) + let schemas = tool_schemas(true, false) .lines() .map(|line| serde_json::from_str::(line).unwrap()) .collect::>(); - assert_eq!(schemas.len(), TOOLS.len()); - for (schema, tool) in schemas.iter().zip(TOOLS) { + let contracts = TOOLS + .iter() + .filter(|tool| tool.handler != ToolHandler::RalphReport) + .collect::>(); + assert_eq!(schemas.len(), contracts.len()); + for (schema, tool) in schemas.iter().zip(contracts) { assert_eq!(schema["function"]["name"], tool.name); assert_eq!(schema["function"]["description"], tool.description); let parameters = &schema["function"]["parameters"]; @@ -2932,6 +3689,246 @@ mod tests { assert!(result.contains("Retry using the exact tool transport syntax")); } + #[test] + fn ralph_report_schemas_are_child_only_and_parse_for_both_models() { + for model in [ModelChoice::DeepSeekV4Flash, ModelChoice::Glm52] { + let parent = system_prompt(model, "", false); + let child = ralph_system_prompt(model, "", false); + assert!(parent.contains("\"name\":\"ralph\"")); + assert!(!parent.contains("\"name\":\"ralph_report\"")); + assert!(!child.contains("\"name\":\"ralph\"")); + assert!(child.contains("\"name\":\"ralph_report\"")); + } + + let glm = "ralph_reportstatuscompletesummaryImplemented and verified.evidencecargo test passednext_stepsblocker"; + let (_, calls) = parse_tool_calls(ModelChoice::Glm52, glm).unwrap(); + assert_eq!( + ralph_report(&calls[0]).unwrap().status, + RalphStatus::Complete + ); + + let dsml = "<|DSML|tool_calls><|DSML|invoke name=\"ralph_report\"><|DSML|parameter name=\"status\" string=\"true\">continue<|DSML|parameter name=\"summary\" string=\"true\">Inspected the failing test.<|DSML|parameter name=\"evidence\" string=\"true\">failure reproduced<|DSML|parameter name=\"next_steps\" string=\"true\">Fix the shared parser.<|DSML|parameter name=\"blocker\" string=\"true\">"; + let (_, calls) = parse_tool_calls(ModelChoice::DeepSeekV4Flash, dsml).unwrap(); + assert_eq!( + ralph_report(&calls[0]).unwrap().status, + RalphStatus::Continue + ); + } + + #[test] + fn ralph_report_semantics_and_handoff_bound_are_enforced() { + for report in [ + report_call("continue", "work", "observed", "next", ""), + report_call("complete", "done", "tests passed", "", ""), + report_call("blocked", "blocked", "", "", "missing fixture"), + ] { + assert!(ralph_report(&report).is_ok()); + } + for (report, field) in [ + (report_call("continue", "work", "", "", ""), "$.next_steps"), + ( + report_call("continue", "work", "", "next", "permission denied"), + "$.blocker", + ), + (report_call("complete", "done", "", "", ""), "$.evidence"), + ( + report_call("complete", "done", "tests", "more", ""), + "$.next_steps", + ), + (report_call("blocked", "blocked", "", "", ""), "$.blocker"), + ] { + let error = match ralph_report(&report) { + Ok(_) => panic!("expected report rejection"), + Err(error) => error.render(), + }; + assert!(error.contains("code=invalid_report"), "{error}"); + assert!(error.contains(&format!("field={field}")), "{error}"); + } + + let oversized = report_call( + "complete", + "done", + &"x".repeat(MAX_RALPH_REPORT_BYTES), + "", + "", + ); + assert!( + match ralph_report(&oversized) { + Ok(_) => panic!("expected oversized report rejection"), + Err(error) => error.render(), + } + .contains("code=report_too_large") + ); + assert!( + validation_error(&call("ralph", [("objective", " ")])) + .render() + .contains("code=invalid_value field=$.objective") + ); + assert!( + validation_error(&call( + "ralph", + [("objective", "work"), ("max_rounds", "65")] + )) + .render() + .contains("code=out_of_range field=$.max_rounds") + ); + } + + #[test] + fn scripted_ralph_backend_gets_fresh_prompts_and_only_validated_handoffs() { + let cancel = AtomicBool::new(false); + let mut scripted = std::collections::VecDeque::from([ + Ok(RalphReport { + status: RalphStatus::Continue, + summary: "Inspected the parser.".into(), + evidence: "The focused test fails.".into(), + next_steps: "Fix the shared parser and rerun the test.".into(), + blocker: String::new(), + }), + Ok(RalphReport { + status: RalphStatus::Complete, + summary: "Fixed the parser.".into(), + evidence: "Focused and aggregate tests pass.".into(), + next_steps: String::new(), + blocker: String::new(), + }), + ]); + let mut prompts = Vec::new(); + let outcome = run_ralph_loop( + 8, + &cancel, + |_| {}, + |round, previous| { + prompts.push(ralph_round_prompt("Fix parser", round, 8, previous)); + scripted.pop_front().unwrap() + }, + ); + assert_eq!(outcome.status, RalphOutcomeStatus::WorkerReportedComplete); + assert_eq!(outcome.rounds, 2); + assert!(prompts[0].contains("previous_report=null")); + assert!(prompts[1].contains("Inspected the parser.")); + assert!(!prompts[1].contains("parent conversation")); + assert!(outcome.render().contains("not independently certified")); + } + + #[test] + fn ralph_loop_stops_on_cap_blocker_failure_and_cancellation() { + let cancel = AtomicBool::new(false); + let continuation = RalphReport { + status: RalphStatus::Continue, + summary: "Still working.".into(), + evidence: "One test remains.".into(), + next_steps: "Fix it.".into(), + blocker: String::new(), + }; + let cap = run_ralph_loop(2, &cancel, |_| {}, |_, _| Ok(continuation.clone())); + assert_eq!(cap.status, RalphOutcomeStatus::RoundLimit); + assert_eq!(cap.rounds, 2); + + let blocked = run_ralph_loop( + 8, + &cancel, + |_| {}, + |_, _| { + Ok(RalphReport { + status: RalphStatus::Blocked, + summary: "Cannot verify hardware path.".into(), + evidence: String::new(), + next_steps: String::new(), + blocker: "Required checkpoint is not installed.".into(), + }) + }, + ); + assert_eq!(blocked.status, RalphOutcomeStatus::WorkerReportedBlocked); + + let mut attempts = 0; + let failed = run_ralph_loop( + 8, + &cancel, + |_| {}, + |_, _| { + attempts += 1; + if attempts == 1 { + Ok(continuation.clone()) + } else { + Err("scripted child failure".into()) + } + }, + ); + assert_eq!(failed.status, RalphOutcomeStatus::ChildFailed); + assert_eq!(failed.rounds, 2); + assert_eq!(failed.report, Some(continuation)); + + cancel.store(true, Ordering::Relaxed); + let cancelled = run_ralph_loop(8, &cancel, |_| {}, |_, _| unreachable!()); + assert_eq!(cancelled.status, RalphOutcomeStatus::Cancelled); + assert_eq!(cancelled.rounds, 0); + } + + #[cfg(target_os = "macos")] + #[test] + fn ralph_round_contexts_are_unique_and_child_jobs_are_cleaned_up() { + let first_path; + { + let first = RalphRoundDirectory::create(1).unwrap(); + let second = RalphRoundDirectory::create(2).unwrap(); + first_path = first.0.clone(); + assert_ne!(first.0, second.0); + assert!(first.0.is_dir()); + assert!(second.0.is_dir()); + } + assert!(!first_path.exists()); + + let directory = std::env::temp_dir().join(format!( + "ds4-ralph-jobs-{}", + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(&directory).unwrap(); + let mut tools = Tools::new(&directory, 4096).unwrap(); + for id in [1, 2] { + let output = directory.join(format!("job-{id}.txt")); + let stdout = File::create(&output).unwrap(); + let stderr = stdout.try_clone().unwrap(); + let child = Command::new("/bin/sh") + .args(["-c", "sleep 30"]) + .process_group(0) + .stdout(stdout) + .stderr(stderr) + .spawn() + .unwrap(); + tools.jobs.insert( + id, + BashJob { + id, + command: "sleep 30".into(), + child, + output, + started: Instant::now(), + timeout: Duration::from_secs(60), + observed: 0, + }, + ); + } + let child_pid = tools.jobs[&2].child.id(); + assert!(tools.stop_jobs_except(&[1]).is_empty()); + assert!(tools.jobs.contains_key(&1)); + assert!(!tools.jobs.contains_key(&2)); + assert!( + !Command::new("/bin/kill") + .args(["-0", &child_pid.to_string()]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .unwrap() + .success() + ); + drop(tools); + fs::remove_dir_all(directory).unwrap(); + } + #[test] fn repeated_call_advisories_use_canonical_arguments_and_reset_per_user_turn() { let directory = std::env::temp_dir().join(format!( @@ -3527,6 +4524,25 @@ mod tests { } } + fn report_call( + status: &str, + summary: &str, + evidence: &str, + next_steps: &str, + blocker: &str, + ) -> ToolCall { + call( + "ralph_report", + [ + ("status", status), + ("summary", summary), + ("evidence", evidence), + ("next_steps", next_steps), + ("blocker", blocker), + ], + ) + } + fn validation_error(call: &ToolCall) -> ToolFailure { match validate_tool_call(call) { Ok(_) => panic!("expected validation to fail"), diff --git a/src/app/generation.rs b/src/app/generation.rs index 2821493..9a1b4f6 100644 --- a/src/app/generation.rs +++ b/src/app/generation.rs @@ -1266,27 +1266,50 @@ impl App { self.agent_tools = Some((session_id, Arc::new(Mutex::new(tools)))); } let tools = Arc::clone(&self.agent_tools.as_ref().unwrap().1); + let model = self.config.model; + let generation = self.config.active_generation(); + let runtime = self.config.runtime_for(model); + let effective = + crate::settings::effective_settings(model, &generation, &runtime, &models_path())?; + let service = self + .generation_service + .as_ref() + .ok_or_else(|| "The model runtime is unavailable.".to_owned())? + .clone(); + let idle_timeout = Duration::from_secs(self.config.idle_timeout_minutes.max(1) as u64 * 60); + let mut child_turn = effective.turn.clone(); + child_turn.system_prompt = crate::agent::ralph_system_prompt( + model, + &child_turn.system_prompt, + self.config.dev_brain.enabled, + ); + if let Some(agents) = self.session_agents_prompt() { + child_turn.system_prompt.push_str("\n\n"); + child_turn.system_prompt.push_str(agents); + } + if let Some(skills) = self.dev_brain_skills_prompt() { + child_turn.system_prompt.push_str("\n\n"); + child_turn.system_prompt.push_str(&skills); + } + tools + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .enable_ralph( + service.clone(), + model, + effective.engine.clone(), + child_turn, + idle_timeout, + ); let approval_mode = match self.permission_mode { PermissionMode::Heuristic => crate::agent::ShellApprovalMode::Heuristic, PermissionMode::Ai => { - let generation = self.config.active_generation(); - let runtime = self.config.runtime_for(self.config.model); - let effective = crate::settings::effective_settings( - self.config.model, - &generation, - &runtime, - &models_path(), - )?; - let service = self - .generation_service - .as_ref() - .ok_or_else(|| "The model runtime is unavailable.".to_owned())?; crate::agent::ShellApprovalMode::Ai(Box::new(crate::agent::AiRiskClassifier::new( - service.clone(), + service, effective.engine, effective.turn, transient_cache_path(), - Duration::from_secs(self.config.idle_timeout_minutes.max(1) as u64 * 60), + idle_timeout, ))) } };