Add persistent local agent tools

This commit is contained in:
Georg Bauer
2026-07-25 14:57:17 +02:00
parent 668d8b787e
commit 4a45735072
20 changed files with 3139 additions and 37 deletions

40
PLAN.md
View File

@@ -413,11 +413,14 @@ the same conversation without prefill when its KV payload is compatible.
## Phase 4 — agent chat and tools ## Phase 4 — agent chat and tools
Status: **basic local chat implemented**. The conversation area streams Status: **solid local agent base implemented**. The conversation area streams
multi-turn DeepSeek Flash output, persists and rehydrates structured reasoning multi-turn DeepSeek Flash output, persists and rehydrates structured reasoning
and answers, renders Markdown, follows new output, restores sessions at their and answers, renders Markdown, follows new output, restores sessions at their
latest message, reports context use and generation speed, and supports Stop. latest message, reports context use and generation speed, and supports Stop.
Tool turns and the coding-agent runtime remain open. DeepSeek DSML and GLM tool turns now execute off the UI thread, persist semantic
tool roles, resume the same checkpoint, and expose the complete `ds4_agent.c`
starting tool set. Context compaction and richer partial-tool presentation
remain open.
Every chat feature must persist its semantic state and rehydrate it when a Every chat feature must persist its semantic state and rehydrate it when a
session is reopened in the same implementation slice. In-memory-only chat session is reopened in the same implementation slice. In-memory-only chat
@@ -431,23 +434,27 @@ ephemeral presentation preferences are the only exception.
decode, HTTP, and KV telemetry; tool-call cards, queued input, and inline decode, HTTP, and KV telemetry; tool-call cards, queued input, and inline
chat-prefill progress remain. Local turns must call the same session registry chat-prefill progress remain. Local turns must call the same session registry
and KV path used by the HTTP endpoint. and KV path used by the HTTP endpoint.
2. Port the native agent turn loop from `ds4_agent.c`, including model-specific 2. **Implemented:** port the native agent turn loop from `ds4_agent.c`, including
system prompts and DeepSeek DSML/GLM tool-call parsing. model-specific system prompts and DeepSeek DSML/GLM tool-call parsing.
3. Implement the local tool set with the project directory as its boundary: 3. **Implemented starting set:** implement the local tool set with the project directory as its boundary:
- shell command execution - shell command execution
- file read and continuation - file read and continuation
- file write - file write
- anchored edit - anchored edit
- text/file search - text/file search
- opt-in Dev Brain knowledge lookup and memory storage - directory listing
4. Stream assistant tokens and partial tool calls into Iced while inference and - asynchronous shell status/stop
tools run on workers. Preserve cooperative interruption and a valid KV - approved visible-Chrome Google search and rendered page visits
prefix at every stop point. - opt-in Dev Brain knowledge lookup and memory storage remains separate
5. Require confirmation for commands or writes outside the project boundary, 4. **Partially implemented:** assistant tokens and compact completed tool calls
destructive actions, and network tools. Add web search/page visiting only stream into Iced while inference and tools run on workers. Cooperative Stop
after the local tool loop is stable. covers generation and tool work; richer partial-call cards remain.
6. Port context compaction and system-prompt reinjection after ordinary 5. **Partially implemented:** file tools enforce the project boundary and web
multi-turn operation is correct. tools require visible-browser approval. Destructive shell-command approval
remains before broader distribution.
6. The full tool system prompt is derived into every local generation, including
reopened sessions. Port context compaction before prompts approach their
configured context limit.
### Native macOS menus (required with chat) ### Native macOS menus (required with chat)
@@ -594,8 +601,9 @@ notarization, and delivery remain open.
then porting every parser/route/state machine and its fixtures. Keep HTTP then porting every parser/route/state machine and its fixtures. Keep HTTP
conversation state transient except for opaque KV cache files. **In conversation state transient except for opaque KV cache files. **In
progress; no route is considered complete until differential tests pass.** progress; no route is considered complete until differential tests pass.**
3. Local transcript/KV persistence and the conversation UI are implemented; 3. Local transcript/KV persistence, the conversation UI, and the native agent
port the agent tools after the shared endpoint lifecycle is stable. starting tool set are implemented. Add context compaction and richer
partial-tool surfaces next.
4. Add the opt-in Dev Brain tool after local file/search boundaries and agent 4. Add the opt-in Dev Brain tool after local file/search boundaries and agent
approvals are stable. approvals are stable.
5. Add bDS2-style A2UI render tools and native inline surfaces after the core 5. Add bDS2-style A2UI render tools and native inline surfaces after the core

View File

@@ -4,9 +4,11 @@ DS4Server is a native macOS coding-agent application that rewrites the
DwarfStar (`ds4`) inference engine in Rust. It uses Rust and Iced and will combine local model loading, an DwarfStar (`ds4`) inference engine in Rust. It uses Rust and Iced and will combine local model loading, an
OpenAI-compatible localhost endpoint, and project-scoped agent chat in one app. OpenAI-compatible localhost endpoint, and project-scoped agent chat in one app.
DS4Server vendors and adapts the Metal kernels and Objective-C Metal glue from DS4Server vendors and adapts the Metal kernels, Objective-C Metal glue, and
visible-Chrome web tool runtime from
[DwarfStar (`ds4`)](https://github.com/antirez/ds4). Their copyright and license [DwarfStar (`ds4`)](https://github.com/antirez/ds4). Their copyright and license
notices are retained in [`native/metal/LICENSE`](native/metal/LICENSE). notices are retained in [`native/metal/LICENSE`](native/metal/LICENSE) and
[`native/web/LICENSE`](native/web/LICENSE).
The current milestone provides a Codex-inspired project/session layout. A native The current milestone provides a Codex-inspired project/session layout. A native
macOS folder picker selects each workspace, then the app asks for its display macOS folder picker selects each workspace, then the app asks for its display
@@ -29,8 +31,16 @@ follow-up turns reuse durable transcript and KV state, and the model unloads
after the idle timeout. The graph uses the full configured context with the after the idle timeout. The graph uses the full configured context with the
ratio-4 sparse indexer. ratio-4 sparse indexer.
The app also listens on `127.0.0.1:4000` by default for `GET /v1/models` and Project chat includes the native `ds4_agent.c` starting tool set: bounded file
`POST /v1/chat/completions`; the port is configurable in Preferences. The read/continuation, write, anchored edit, search, directory listing, asynchronous
shell jobs, Google search, and rendered page visits. Tool calls and results are
persisted as transcript roles and automatically continue the same model turn.
File tools stay inside the selected project. Web tools ask before starting a
visible Chrome profile.
The app also listens on `127.0.0.1:4000` by default for Models, Chat
Completions, Completions, Anthropic Messages, and Responses APIs. The listener,
port, and opt-in CORS are configurable in Preferences. The
endpoint and local chat share the single model owner. External conversations endpoint and local chat share the single model owner. External conversations
are client-managed and never enter the project, session, message, or transcript are client-managed and never enter the project, session, message, or transcript
database; only opaque content-addressed KV cache files are retained. Model database; only opaque content-addressed KV cache files are retained. Model

View File

@@ -13,6 +13,12 @@ fn main() {
.flag("-fobjc-arc") .flag("-fobjc-arc")
.opt_level(3) .opt_level(3)
.compile("ds4_metal"); .compile("ds4_metal");
println!("cargo:rerun-if-changed=native/web");
cc::Build::new()
.include("native/web")
.file("native/web/ds4_web.c")
.opt_level(2)
.compile("ds4_web");
println!("cargo:rustc-link-lib=framework=Foundation"); println!("cargo:rustc-link-lib=framework=Foundation");
println!("cargo:rustc-link-lib=framework=Metal"); println!("cargo:rustc-link-lib=framework=Metal");
} }

View File

@@ -0,0 +1 @@
ALTER TABLE messages DROP COLUMN tool;

View File

@@ -0,0 +1,2 @@
ALTER TABLE messages ADD COLUMN tool BOOLEAN NOT NULL DEFAULT FALSE
CHECK (tool IN (FALSE, TRUE));

22
native/web/LICENSE Normal file
View File

@@ -0,0 +1,22 @@
MIT License
Copyright (c) 2026 The ds4.c authors
Copyright (c) 2023-2026 The ggml authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

1385
native/web/ds4_web.c Normal file

File diff suppressed because it is too large Load Diff

33
native/web/ds4_web.h Normal file
View File

@@ -0,0 +1,33 @@
#ifndef DS4_WEB_H
#define DS4_WEB_H
#include <stddef.h>
#include <stdbool.h>
typedef int (*ds4_web_confirm_fn)(void *privdata, const char *message,
char *err, size_t err_len);
typedef void (*ds4_web_log_fn)(void *privdata, const char *message);
typedef bool (*ds4_web_cancel_fn)(void *privdata);
typedef struct {
const char *home_dir;
int port;
ds4_web_confirm_fn confirm;
void *confirm_privdata;
ds4_web_log_fn log;
void *log_privdata;
ds4_web_cancel_fn cancel;
void *cancel_privdata;
} ds4_web_config;
typedef struct ds4_web ds4_web;
ds4_web *ds4_web_create(const ds4_web_config *cfg);
void ds4_web_free(ds4_web *web);
char *ds4_web_google_search(ds4_web *web, const char *query,
char *err, size_t err_len);
char *ds4_web_visit_page(ds4_web *web, const char *url,
char *err, size_t err_len);
#endif

1309
src/agent.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -32,7 +32,7 @@ use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::mpsc::{self, TryRecvError}; use std::sync::mpsc::{self, TryRecvError};
use std::sync::{Arc, RwLock}; use std::sync::{Arc, Mutex, RwLock};
use std::thread; use std::thread;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
@@ -84,6 +84,10 @@ pub(crate) struct App {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
active_generation: Option<ActiveGeneration>, active_generation: Option<ActiveGeneration>,
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
agent_tools: Option<(i32, Arc<Mutex<crate::agent::Tools>>)>,
#[cfg(target_os = "macos")]
active_tools: Option<crate::agent::ActiveTools>,
#[cfg(target_os = "macos")]
active_titling: Option<generation::TitleRequest>, active_titling: Option<generation::TitleRequest>,
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
runtime_preferences: Arc<RwLock<AppPreferences>>, runtime_preferences: Arc<RwLock<AppPreferences>>,
@@ -249,6 +253,10 @@ impl App {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
active_generation: None, active_generation: None,
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
agent_tools: None,
#[cfg(target_os = "macos")]
active_tools: None,
#[cfg(target_os = "macos")]
active_titling: None, active_titling: None,
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
runtime_preferences, runtime_preferences,
@@ -326,6 +334,10 @@ impl App {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
active_generation: None, active_generation: None,
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
agent_tools: None,
#[cfg(target_os = "macos")]
active_tools: None,
#[cfg(target_os = "macos")]
active_titling: None, active_titling: None,
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
runtime_preferences, runtime_preferences,
@@ -643,12 +655,15 @@ impl App {
self.start_generation(); self.start_generation();
return scroll_chat_to_end(); return scroll_chat_to_end();
} }
Message::StopGeneration => Message::StopGeneration => {
{
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
if let Some(active) = &self.active_generation { if let Some(active) = &self.active_generation {
active.cancel.store(true, Ordering::Relaxed); active.cancel.store(true, Ordering::Relaxed);
} }
#[cfg(target_os = "macos")]
if let Some(active) = &self.active_tools {
active.cancel.store(true, Ordering::Relaxed);
}
} }
Message::GenerationTick => { Message::GenerationTick => {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
@@ -1007,6 +1022,10 @@ impl Drop for App {
if let Some(active) = &self.active_generation { if let Some(active) = &self.active_generation {
active.cancel.store(true, Ordering::Relaxed); active.cancel.store(true, Ordering::Relaxed);
} }
#[cfg(target_os = "macos")]
if let Some(active) = &self.active_tools {
active.cancel.store(true, Ordering::Relaxed);
}
} }
} }
@@ -1130,6 +1149,7 @@ mod tests {
let mut message = ChatMessage { let mut message = ChatMessage {
id: 1, id: 1,
user: false, user: false,
tool: false,
reasoning: Some(String::new()), reasoning: Some(String::new()),
reasoning_complete: false, reasoning_complete: false,
reasoning_open: true, reasoning_open: true,

View File

@@ -22,6 +22,7 @@ pub(super) struct TitleRequest {
pub(crate) struct ChatMessage { pub(crate) struct ChatMessage {
pub(super) id: i32, pub(super) id: i32,
pub(super) user: bool, pub(super) user: bool,
pub(super) tool: bool,
pub(super) reasoning: Option<String>, pub(super) reasoning: Option<String>,
pub(super) reasoning_complete: bool, pub(super) reasoning_complete: bool,
pub(super) reasoning_open: bool, pub(super) reasoning_open: bool,
@@ -40,11 +41,12 @@ impl ChatMessage {
} }
pub(super) fn refresh_markdown(&mut self) { pub(super) fn refresh_markdown(&mut self) {
if !self.user { if !self.user && !self.tool {
let visible = crate::agent::visible_content(&self.content);
let content = if self.reasoning.is_some() { let content = if self.reasoning.is_some() {
self.content.trim_start() visible.trim_start()
} else { } else {
&self.content visible
}; };
self.markdown = markdown::parse(content).collect(); self.markdown = markdown::parse(content).collect();
} }
@@ -56,6 +58,7 @@ impl From<StoredMessage> for ChatMessage {
let mut message = Self { let mut message = Self {
id: message.id, id: message.id,
user: message.user, user: message.user,
tool: message.tool,
reasoning: message.reasoning, reasoning: message.reasoning,
reasoning_complete: message.reasoning_complete, reasoning_complete: message.reasoning_complete,
reasoning_open: false, reasoning_open: false,
@@ -88,13 +91,15 @@ impl App {
crate::settings::effective_settings(model, &generation, &runtime, &models_path()) crate::settings::effective_settings(model, &generation, &runtime, &models_path())
}) })
}); });
let effective = match effective { let mut effective = match effective {
Ok(settings) => settings, Ok(settings) => settings,
Err(error) => { Err(error) => {
self.error = Some(error); self.error = Some(error);
return; return;
} }
}; };
effective.turn.system_prompt =
crate::agent::system_prompt(model, &effective.turn.system_prompt);
let assistant_reasoning = effective.turn.reasoning_mode != ReasoningMode::Direct; let assistant_reasoning = effective.turn.reasoning_mode != ReasoningMode::Direct;
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
let mut messages = self let mut messages = self
@@ -102,6 +107,7 @@ impl App {
.iter() .iter()
.map(|message| ChatTurn { .map(|message| ChatTurn {
user: message.user, user: message.user,
tool: message.tool,
skip_previous_eos: false, skip_previous_eos: false,
reasoning: message.reasoning.clone(), reasoning: message.reasoning.clone(),
reasoning_complete: message.reasoning_complete, reasoning_complete: message.reasoning_complete,
@@ -111,6 +117,7 @@ impl App {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
messages.push(ChatTurn { messages.push(ChatTurn {
user: true, user: true,
tool: false,
skip_previous_eos: false, skip_previous_eos: false,
reasoning: None, reasoning: None,
reasoning_complete: true, reasoning_complete: true,
@@ -189,6 +196,31 @@ impl App {
} }
pub(super) fn poll_generation(&mut self) -> bool { pub(super) fn poll_generation(&mut self) -> bool {
#[cfg(target_os = "macos")]
if let Some(active) = &self.active_tools {
match crate::agent::try_tool_result(active) {
Ok(Some(result)) => {
let cancelled = active.cancel.load(Ordering::Relaxed);
self.active_tools = None;
if cancelled {
self.generating = false;
return false;
}
if let Err(error) = self.continue_after_tool_result(&result) {
self.generating = false;
self.error = Some(error);
}
return true;
}
Ok(None) => return false,
Err(error) => {
self.active_tools = None;
self.generating = false;
self.error = Some(error);
return false;
}
}
}
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
let Some(active) = &mut self.active_generation else { let Some(active) = &mut self.active_generation else {
self.generating = false; self.generating = false;
@@ -221,9 +253,32 @@ impl App {
context_changed = true; context_changed = true;
} }
Ok(GenerationEvent::Finished(result)) => { Ok(GenerationEvent::Finished(result)) => {
self.generating = false; match result {
if let Err(error) = result { Ok(_) => {
self.error = Some(error); let model = ModelChoice::from_id(&self.preferences.selected_model)
.unwrap_or_default();
let content = self
.conversation
.last()
.map(|message| message.content.clone())
.unwrap_or_default();
match crate::agent::parse_tool_calls(model, &content) {
Ok((_, calls)) if !calls.is_empty() => {
if let Err(error) = self.start_agent_tools(calls) {
self.generating = false;
self.error = Some(error);
}
}
Ok(_) => self.generating = false,
Err(error) => {
self.active_tools = Some(crate::agent::error_async(error));
}
}
}
Err(error) => {
self.generating = false;
self.error = Some(error);
}
} }
self.active_generation = None; self.active_generation = None;
break; break;
@@ -286,6 +341,83 @@ impl App {
false false
} }
#[cfg(target_os = "macos")]
fn start_agent_tools(&mut self, calls: Vec<crate::agent::ToolCall>) -> Result<(), String> {
let session_id = self
.selected_session
.ok_or_else(|| "The active session is unavailable.".to_owned())?;
if self.agent_tools.as_ref().map(|(id, _)| *id) != Some(session_id) {
let project_id = self
.selected_project
.ok_or_else(|| "The active project is unavailable.".to_owned())?;
let root = self
.projects
.iter()
.find(|project| project.project.id == project_id)
.map(|project| PathBuf::from(&project.project.path))
.ok_or_else(|| "The active project is unavailable.".to_owned())?;
let tools = crate::agent::Tools::new(&root, self.preferences.context_tokens)?;
self.agent_tools = Some((session_id, Arc::new(Mutex::new(tools))));
}
let tools = Arc::clone(&self.agent_tools.as_ref().unwrap().1);
self.active_tools = Some(crate::agent::execute_async(tools, calls));
Ok(())
}
#[cfg(target_os = "macos")]
fn continue_after_tool_result(&mut self, result: &str) -> Result<(), String> {
let session_id = self
.selected_session
.ok_or_else(|| "The active session is unavailable.".to_owned())?;
let model = ModelChoice::from_id(&self.preferences.selected_model)
.ok_or_else(|| "The selected model is not supported.".to_owned())?;
let generation = self.preferences.generation()?;
let runtime = self.preferences.runtime()?;
let mut effective =
crate::settings::effective_settings(model, &generation, &runtime, &models_path())?;
effective.turn.system_prompt =
crate::agent::system_prompt(model, &effective.turn.system_prompt);
let assistant_reasoning = effective.turn.reasoning_mode != ReasoningMode::Direct;
let saved = self
.database
.as_mut()
.ok_or_else(|| "The project database is unavailable.".to_owned())?
.continue_tool_turn(session_id, result, assistant_reasoning)
.map_err(|error| format!("Could not save the tool turn: {error}"))?;
self.conversation.push(ChatMessage::from(saved.0));
let messages = self
.conversation
.iter()
.map(|message| ChatTurn {
user: message.user,
tool: message.tool,
skip_previous_eos: false,
reasoning: message.reasoning.clone(),
reasoning_complete: message.reasoning_complete,
content: message.content.clone(),
})
.collect();
let mut assistant = ChatMessage::from(saved.1);
assistant.reasoning_open = assistant_reasoning;
self.conversation.push(assistant);
let idle_timeout =
Duration::from_secs(self.preferences.idle_timeout_minutes.max(1) as u64 * 60);
self.active_generation = Some(
self.generation_service
.as_ref()
.ok_or_else(|| "The model runtime is unavailable.".to_owned())?
.generate(
effective.engine,
effective.turn,
messages,
CheckpointTarget::Local(session_checkpoint_path(session_id)),
idle_timeout,
)?,
);
self.tokens_per_second = None;
Ok(())
}
/// Asks the model for a session title without opening a session for it: the /// Asks the model for a session title without opening a session for it: the
/// turn runs against the shared transient KV cache and only its text is kept. /// turn runs against the shared transient KV cache and only its text is kept.
/// ///
@@ -318,6 +450,7 @@ impl App {
.filter(|message| !message.content.trim().is_empty()) .filter(|message| !message.content.trim().is_empty())
.map(|message| ChatTurn { .map(|message| ChatTurn {
user: message.user, user: message.user,
tool: message.tool,
skip_previous_eos: false, skip_previous_eos: false,
reasoning: None, reasoning: None,
reasoning_complete: true, reasoning_complete: true,
@@ -329,6 +462,7 @@ impl App {
} }
messages.push(ChatTurn { messages.push(ChatTurn {
user: true, user: true,
tool: false,
skip_previous_eos: false, skip_previous_eos: false,
reasoning: None, reasoning: None,
reasoning_complete: true, reasoning_complete: true,

View File

@@ -57,7 +57,13 @@ impl App {
} else { } else {
let markdown_style = markdown::Style::from_palette(app_theme().palette()); let markdown_style = markdown::Style::from_palette(app_theme().palette());
for (index, message) in self.conversation.iter().enumerate() { for (index, message) in self.conversation.iter().enumerate() {
let label = if message.user { "You" } else { "DS4" }; let label = if message.user {
"You"
} else if message.tool {
"Tool"
} else {
"DS4"
};
let active = self.generating && index + 1 == self.conversation.len(); let active = self.generating && index + 1 == self.conversation.len();
let mut body = column![text(label).size(11)].spacing(5); let mut body = column![text(label).size(11)].spacing(5);
if let Some(reasoning) = &message.reasoning { if let Some(reasoning) = &message.reasoning {
@@ -89,11 +95,11 @@ impl App {
} }
} }
if !message.content.is_empty() { if !message.content.is_empty() {
if message.user || message.markdown.is_empty() { if message.user || message.tool || message.markdown.is_empty() {
let content = if message.reasoning.is_some() { let content = if message.reasoning.is_some() {
message.content.trim_start() crate::agent::visible_content(&message.content).trim_start()
} else { } else {
&message.content crate::agent::visible_content(&message.content)
}; };
body = body.push(text(content).size(14)); body = body.push(text(content).size(14));
} else { } else {
@@ -109,6 +115,14 @@ impl App {
} else if active && message.reasoning.is_none() { } else if active && message.reasoning.is_none() {
body = body.push(text("Loading model…").size(14)); body = body.push(text("Loading model…").size(14));
} }
if !message.user
&& !message.tool
&& let Some(model) = ModelChoice::from_id(&self.preferences.selected_model)
{
for summary in crate::agent::tool_summaries(model, &message.content) {
body = body.push(text(summary).size(13).color(muted_text()));
}
}
let user = message.user; let user = message.user;
messages = messages.push( messages = messages.push(
container(body) container(body)

View File

@@ -347,6 +347,7 @@ pub struct StoredMessage {
pub id: i32, pub id: i32,
pub session_id: i32, pub session_id: i32,
pub user: bool, pub user: bool,
pub tool: bool,
pub reasoning: Option<String>, pub reasoning: Option<String>,
pub reasoning_complete: bool, pub reasoning_complete: bool,
pub content: String, pub content: String,
@@ -357,6 +358,7 @@ pub struct StoredMessage {
struct NewMessage<'a> { struct NewMessage<'a> {
session_id: i32, session_id: i32,
user: bool, user: bool,
tool: bool,
reasoning: Option<&'a str>, reasoning: Option<&'a str>,
reasoning_complete: bool, reasoning_complete: bool,
content: &'a str, content: &'a str,
@@ -621,6 +623,7 @@ impl Database {
.values(NewMessage { .values(NewMessage {
session_id, session_id,
user: true, user: true,
tool: false,
reasoning: None, reasoning: None,
reasoning_complete: true, reasoning_complete: true,
content: prompt, content: prompt,
@@ -631,6 +634,7 @@ impl Database {
.values(NewMessage { .values(NewMessage {
session_id, session_id,
user: false, user: false,
tool: false,
reasoning: reasoning.then_some(""), reasoning: reasoning.then_some(""),
reasoning_complete: !reasoning, reasoning_complete: !reasoning,
content: "", content: "",
@@ -642,6 +646,41 @@ impl Database {
.map_err(|error: diesel::result::Error| error.to_string()) .map_err(|error: diesel::result::Error| error.to_string())
} }
pub fn continue_tool_turn(
&mut self,
session_id: i32,
result: &str,
reasoning: bool,
) -> Result<(StoredMessage, StoredMessage), String> {
self.connection
.transaction(|connection| {
let tool = diesel::insert_into(messages::table)
.values(NewMessage {
session_id,
user: false,
tool: true,
reasoning: None,
reasoning_complete: true,
content: result,
})
.returning(StoredMessage::as_returning())
.get_result(connection)?;
let assistant = diesel::insert_into(messages::table)
.values(NewMessage {
session_id,
user: false,
tool: false,
reasoning: reasoning.then_some(""),
reasoning_complete: !reasoning,
content: "",
})
.returning(StoredMessage::as_returning())
.get_result(connection)?;
Ok((tool, assistant))
})
.map_err(|error: diesel::result::Error| error.to_string())
}
pub fn update_message( pub fn update_message(
&mut self, &mut self,
id: i32, id: i32,
@@ -832,6 +871,9 @@ mod tests {
database database
.update_message(assistant.id, Some("Reasoning"), true, "Answer") .update_message(assistant.id, Some("Reasoning"), true, "Answer")
.unwrap(); .unwrap();
database
.continue_tool_turn(session.id, "Tool result", false)
.unwrap();
database database
.update_session_context(session.id, 1_234, 65_536, Some(12.5)) .update_session_context(session.id, 1_234, 65_536, Some(12.5))
.unwrap(); .unwrap();
@@ -843,12 +885,17 @@ mod tests {
assert_eq!(projects[0].sessions[0].context_limit, 65_536); assert_eq!(projects[0].sessions[0].context_limit, 65_536);
assert_eq!(projects[0].sessions[0].last_tokens_per_second, Some(12.5)); assert_eq!(projects[0].sessions[0].last_tokens_per_second, Some(12.5));
let messages = reopened.load_messages(session.id).unwrap(); let messages = reopened.load_messages(session.id).unwrap();
assert_eq!(messages.len(), 2); assert_eq!(messages.len(), 4);
assert!(messages[0].user); assert!(messages[0].user);
assert!(!messages[0].tool);
assert_eq!(messages[0].content, "Question"); assert_eq!(messages[0].content, "Question");
assert_eq!(messages[1].reasoning.as_deref(), Some("Reasoning")); assert_eq!(messages[1].reasoning.as_deref(), Some("Reasoning"));
assert!(messages[1].reasoning_complete); assert!(messages[1].reasoning_complete);
assert_eq!(messages[1].content, "Answer"); assert_eq!(messages[1].content, "Answer");
assert!(messages[2].tool);
assert_eq!(messages[2].content, "Tool result");
assert!(!messages[3].user);
assert!(!messages[3].tool);
reopened.delete_session(session.id).unwrap(); reopened.delete_session(session.id).unwrap();
assert!(reopened.load_messages(session.id).unwrap().is_empty()); assert!(reopened.load_messages(session.id).unwrap().is_empty());
drop(reopened); drop(reopened);

View File

@@ -319,6 +319,7 @@ pub(crate) struct Generator {
#[derive(Clone)] #[derive(Clone)]
pub(crate) struct ChatTurn { pub(crate) struct ChatTurn {
pub(crate) user: bool, pub(crate) user: bool,
pub(crate) tool: bool,
pub(crate) skip_previous_eos: bool, pub(crate) skip_previous_eos: bool,
pub(crate) reasoning: Option<String>, pub(crate) reasoning: Option<String>,
pub(crate) reasoning_complete: bool, pub(crate) reasoning_complete: bool,
@@ -571,6 +572,7 @@ impl Generator {
let mut reasoning = settings.reasoning_mode != ReasoningMode::Direct; let mut reasoning = settings.reasoning_mode != ReasoningMode::Direct;
let mut generated = ChatTurn { let mut generated = ChatTurn {
user: false, user: false,
tool: false,
skip_previous_eos: false, skip_previous_eos: false,
reasoning: reasoning.then(String::new), reasoning: reasoning.then(String::new),
reasoning_complete: !reasoning, reasoning_complete: !reasoning,
@@ -896,7 +898,7 @@ fn conversation_key(system: &str, reasoning: ReasoningMode, messages: &[ChatTurn
output.extend_from_slice(value.as_bytes()); output.extend_from_slice(value.as_bytes());
} }
let mut output = b"DS4Server chat checkpoint v2".to_vec(); let mut output = b"DS4Server chat checkpoint v3".to_vec();
text(&mut output, system); text(&mut output, system);
output.push(match reasoning { output.push(match reasoning {
ReasoningMode::Direct => 0, ReasoningMode::Direct => 0,
@@ -905,6 +907,7 @@ fn conversation_key(system: &str, reasoning: ReasoningMode, messages: &[ChatTurn
}); });
for message in messages { for message in messages {
output.push(u8::from(message.user)); output.push(u8::from(message.user));
output.push(u8::from(message.tool));
output.push(u8::from(message.skip_previous_eos)); output.push(u8::from(message.skip_previous_eos));
match &message.reasoning { match &message.reasoning {
Some(reasoning) => { Some(reasoning) => {
@@ -1057,6 +1060,7 @@ mod sampling_tests {
fn split_utf8_token_bytes_are_joined_before_decoding() { fn split_utf8_token_bytes_are_joined_before_decoding() {
let mut generated = ChatTurn { let mut generated = ChatTurn {
user: false, user: false,
tool: false,
skip_previous_eos: false, skip_previous_eos: false,
reasoning: None, reasoning: None,
reasoning_complete: true, reasoning_complete: true,
@@ -1076,6 +1080,7 @@ mod sampling_tests {
fn checkpoint_tag_covers_the_canonical_chat_state() { fn checkpoint_tag_covers_the_canonical_chat_state() {
let mut messages = vec![ChatTurn { let mut messages = vec![ChatTurn {
user: true, user: true,
tool: false,
skip_previous_eos: false, skip_previous_eos: false,
reasoning: None, reasoning: None,
reasoning_complete: true, reasoning_complete: true,
@@ -1102,6 +1107,7 @@ mod sampling_tests {
let prefix = conversation_key("System", ReasoningMode::High, &messages); let prefix = conversation_key("System", ReasoningMode::High, &messages);
messages.push(ChatTurn { messages.push(ChatTurn {
user: false, user: false,
tool: false,
skip_previous_eos: false, skip_previous_eos: false,
reasoning: Some("because".into()), reasoning: Some("because".into()),
reasoning_complete: true, reasoning_complete: true,

View File

@@ -195,6 +195,7 @@ impl Tokenizer {
system_prompt, system_prompt,
&[ChatTurn { &[ChatTurn {
user: true, user: true,
tool: false,
skip_previous_eos: false, skip_previous_eos: false,
reasoning: None, reasoning: None,
reasoning_complete: true, reasoning_complete: true,
@@ -235,6 +236,32 @@ impl Tokenizer {
output.extend(self.tokenize_rendered(system_prompt)); output.extend(self.tokenize_rendered(system_prompt));
} }
for message in messages { for message in messages {
if message.tool {
if self.family == ModelFamily::Glm {
output.push(self.observation);
output.extend(self.tokenize_rendered("<tool_response>"));
output.extend(
self.tokenize_rendered(
&message
.content
.replace("</tool_response>", "&lt;/tool_response>"),
),
);
output.extend(self.tokenize_rendered("</tool_response>"));
} else {
output.push(self.user);
output.extend(self.tokenize("<tool_result>"));
output.extend(
self.tokenize(
&message
.content
.replace("</tool_result>", "&lt;/tool_result>"),
),
);
output.extend(self.tokenize("</tool_result>"));
}
continue;
}
output.push(if message.user { output.push(if message.user {
self.user self.user
} else { } else {

View File

@@ -904,6 +904,7 @@ mod tests {
&[ &[
ChatTurn { ChatTurn {
user: true, user: true,
tool: false,
skip_previous_eos: false, skip_previous_eos: false,
reasoning: None, reasoning: None,
reasoning_complete: true, reasoning_complete: true,
@@ -911,6 +912,7 @@ mod tests {
}, },
ChatTurn { ChatTurn {
user: false, user: false,
tool: false,
skip_previous_eos: false, skip_previous_eos: false,
reasoning: None, reasoning: None,
reasoning_complete: true, reasoning_complete: true,
@@ -918,6 +920,7 @@ mod tests {
}, },
ChatTurn { ChatTurn {
user: true, user: true,
tool: false,
skip_previous_eos: false, skip_previous_eos: false,
reasoning: None, reasoning: None,
reasoning_complete: true, reasoning_complete: true,
@@ -938,12 +941,33 @@ mod tests {
model.render_continuation("Hello", ReasoningMode::Direct, true), model.render_continuation("Hello", ReasoningMode::Direct, true),
[128_803, 19_923, 128_804, 128_822] [128_803, 19_923, 128_804, 128_822]
); );
let tool_turn = ChatTurn {
user: false,
tool: true,
skip_previous_eos: false,
reasoning: None,
reasoning_complete: true,
content: "Hello".into(),
};
let wrapped_user = ChatTurn {
user: true,
tool: false,
skip_previous_eos: false,
reasoning: None,
reasoning_complete: true,
content: "<tool_result>Hello</tool_result>".into(),
};
assert_eq!(
model.render_conversation("", &[tool_turn], ReasoningMode::Direct),
model.render_conversation("", &[wrapped_user], ReasoningMode::Direct)
);
assert_eq!( assert_eq!(
model.render_conversation( model.render_conversation(
"", "",
&[ &[
ChatTurn { ChatTurn {
user: true, user: true,
tool: false,
skip_previous_eos: false, skip_previous_eos: false,
reasoning: None, reasoning: None,
reasoning_complete: true, reasoning_complete: true,
@@ -951,6 +975,7 @@ mod tests {
}, },
ChatTurn { ChatTurn {
user: false, user: false,
tool: false,
skip_previous_eos: false, skip_previous_eos: false,
reasoning: Some("Hello".into()), reasoning: Some("Hello".into()),
reasoning_complete: true, reasoning_complete: true,
@@ -958,6 +983,7 @@ mod tests {
}, },
ChatTurn { ChatTurn {
user: true, user: true,
tool: false,
skip_previous_eos: false, skip_previous_eos: false,
reasoning: None, reasoning: None,
reasoning_complete: true, reasoning_complete: true,

View File

@@ -1,3 +1,4 @@
mod agent;
mod app; mod app;
mod database; mod database;
mod engine; mod engine;

View File

@@ -3,6 +3,7 @@ diesel::table! {
id -> Integer, id -> Integer,
session_id -> Integer, session_id -> Integer,
user -> Bool, user -> Bool,
tool -> Bool,
reasoning -> Nullable<Text>, reasoning -> Nullable<Text>,
reasoning_complete -> Bool, reasoning_complete -> Bool,
content -> Text, content -> Text,

View File

@@ -312,6 +312,49 @@ 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::<Result<Vec<_>, _>>()?;
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) { fn handle(mut stream: TcpStream, state: &State) {
let _ = stream.set_write_timeout(Some(HTTP_IO_TIMEOUT)); let _ = stream.set_write_timeout(Some(HTTP_IO_TIMEOUT));
let started = Instant::now(); let started = Instant::now();

View File

@@ -132,7 +132,11 @@ impl ToolProjector {
self.state = ToolProjectionState::Failed; self.state = ToolProjectionState::Failed;
break; break;
}; };
let id = random_tool_id(prefix); let id = if prefix.is_empty() {
String::new()
} else {
random_tool_id(prefix)
};
self.ids.push(id.clone()); self.ids.push(id.clone());
events.push(ToolProjectionEvent::Start { events.push(ToolProjectionEvent::Start {
index: self.index, index: self.index,
@@ -370,6 +374,7 @@ pub(super) fn render_messages(
} }
"user" => turns.push(ChatTurn { "user" => turns.push(ChatTurn {
user: true, user: true,
tool: false,
skip_previous_eos: false, skip_previous_eos: false,
reasoning: None, reasoning: None,
reasoning_complete: true, reasoning_complete: true,
@@ -388,6 +393,7 @@ pub(super) fn render_messages(
} else { } else {
turns.push(ChatTurn { turns.push(ChatTurn {
user: true, user: true,
tool: false,
skip_previous_eos: protocol == Protocol::Responses, skip_previous_eos: protocol == Protocol::Responses,
reasoning: None, reasoning: None,
reasoning_complete: true, reasoning_complete: true,
@@ -403,6 +409,7 @@ pub(super) fn render_messages(
let reasoning = content_text(&message.reasoning_content); let reasoning = content_text(&message.reasoning_content);
turns.push(ChatTurn { turns.push(ChatTurn {
user: false, user: false,
tool: false,
skip_previous_eos: false, skip_previous_eos: false,
reasoning: (preserve_reasoning && !reasoning.is_empty()).then_some(reasoning), reasoning: (preserve_reasoning && !reasoning.is_empty()).then_some(reasoning),
reasoning_complete: true, reasoning_complete: true,