diff --git a/src/agent.rs b/src/agent.rs index 4db1ce7..7923542 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -1173,7 +1173,7 @@ pub(crate) fn parse_tool_calls( let (content, calls) = if model == ModelChoice::Glm52 { parse_glm_calls(text)? } else { - crate::server::parse_dsml_tool_calls(text)? + crate::dsml::parse_tool_calls(text)? }; calls .into_iter() diff --git a/src/app.rs b/src/app.rs index 032f421..bd83aac 100644 --- a/src/app.rs +++ b/src/app.rs @@ -19,7 +19,7 @@ use crate::config::{ GitDiffWhitespace, }; use crate::database::{Database, ProjectWithSessions, SessionState, StoredMessage}; -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", test))] use crate::engine::ChatTurn; use crate::metrics::{KvCacheReport, Metrics, MetricsSnapshot}; use crate::model::{self, DownloadOutcome, DownloadProgress, ManagedArtifactId, ModelChoice}; @@ -36,9 +36,11 @@ use rfd::AsyncFileDialog; use std::collections::{HashMap, HashSet, VecDeque}; use std::fs; use std::path::{Path, PathBuf}; +use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::mpsc::{self, TryRecvError}; -use std::sync::{Arc, Mutex, RwLock}; +#[cfg(target_os = "macos")] +use std::sync::{Mutex, RwLock}; use std::thread; use std::time::{Duration, Instant}; @@ -595,6 +597,7 @@ impl App { active_generation: None, #[cfg(target_os = "macos")] active_compaction: None, + #[cfg(target_os = "macos")] active_tool_check: None, #[cfg(target_os = "macos")] agent_tools: None, @@ -737,6 +740,7 @@ impl App { active_generation: None, #[cfg(target_os = "macos")] active_compaction: None, + #[cfg(target_os = "macos")] active_tool_check: None, #[cfg(target_os = "macos")] agent_tools: None, @@ -1567,8 +1571,11 @@ impl App { self.error = Some(format!("Could not play media: {error}")); } #[cfg(not(target_os = "macos"))] - if let Err(error) = std::process::Command::new("open").arg(url).spawn() { - self.error = Some(format!("Could not open media: {error}")); + { + let _ = (title, video); + if let Err(error) = std::process::Command::new("open").arg(url).spawn() { + self.error = Some(format!("Could not open media: {error}")); + } } } Message::RequestA2uiDismiss(surface_id) => { @@ -1788,8 +1795,10 @@ impl App { if let Some(database) = &mut self.database { match database.delete_project(project_id) { Ok(()) => { + #[cfg(not(target_os = "macos"))] + let _ = checkpoint_ids; + #[cfg(target_os = "macos")] for session_id in checkpoint_ids { - #[cfg(target_os = "macos")] self.background_chats.remove(&session_id); } self.drafts.remove(&project_id); diff --git a/src/app/generation.rs b/src/app/generation.rs index d7fc437..5684d4e 100644 --- a/src/app/generation.rs +++ b/src/app/generation.rs @@ -293,7 +293,7 @@ fn sync_a2ui_message( renderable_surface_updated } -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", test))] fn chat_turn(message: &ChatMessage) -> ChatTurn { ChatTurn { user: message.user, @@ -368,7 +368,7 @@ fn project_agents(path: &Path) -> Result, String> { } } -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", test))] fn title_context(messages: impl IntoIterator) -> Vec { let mut started = false; messages @@ -497,6 +497,8 @@ impl App { #[cfg(target_os = "macos")] let agents = agents_prompt_for_turn(opening_turn, opening_agents, self.session_agents_prompt()); + #[cfg(not(target_os = "macos"))] + let agents = None::; self.a2ui_auto_switch_pending = true; self.context_notice = None; #[cfg(target_os = "macos")] @@ -535,6 +537,7 @@ impl App { &effective.turn.system_prompt, self.compaction_summary(), ); + #[cfg(target_os = "macos")] let assistant_reasoning = effective.turn.reasoning_mode != ReasoningMode::Direct; #[cfg(target_os = "macos")] let model_prompt = if self.config.a2ui_enabled { @@ -700,7 +703,6 @@ impl App { { let _ = effective; self.error = Some("Local Metal generation requires macOS.".into()); - return; } } diff --git a/src/app/preferences.rs b/src/app/preferences.rs index 5421806..8b2d792 100644 --- a/src/app/preferences.rs +++ b/src/app/preferences.rs @@ -1,4 +1,5 @@ use super::*; +use std::sync::RwLock; #[derive(Clone)] pub(super) struct PreferenceDraft { @@ -437,6 +438,7 @@ impl App { return; } } + #[cfg(target_os = "macos")] let dev_brain_changed = self.config.dev_brain != config.dev_brain; #[cfg(target_os = "macos")] let endpoint_changed = self.config.endpoint != config.endpoint; @@ -447,6 +449,7 @@ impl App { { self._endpoint = None; } + #[cfg(target_os = "macos")] let pending_endpoint = if config.endpoint.enabled && (endpoint_changed || self._endpoint.is_none()) { let Some(generation) = &self.generation_service else { diff --git a/src/dsml.rs b/src/dsml.rs new file mode 100644 index 0000000..dd2cd98 --- /dev/null +++ b/src/dsml.rs @@ -0,0 +1,210 @@ +use serde_json::{Map, Value}; + +#[derive(Clone, Copy)] +pub(crate) struct Syntax { + pub(crate) tool_start: &'static str, + pub(crate) tool_end: &'static str, + pub(crate) invoke_start: &'static str, + pub(crate) invoke_end: &'static str, + pub(crate) parameter_start: &'static str, + pub(crate) parameter_end: &'static str, +} + +pub(crate) const SYNTAXES: [Syntax; 3] = [ + Syntax { + tool_start: "<|DSML|tool_calls>", + tool_end: "", + invoke_start: "<|DSML|invoke", + invoke_end: "", + parameter_start: "<|DSML|parameter", + parameter_end: "", + }, + Syntax { + tool_start: "", + tool_end: "", + invoke_start: "", + parameter_start: "", + }, + Syntax { + tool_start: "", + tool_end: "", + invoke_start: "", + parameter_start: "", + }, +]; + +pub(crate) fn parse_tool_calls(text: &str) -> Result<(String, Vec<(String, Value)>), String> { + let Some((start, syntax)) = SYNTAXES + .iter() + .filter_map(|syntax| text.find(syntax.tool_start).map(|start| (start, *syntax))) + .min_by_key(|(start, _)| *start) + else { + return Ok((text.to_owned(), Vec::new())); + }; + let Some(relative_end) = text[start..].find(syntax.tool_end) else { + return Err("invalid or incomplete DSML tool call".into()); + }; + let end = start + relative_end + syntax.tool_end.len(); + let content = text[..start].trim_end().to_owned(); + let raw = &text[start..end]; + let mut cursor = syntax.tool_start.len(); + let mut calls = Vec::new(); + loop { + skip_whitespace(raw, &mut cursor); + if raw[cursor..].starts_with(syntax.tool_end) { + break; + } + if !raw[cursor..].starts_with(syntax.invoke_start) { + return Err("invalid or incomplete DSML tool call".into()); + } + let tag_end = raw[cursor..] + .find('>') + .map(|offset| cursor + offset + 1) + .ok_or_else(|| "invalid or incomplete DSML tool call".to_owned())?; + let name = attribute(&raw[cursor..tag_end], "name") + .ok_or_else(|| "invalid or incomplete DSML tool call".to_owned())?; + cursor = tag_end; + let mut arguments = Map::new(); + loop { + skip_whitespace(raw, &mut cursor); + if raw[cursor..].starts_with(syntax.invoke_end) { + cursor += syntax.invoke_end.len(); + break; + } + let (name, value) = parse_parameter(raw, &mut cursor, syntax)? + .ok_or_else(|| "invalid or incomplete DSML tool call".to_owned())?; + arguments.insert(name, value); + } + calls.push((name, Value::Object(arguments))); + } + if calls.is_empty() { + return Err("invalid or incomplete DSML tool call".into()); + } + Ok((content, calls)) +} + +fn parse_parameter( + text: &str, + cursor: &mut usize, + syntax: Syntax, +) -> Result, String> { + if !text[*cursor..].starts_with(syntax.parameter_start) { + return Ok(None); + } + let Some(tag_end) = text[*cursor..].find('>').map(|end| *cursor + end + 1) else { + return Ok(None); + }; + let tag = &text[*cursor..tag_end]; + let Some(name) = attribute(tag, "name") else { + return Ok(None); + }; + let is_string = attribute(tag, "string"); + *cursor = tag_end; + let mut nested_start = *cursor; + skip_whitespace(text, &mut nested_start); + if is_string.is_none() && text[nested_start..].starts_with(syntax.parameter_start) { + *cursor = nested_start; + let mut nested = Map::new(); + loop { + skip_whitespace(text, cursor); + if !text[*cursor..].starts_with(syntax.parameter_start) { + break; + } + let Some((name, value)) = parse_parameter(text, cursor, syntax)? else { + return Ok(None); + }; + nested.insert(name, value); + } + skip_whitespace(text, cursor); + if !text[*cursor..].starts_with(syntax.parameter_end) { + return Ok(None); + } + *cursor += syntax.parameter_end.len(); + return Ok(Some((name, Value::Object(nested)))); + } + let Some(value_end) = text[*cursor..] + .find(syntax.parameter_end) + .map(|end| *cursor + end) + else { + return Ok(None); + }; + let raw = &text[*cursor..value_end]; + *cursor = value_end + syntax.parameter_end.len(); + let value = if is_string.as_deref().unwrap_or("true") == "true" { + Value::String(unescape(raw)) + } else { + serde_json::from_str(raw) + .map_err(|error| format!("invalid DSML tool arguments: {error}"))? + }; + Ok(Some((name, value))) +} + +fn attribute(tag: &str, name: &str) -> Option { + let start = tag.find(&format!("{name}=\""))? + name.len() + 2; + let end = start + tag[start..].find('"')?; + Some(unescape(&tag[start..end])) +} + +fn skip_whitespace(text: &str, cursor: &mut usize) { + while text[*cursor..] + .chars() + .next() + .is_some_and(char::is_whitespace) + { + *cursor += text[*cursor..].chars().next().unwrap().len_utf8(); + } +} + +fn unescape(text: &str) -> String { + text.replace(""", "\"") + .replace(">", ">") + .replace("<", "<") + .replace("&", "&") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_reference_and_legacy_dsml_syntaxes() { + for (start, end, invoke, invoke_end, parameter, parameter_end) in [ + ( + "<|DSML|tool_calls>", + "", + "<|DSML|invoke", + "", + "<|DSML|parameter", + "", + ), + ( + "", + "", + "", + "", + ), + ( + "", + "", + "", + "", + ), + ] { + let raw = format!( + "done{start}{invoke} name=\"read\">{parameter} name=\"path\" string=\"true\">src/main.rs{parameter_end}{invoke_end}{end}" + ); + let (content, calls) = parse_tool_calls(&raw).unwrap(); + assert_eq!(content, "done"); + assert_eq!(calls[0].0, "read"); + assert_eq!(calls[0].1["path"], "src/main.rs"); + } + } +} diff --git a/src/engine.rs b/src/engine.rs index 4cff18e..00f3bf1 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -1,5 +1,5 @@ mod gguf; -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", test))] mod kvstore; #[cfg(target_os = "macos")] mod metal; @@ -9,6 +9,7 @@ mod validation; #[cfg(target_os = "macos")] use crate::metrics::{KvLookup, Metrics}; use crate::model::ModelChoice; +#[cfg(target_os = "macos")] use crate::settings::TurnSettings; use crate::settings::{EngineSettings, ReasoningMode}; use gguf::{F16, F32, Gguf, I32, IQ2_XXS, Q2_K, Q4_0, Q4_K, Q5_K, Q6_K, Q8_0, Tensor, Value}; @@ -17,9 +18,12 @@ use kvstore::{KvStore, StoreReason}; use sha2::{Digest, Sha256}; #[cfg(target_os = "macos")] use std::collections::HashMap; -use std::path::{Path, PathBuf}; +use std::path::Path; +#[cfg(target_os = "macos")] +use std::path::PathBuf; #[cfg(target_os = "macos")] use std::sync::Arc; +#[cfg(target_os = "macos")] use std::sync::atomic::{AtomicBool, Ordering}; #[cfg(target_os = "macos")] use std::time::Instant; @@ -1386,7 +1390,7 @@ impl Generator { } } -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", test))] fn append_generated_bytes( generated: &mut ChatTurn, reasoning: bool, @@ -1449,7 +1453,7 @@ fn flush_generated( ); } -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", test))] fn emit_safe_text( text: &mut String, emitted: &mut usize, @@ -1494,7 +1498,7 @@ fn emit_safe_text( false } -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", test))] fn conversation_key(system: &str, reasoning: ReasoningMode, messages: &[ChatTurn]) -> Vec { fn text(output: &mut Vec, value: &str) { output.extend_from_slice(&(value.len() as u64).to_le_bytes()); @@ -1526,12 +1530,12 @@ fn conversation_key(system: &str, reasoning: ReasoningMode, messages: &[ChatTurn output } -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", test))] fn conversation_tag(system: &str, reasoning: ReasoningMode, messages: &[ChatTurn]) -> [u8; 32] { Sha256::digest(conversation_key(system, reasoning, messages)).into() } -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", test))] fn checkpoint_matches_prefix( checkpoint: [u8; 32], system: &str, @@ -1555,7 +1559,7 @@ fn resident_key(directory: &Path, tag: [u8; 32]) -> PathBuf { directory.join("resident").join(name) } -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", test))] fn sample( logits: &[f32], temperature: f32, @@ -1631,10 +1635,10 @@ fn sample( probabilities.last().map_or(0, |(token, _)| *token as i32) } -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", test))] struct Rng(u64); -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", test))] impl Rng { fn new(seed: u64) -> Self { Self(seed.max(1)) @@ -1654,7 +1658,7 @@ impl Rng { } } -#[cfg(all(test, target_os = "macos"))] +#[cfg(test)] mod sampling_tests { use super::*; @@ -1835,6 +1839,7 @@ mod sampling_tests { } #[test] + #[cfg(target_os = "macos")] #[ignore = "requires the 80 GiB Flash checkpoint and Apple Metal"] fn metal_executes_real_flash_token() { configure_metal_sources().unwrap(); diff --git a/src/main.rs b/src/main.rs index 09209ee..e495110 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,5 @@ +#![cfg_attr(not(target_os = "macos"), allow(dead_code))] + mod a2ui; mod a2ui_validation; mod agent; @@ -6,6 +8,7 @@ mod compaction; mod config; mod database; mod dev_brain; +mod dsml; mod engine; mod metrics; mod model; diff --git a/src/server.rs b/src/server.rs index 9c39996..ccaf8f9 100644 --- a/src/server.rs +++ b/src/server.rs @@ -312,49 +312,6 @@ impl Drop for ConnectionSlot { } } -pub(crate) fn parse_dsml_tool_calls(text: &str) -> Result<(String, Vec<(String, Value)>), String> { - let has_tool_marker = TOOL_SYNTAXES - .iter() - .any(|syntax| text.contains(syntax.tool_start)); - let mut projector = ToolProjector::new(); - let events = projector.push(text, true, ""); - let mut content = String::new(); - let mut calls = Vec::<(String, String, bool)>::new(); - for event in events { - match event { - ToolProjectionEvent::Text(text) => content.push_str(&text), - ToolProjectionEvent::Start { index, name, .. } => { - if calls.len() == index { - calls.push((name, String::new(), false)); - } - } - ToolProjectionEvent::Arguments { index, fragment } => { - if let Some((_, arguments, _)) = calls.get_mut(index) { - arguments.push_str(&fragment); - } - } - ToolProjectionEvent::End { index } => { - if let Some((_, _, complete)) = calls.get_mut(index) { - *complete = true; - } - } - } - } - let calls = calls - .into_iter() - .filter(|(_, _, complete)| *complete) - .map(|(name, arguments, _)| { - serde_json::from_str(&arguments) - .map(|arguments| (name, arguments)) - .map_err(|error| format!("invalid DSML tool arguments: {error}")) - }) - .collect::, _>>()?; - if has_tool_marker && calls.is_empty() { - return Err("invalid or incomplete DSML tool call".into()); - } - Ok((content, calls)) -} - fn handle(mut stream: TcpStream, state: &State) { let _ = stream.set_write_timeout(Some(HTTP_IO_TIMEOUT)); let started = Instant::now(); diff --git a/src/server/tools.rs b/src/server/tools.rs index 199bfa8..a5f4143 100644 --- a/src/server/tools.rs +++ b/src/server/tools.rs @@ -1,4 +1,5 @@ use super::*; +pub(super) use crate::dsml::{SYNTAXES as TOOL_SYNTAXES, Syntax as ToolSyntax}; const TOOL_MEMORY_FILE: &str = "tool-replay.json"; const TOOL_MEMORY_MAX_IDS: usize = 100_000; @@ -698,43 +699,6 @@ fn repair_generated_tools(text: &str) -> Option { Some(repaired) } -#[derive(Clone, Copy)] -pub(super) struct ToolSyntax { - pub(super) tool_start: &'static str, - pub(super) tool_end: &'static str, - pub(super) invoke_start: &'static str, - pub(super) invoke_end: &'static str, - pub(super) parameter_start: &'static str, - pub(super) parameter_end: &'static str, -} - -pub(super) const TOOL_SYNTAXES: [ToolSyntax; 3] = [ - ToolSyntax { - tool_start: "<|DSML|tool_calls>", - tool_end: "", - invoke_start: "<|DSML|invoke", - invoke_end: "", - parameter_start: "<|DSML|parameter", - parameter_end: "", - }, - ToolSyntax { - tool_start: "", - tool_end: "", - invoke_start: "", - parameter_start: "", - }, - ToolSyntax { - tool_start: "", - tool_end: "", - invoke_start: "", - parameter_start: "", - }, -]; - fn parse_tool_parameter( text: &str, cursor: &mut usize,