Add persistent local agent tools
This commit is contained in:
40
PLAN.md
40
PLAN.md
@@ -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
|
||||||
|
|||||||
18
README.md
18
README.md
@@ -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
|
||||||
|
|||||||
6
build.rs
6
build.rs
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
1
migrations/20260725140000_add_tool_messages/down.sql
Normal file
1
migrations/20260725140000_add_tool_messages/down.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE messages DROP COLUMN tool;
|
||||||
2
migrations/20260725140000_add_tool_messages/up.sql
Normal file
2
migrations/20260725140000_add_tool_messages/up.sql
Normal 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
22
native/web/LICENSE
Normal 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
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
33
native/web/ds4_web.h
Normal 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
1309
src/agent.rs
Normal file
File diff suppressed because it is too large
Load Diff
26
src/app.rs
26
src/app.rs
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>", "</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>", "</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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
mod agent;
|
||||||
mod app;
|
mod app;
|
||||||
mod database;
|
mod database;
|
||||||
mod engine;
|
mod engine;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user