From 8843f731340120f32fdf799f83c40b82d1ece16c Mon Sep 17 00:00:00 2001 From: clearloop Date: Thu, 18 Dec 2025 17:09:17 +0800 Subject: [PATCH 01/18] chore(cli): introduce module agents --- crates/cli/src/agents/mod.rs | 0 crates/cli/src/lib.rs | 7 ++++--- 2 files changed, 4 insertions(+), 3 deletions(-) create mode 100644 crates/cli/src/agents/mod.rs diff --git a/crates/cli/src/agents/mod.rs b/crates/cli/src/agents/mod.rs new file mode 100644 index 0000000..e69de29 diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 5fcd4df..8ab3a79 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -1,12 +1,13 @@ //! CLI commands for ullm -mod chat; -mod config; - use clap::{Parser, Subcommand}; use tracing_subscriber::{EnvFilter, fmt}; pub use {chat::ChatCmd, config::Config}; +mod agents; +mod chat; +mod config; + /// Unified LLM Interface CLI #[derive(Debug, Parser)] #[command(name = "ullm", version, about)] From 9d0a18d2a30357386274620c08e377dfec38fd35 Mon Sep 17 00:00:00 2001 From: clearloop Date: Thu, 18 Dec 2025 18:00:47 +0800 Subject: [PATCH 02/18] chore(core): make tools as a function method --- Cargo.lock | 3 ++ crates/cli/Cargo.toml | 7 +++++ crates/cli/bin/ullm.rs | 2 +- crates/cli/src/agents/anto.rs | 59 +++++++++++++++++++++++++++++++++++ crates/cli/src/agents/mod.rs | 14 +++++++++ crates/cli/src/chat.rs | 35 +++++++++++++++++---- crates/core/src/agent.rs | 4 ++- crates/core/src/chat.rs | 2 +- crates/core/src/lib.rs | 2 +- crates/ullm/src/lib.rs | 5 ++- 10 files changed, 122 insertions(+), 11 deletions(-) create mode 100644 crates/cli/src/agents/anto.rs diff --git a/Cargo.lock b/Cargo.lock index 8a72edd..34206ed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2963,7 +2963,10 @@ dependencies = [ "clap", "dirs", "futures-util", + "schemars", "serde", + "serde_json", + "tokio", "toml", "tracing", "tracing-subscriber", diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index cf55045..d8d03e8 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -8,6 +8,10 @@ repository.workspace = true documentation.workspace = true keywords.workspace = true +[[bin]] +name = "ullm" +path = "bin/ullm.rs" + [dependencies] ullm.workspace = true @@ -18,5 +22,8 @@ dirs.workspace = true futures-util.workspace = true serde.workspace = true toml.workspace = true +schemars.workspace = true +serde_json.workspace = true +tokio.workspace = true tracing.workspace = true tracing-subscriber.workspace = true diff --git a/crates/cli/bin/ullm.rs b/crates/cli/bin/ullm.rs index a0f06e2..87fe547 100644 --- a/crates/cli/bin/ullm.rs +++ b/crates/cli/bin/ullm.rs @@ -1,6 +1,6 @@ use anyhow::Result; use clap::Parser; -use ucli::{App, Command, Config}; +use ullm_cli::{App, Command, Config}; #[tokio::main] async fn main() -> Result<()> { diff --git a/crates/cli/src/agents/anto.rs b/crates/cli/src/agents/anto.rs new file mode 100644 index 0000000..896fe80 --- /dev/null +++ b/crates/cli/src/agents/anto.rs @@ -0,0 +1,59 @@ +//! Anto agent - a basic agent to verify tool calling + +use anyhow::Result; +use schemars::JsonSchema; +use ullm::{Agent, Message, StreamChunk, Tool, ToolCall, ToolMessage}; + +/// Anto - a basic agent with tools for testing tool calls +#[derive(Clone)] +pub struct Anto; + +/// Parameters for the get_time tool +#[allow(dead_code)] +#[derive(JsonSchema)] +struct GetTimeParams { + /// Optional timezone (e.g., "UTC", "Local"). Defaults to local time. + timezone: Option, +} + +impl Agent for Anto { + type Chunk = StreamChunk; + + const SYSTEM_PROMPT: &str = "You are Anto, a helpful assistant. You can get the current time."; + + fn tools() -> Vec { + vec![Tool { + name: "get_time".into(), + description: "Gets the current date and time.".into(), + parameters: schemars::schema_for!(GetTimeParams).into(), + strict: true, + }] + } + + fn dispatch(&self, tools: &[ToolCall]) -> impl Future> { + async move { + tools + .iter() + .map(|call| { + let result = match call.function.name.as_str() { + "get_time" => { + let now = std::time::SystemTime::now(); + let duration = now.duration_since(std::time::UNIX_EPOCH).unwrap(); + let secs = duration.as_secs(); + format!("Current Unix timestamp: {secs}") + } + _ => format!("Unknown tool: {}", call.function.name), + }; + ToolMessage { + tool: call.id.clone(), + message: Message::tool(result), + } + }) + .collect() + } + } + + async fn chunk(&self, chunk: &StreamChunk) -> Result { + Ok(chunk.clone()) + } +} diff --git a/crates/cli/src/agents/mod.rs b/crates/cli/src/agents/mod.rs index e69de29..20f75d1 100644 --- a/crates/cli/src/agents/mod.rs +++ b/crates/cli/src/agents/mod.rs @@ -0,0 +1,14 @@ +//! CLI Agents + +mod anto; + +pub use anto::Anto; + +use clap::ValueEnum; + +/// Available agent types +#[derive(Debug, Clone, Copy, ValueEnum)] +pub enum AgentKind { + /// Anto - basic agent with echo tool for testing + Anto, +} diff --git a/crates/cli/src/chat.rs b/crates/cli/src/chat.rs index df02820..bc53b64 100644 --- a/crates/cli/src/chat.rs +++ b/crates/cli/src/chat.rs @@ -1,6 +1,7 @@ //! Chat command use super::Config; +use crate::agents::{AgentKind, Anto}; use anyhow::Result; use clap::{Args, ValueEnum}; use futures_util::StreamExt; @@ -8,8 +9,7 @@ use std::{ fmt::{Display, Formatter}, io::{BufRead, Write}, }; -use ullm::DeepSeek; -use ullm::{Chat, Client, LLM, Message}; +use ullm::{Agent, Chat, Client, Config as _, DeepSeek, Message, StreamChunk, LLM}; /// Chat command arguments #[derive(Debug, Args)] @@ -18,6 +18,10 @@ pub struct ChatCmd { #[arg(short, long, default_value = "deepseek")] pub model: Model, + /// The agent to use for the chat + #[arg(short, long)] + pub agent: Option, + /// The message to send (if empty, starts interactive mode) pub message: Option, } @@ -34,9 +38,25 @@ impl ChatCmd { Model::Deepseek => DeepSeek::new(Client::new(), key)?, }; - let mut chat = provider.chat(config.config().clone()); + match self.agent { + Some(AgentKind::Anto) => { + let mut chat = provider.chat(config.config().clone()).system(Anto); + chat.config = chat.config.with_tools(Anto::tools()); + self.run_chat(&mut chat, stream).await + } + None => { + let mut chat = provider.chat(config.config().clone()); + self.run_chat(&mut chat, stream).await + } + } + } + + async fn run_chat(&self, chat: &mut Chat, stream: bool) -> Result<()> + where + A: Agent, + { if let Some(msg) = &self.message { - Self::send(&mut chat, Message::user(msg), stream).await?; + Self::send(chat, Message::user(msg), stream).await?; } else { let stdin = std::io::stdin(); let mut stdout = std::io::stdout(); @@ -57,14 +77,17 @@ impl ChatCmd { break; } - Self::send(&mut chat, Message::user(input), stream).await?; + Self::send(chat, Message::user(input), stream).await?; } } Ok(()) } - async fn send(chat: &mut Chat, message: Message, stream: bool) -> Result<()> { + async fn send(chat: &mut Chat, message: Message, stream: bool) -> Result<()> + where + A: Agent, + { if stream { let mut response_content = String::new(); { diff --git a/crates/core/src/agent.rs b/crates/core/src/agent.rs index e7d52e7..0a5ef0f 100644 --- a/crates/core/src/agent.rs +++ b/crates/core/src/agent.rs @@ -14,7 +14,9 @@ pub trait Agent: Clone { const SYSTEM_PROMPT: &str; /// The tools for the agent - const TOOLS: Vec = Vec::new(); + fn tools() -> Vec { + Vec::new() + } /// Filter the messages to match required tools for the agent fn filter(&self, _message: &str) -> ToolChoice { diff --git a/crates/core/src/chat.rs b/crates/core/src/chat.rs index 1f1e4d7..bc62331 100644 --- a/crates/core/src/chat.rs +++ b/crates/core/src/chat.rs @@ -58,7 +58,7 @@ impl Chat { .collect(); } - self.config = self.config.with_tools(A::TOOLS); + self.config = self.config.with_tools(A::tools()); Chat { messages, provider: self.provider, diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 17b1424..17188d5 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -4,7 +4,7 @@ pub use { agent::Agent, chat::{Chat, ChatMessage}, config::{Config, General}, - message::{Message, Role}, + message::{Message, Role, ToolMessage}, provider::LLM, reqwest::{self, Client}, response::{ diff --git a/crates/ullm/src/lib.rs b/crates/ullm/src/lib.rs index a02ea39..e083ff0 100644 --- a/crates/ullm/src/lib.rs +++ b/crates/ullm/src/lib.rs @@ -3,4 +3,7 @@ //! This is the umbrella crate that re-exports all ullm components. pub use deepseek::DeepSeek; -pub use ucore::{self, Chat, ChatMessage, Client, Config, General, LLM, Message}; +pub use ucore::{ + self, Agent, Chat, ChatMessage, Client, Config, General, LLM, Message, StreamChunk, Tool, + ToolCall, ToolMessage, +}; From 36c6a9cea3b7da0ce24b6b6b796139cf0ebb1cb3 Mon Sep 17 00:00:00 2001 From: clearloop Date: Thu, 18 Dec 2025 18:35:08 +0800 Subject: [PATCH 03/18] chore(deepseek): wrap tools with functions in request --- llm/deepseek/src/llm.rs | 8 +++++++- llm/deepseek/src/request.rs | 9 +++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/llm/deepseek/src/llm.rs b/llm/deepseek/src/llm.rs index 93c8a75..2fcb927 100644 --- a/llm/deepseek/src/llm.rs +++ b/llm/deepseek/src/llm.rs @@ -30,16 +30,22 @@ impl LLM for DeepSeek { /// Send a message to the LLM async fn send(&mut self, req: &Request, messages: &[ChatMessage]) -> Result { + let body = req.messages(messages); + tracing::debug!( + "request: {}", + serde_json::to_string_pretty(&body).unwrap_or_default() + ); let text = self .client .request(Method::POST, ENDPOINT) .headers(self.headers.clone()) - .json(&req.messages(messages)) + .json(&body) .send() .await? .text() .await?; + tracing::debug!("response: {text}"); serde_json::from_str(&text).map_err(Into::into) // self.client // .request(Method::POST, ENDPOINT) diff --git a/llm/deepseek/src/request.rs b/llm/deepseek/src/request.rs index 2fc10d4..beef9f2 100644 --- a/llm/deepseek/src/request.rs +++ b/llm/deepseek/src/request.rs @@ -117,6 +117,15 @@ impl From for Request { impl Config for Request { fn with_tools(self, tools: Vec) -> Self { + let tools = tools + .into_iter() + .map(|tool| { + json!({ + "type": "function", + "function": json!(tool), + }) + }) + .collect::>(); Self { tools: Some(json!(tools)), ..self.clone() From 6432348744616db0b4609dd5208be4badd1c453d Mon Sep 17 00:00:00 2001 From: clearloop Date: Thu, 18 Dec 2025 18:57:47 +0800 Subject: [PATCH 04/18] chore(core): process agent messages before calling tools --- crates/cli/src/agents/anto.rs | 2 +- crates/core/src/agent.rs | 2 +- crates/core/src/chat.rs | 12 ++++++++---- crates/core/src/message.rs | 6 +++--- 4 files changed, 13 insertions(+), 9 deletions(-) diff --git a/crates/cli/src/agents/anto.rs b/crates/cli/src/agents/anto.rs index 896fe80..b8d0a76 100644 --- a/crates/cli/src/agents/anto.rs +++ b/crates/cli/src/agents/anto.rs @@ -45,7 +45,7 @@ impl Agent for Anto { _ => format!("Unknown tool: {}", call.function.name), }; ToolMessage { - tool: call.id.clone(), + call: call.id.clone(), message: Message::tool(result), } }) diff --git a/crates/core/src/agent.rs b/crates/core/src/agent.rs index 0a5ef0f..f259841 100644 --- a/crates/core/src/agent.rs +++ b/crates/core/src/agent.rs @@ -29,7 +29,7 @@ pub trait Agent: Clone { tools .iter() .map(|tool| ToolMessage { - tool: tool.id.clone(), + call: tool.id.clone(), message: Message::tool(format!( "function {} not available", tool.function.name diff --git a/crates/core/src/chat.rs b/crates/core/src/chat.rs index bc62331..02ff091 100644 --- a/crates/core/src/chat.rs +++ b/crates/core/src/chat.rs @@ -48,11 +48,11 @@ impl Chat { pub fn system(mut self, agent: B) -> Chat { let mut messages = self.messages; if messages.is_empty() { - messages.push(Message::system(A::SYSTEM_PROMPT).into()); + messages.push(Message::system(B::SYSTEM_PROMPT).into()); } else if let Some(ChatMessage::System(_)) = messages.first() { - messages.insert(0, Message::system(A::SYSTEM_PROMPT).into()); + messages.insert(0, Message::system(B::SYSTEM_PROMPT).into()); } else { - messages = vec![Message::system(A::SYSTEM_PROMPT).into()] + messages = vec![Message::system(B::SYSTEM_PROMPT).into()] .into_iter() .chain(messages) .collect(); @@ -77,6 +77,10 @@ impl Chat { for _ in 0..MAX_TOOL_CALLS { let response = self.provider.send(&config, &self.messages).await?; + if let Some(message) = response.message() { + self.messages.push(Message::assistant(message).into()); + } + let Some(tool_calls) = response.tool_calls() else { return Ok(response); }; @@ -171,7 +175,7 @@ impl From for ChatMessage { }), Role::System => ChatMessage::System(message), Role::Tool => ChatMessage::Tool(ToolMessage { - tool: String::new(), + call: String::new(), message, }), } diff --git a/crates/core/src/message.rs b/crates/core/src/message.rs index 85e9f13..0d67102 100644 --- a/crates/core/src/message.rs +++ b/crates/core/src/message.rs @@ -61,8 +61,8 @@ pub struct ToolMessage { pub message: Message, /// The tool call id - #[serde(alias = "tool_call_id")] - pub tool: String, + #[serde(rename = "tool_call_id")] + pub call: String, } /// An assistant message in the chat @@ -76,7 +76,7 @@ pub struct AssistantMessage { pub prefix: bool, /// The reasoning content - #[serde(alias = "reasoning_content")] + #[serde(alias = "reasoning_content", skip_serializing_if = "String::is_empty")] pub reasoning: String, } From 3ece5bac04231ce952efbf7a8deccba8c85a9528 Mon Sep 17 00:00:00 2001 From: clearloop Date: Thu, 18 Dec 2025 22:09:43 +0800 Subject: [PATCH 05/18] feat(core): merge chat message and message --- crates/cli/src/agents/anto.rs | 9 ++-- crates/cli/src/chat.rs | 6 +-- crates/core/src/agent.rs | 15 ++++--- crates/core/src/chat.rs | 77 ++++++++++------------------------- crates/core/src/lib.rs | 4 +- crates/core/src/message.rs | 75 +++++++++++++++++----------------- crates/core/src/provider.rs | 6 +-- crates/ullm/src/lib.rs | 3 +- llm/deepseek/src/llm.rs | 6 +-- llm/deepseek/src/request.rs | 6 +-- 10 files changed, 82 insertions(+), 125 deletions(-) diff --git a/crates/cli/src/agents/anto.rs b/crates/cli/src/agents/anto.rs index b8d0a76..be1cb83 100644 --- a/crates/cli/src/agents/anto.rs +++ b/crates/cli/src/agents/anto.rs @@ -2,7 +2,7 @@ use anyhow::Result; use schemars::JsonSchema; -use ullm::{Agent, Message, StreamChunk, Tool, ToolCall, ToolMessage}; +use ullm::{Agent, Message, StreamChunk, Tool, ToolCall}; /// Anto - a basic agent with tools for testing tool calls #[derive(Clone)] @@ -30,7 +30,7 @@ impl Agent for Anto { }] } - fn dispatch(&self, tools: &[ToolCall]) -> impl Future> { + fn dispatch(&self, tools: &[ToolCall]) -> impl Future> { async move { tools .iter() @@ -44,10 +44,7 @@ impl Agent for Anto { } _ => format!("Unknown tool: {}", call.function.name), }; - ToolMessage { - call: call.id.clone(), - message: Message::tool(result), - } + Message::tool(result, call.id.clone()) }) .collect() } diff --git a/crates/cli/src/chat.rs b/crates/cli/src/chat.rs index bc53b64..b57ee7f 100644 --- a/crates/cli/src/chat.rs +++ b/crates/cli/src/chat.rs @@ -9,7 +9,7 @@ use std::{ fmt::{Display, Formatter}, io::{BufRead, Write}, }; -use ullm::{Agent, Chat, Client, Config as _, DeepSeek, Message, StreamChunk, LLM}; +use ullm::{Agent, Chat, Client, Config as _, DeepSeek, LLM, Message, StreamChunk}; /// Chat command arguments #[derive(Debug, Args)] @@ -115,8 +115,6 @@ impl ChatCmd { } } println!(); - chat.messages - .push(Message::assistant(&response_content).into()); } else { let response = chat.send(message).await?; if let Some(reasoning_content) = response.reasoning() { @@ -126,8 +124,6 @@ impl ChatCmd { if let Some(content) = response.message() { println!("{content}"); } - chat.messages - .push(Message::assistant(response.message().unwrap_or(&String::new())).into()); } Ok(()) } diff --git a/crates/core/src/agent.rs b/crates/core/src/agent.rs index f259841..f4910a8 100644 --- a/crates/core/src/agent.rs +++ b/crates/core/src/agent.rs @@ -1,6 +1,6 @@ //! Turbofish Agent library -use crate::{Message, StreamChunk, Tool, ToolCall, ToolChoice, message::ToolMessage}; +use crate::{Message, StreamChunk, Tool, ToolCall, ToolChoice}; use anyhow::Result; /// A trait for turbofish agents @@ -24,16 +24,15 @@ pub trait Agent: Clone { } /// Dispatch tool calls - fn dispatch(&self, tools: &[ToolCall]) -> impl Future> { + fn dispatch(&self, tools: &[ToolCall]) -> impl Future> { async move { tools .iter() - .map(|tool| ToolMessage { - call: tool.id.clone(), - message: Message::tool(format!( - "function {} not available", - tool.function.name - )), + .map(|tool| { + Message::tool( + format!("function {} not available", tool.function.name), + tool.id.clone(), + ) }) .collect() } diff --git a/crates/core/src/chat.rs b/crates/core/src/chat.rs index 02ff091..c92d0c6 100644 --- a/crates/core/src/chat.rs +++ b/crates/core/src/chat.rs @@ -1,13 +1,9 @@ //! Chat abstractions for the unified LLM Interfaces -use crate::{ - Agent, Config, FinishReason, General, LLM, Response, Role, - message::{AssistantMessage, Message, ToolMessage}, -}; +use crate::{Agent, Config, FinishReason, General, LLM, Response, Role, message::Message}; use anyhow::Result; use futures_core::Stream; use futures_util::StreamExt; -use serde::Serialize; const MAX_TOOL_CALLS: usize = 16; @@ -18,7 +14,7 @@ pub struct Chat { pub config: P::ChatConfig, /// Chat history in memory - pub messages: Vec, + pub messages: Vec, /// The LLM provider provider: P, @@ -48,11 +44,11 @@ impl Chat { pub fn system(mut self, agent: B) -> Chat { let mut messages = self.messages; if messages.is_empty() { - messages.push(Message::system(B::SYSTEM_PROMPT).into()); - } else if let Some(ChatMessage::System(_)) = messages.first() { - messages.insert(0, Message::system(B::SYSTEM_PROMPT).into()); + messages.push(Message::system(B::SYSTEM_PROMPT)); + } else if messages.first().map(|m| m.role) == Some(Role::System) { + messages.insert(0, Message::system(B::SYSTEM_PROMPT)); } else { - messages = vec![Message::system(B::SYSTEM_PROMPT).into()] + messages = vec![Message::system(B::SYSTEM_PROMPT)] .into_iter() .chain(messages) .collect(); @@ -73,12 +69,16 @@ impl Chat { let config = self .config .with_tool_choice(self.agent.filter(message.content.as_str())); - self.messages.push(message.into()); + self.messages.push(message); for _ in 0..MAX_TOOL_CALLS { let response = self.provider.send(&config, &self.messages).await?; if let Some(message) = response.message() { - self.messages.push(Message::assistant(message).into()); + self.messages.push(Message::assistant( + message, + response.reasoning().cloned(), + response.tool_calls(), + )); } let Some(tool_calls) = response.tool_calls() else { @@ -86,7 +86,7 @@ impl Chat { }; let result = self.agent.dispatch(tool_calls).await; - self.messages.extend(result.into_iter().map(Into::into)); + self.messages.extend(result); } anyhow::bail!("max tool calls reached"); @@ -100,7 +100,7 @@ impl Chat { let config = self .config .with_tool_choice(self.agent.filter(message.content.as_str())); - self.messages.push(message.into()); + self.messages.push(message); async_stream::try_stream! { for _ in 0..MAX_TOOL_CALLS { @@ -110,6 +110,7 @@ impl Chat { let mut tool_calls = None; let mut message = String::new(); + let mut reasoning = String::new(); while let Some(chunk) = inner.next().await { let chunk = chunk?; if let Some(calls) = chunk.tool_calls() { @@ -120,6 +121,10 @@ impl Chat { message.push_str(content); } + if let Some(reason) = chunk.reasoning_content() { + reasoning.push_str(reason); + } + yield self.agent.chunk(&chunk).await?; if let Some(reason) = chunk.reason() { match reason { @@ -131,7 +136,8 @@ impl Chat { } if !message.is_empty() { - self.messages.push(Message::assistant(&message).into()); + let reasoning = if reasoning.is_empty() { None } else { Some(reasoning) }; + self.messages.push(Message::assistant(&message, reasoning, tool_calls.as_deref())); } if let Some(calls) = tool_calls { @@ -146,44 +152,3 @@ impl Chat { } } } - -/// A chat message in memory -#[derive(Debug, Clone, Serialize)] -#[serde(untagged)] -pub enum ChatMessage { - /// A user message - User(Message), - - /// An assistant message - Assistant(AssistantMessage), - - /// A tool message - Tool(ToolMessage), - - /// A system message - System(Message), -} - -impl From for ChatMessage { - fn from(message: Message) -> Self { - match message.role { - Role::User => ChatMessage::User(message), - Role::Assistant => ChatMessage::Assistant(AssistantMessage { - message, - prefix: false, - reasoning: String::new(), - }), - Role::System => ChatMessage::System(message), - Role::Tool => ChatMessage::Tool(ToolMessage { - call: String::new(), - message, - }), - } - } -} - -impl From for ChatMessage { - fn from(message: ToolMessage) -> Self { - ChatMessage::Tool(message) - } -} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 17188d5..eb6f609 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -2,9 +2,9 @@ pub use { agent::Agent, - chat::{Chat, ChatMessage}, + chat::Chat, config::{Config, General}, - message::{Message, Role, ToolMessage}, + message::{Message, Role}, provider::LLM, reqwest::{self, Client}, response::{ diff --git a/crates/core/src/message.rs b/crates/core/src/message.rs index 0d67102..9d3bf32 100644 --- a/crates/core/src/message.rs +++ b/crates/core/src/message.rs @@ -2,17 +2,37 @@ use serde::{Deserialize, Serialize}; +use crate::ToolCall; + /// A message in the chat -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct Message { + /// The role of the message + pub role: Role, + /// The content of the message + #[serde(skip_serializing_if = "String::is_empty")] pub content: String, /// The name of the message + #[serde(skip_serializing_if = "String::is_empty")] pub name: String, - /// The role of the message - pub role: Role, + /// Whether to prefix the message + #[serde(skip_serializing_if = "Option::is_none")] + pub prefix: Option, + + /// The reasoning content + #[serde(skip_serializing_if = "String::is_empty")] + pub reasoning: String, + + /// The tool call id + #[serde(skip_serializing_if = "String::is_empty")] + pub tool_call_id: String, + + /// The tool calls + #[serde(skip_serializing_if = "Vec::is_empty")] + pub tool_calls: Vec, } impl Message { @@ -20,8 +40,8 @@ impl Message { pub fn system(content: impl Into) -> Self { Self { role: Role::System, - name: String::new(), content: content.into(), + ..Default::default() } } @@ -29,62 +49,43 @@ impl Message { pub fn user(content: impl Into) -> Self { Self { role: Role::User, - name: String::new(), content: content.into(), + ..Default::default() } } /// Create a new assistant message - pub fn assistant(content: impl Into) -> Self { + pub fn assistant( + content: impl Into, + reasoning: Option, + tool_calls: Option<&[ToolCall]>, + ) -> Self { Self { role: Role::Assistant, - name: String::new(), content: content.into(), + reasoning: reasoning.unwrap_or_default().into(), + tool_calls: tool_calls.unwrap_or_default().to_vec(), + ..Default::default() } } /// Create a new tool message - pub fn tool(content: impl Into) -> Self { + pub fn tool(content: impl Into, call: impl Into) -> Self { Self { role: Role::Tool, - name: String::new(), content: content.into(), + tool_call_id: call.into(), + ..Default::default() } } } -/// A tool message in the chat -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct ToolMessage { - /// The message - #[serde(flatten)] - pub message: Message, - - /// The tool call id - #[serde(rename = "tool_call_id")] - pub call: String, -} - -/// An assistant message in the chat -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct AssistantMessage { - /// The message - #[serde(flatten)] - pub message: Message, - - /// Whether to prefix the message - pub prefix: bool, - - /// The reasoning content - #[serde(alias = "reasoning_content", skip_serializing_if = "String::is_empty")] - pub reasoning: String, -} - /// The role of a message -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize, Default)] pub enum Role { /// The user role #[serde(rename = "user")] + #[default] User, /// The assistant role #[serde(rename = "assistant")] diff --git a/crates/core/src/provider.rs b/crates/core/src/provider.rs index b2f5010..65f70d6 100644 --- a/crates/core/src/provider.rs +++ b/crates/core/src/provider.rs @@ -1,6 +1,6 @@ //! Provider abstractions for the unified LLM Interfaces -use crate::{Chat, ChatMessage, Config, General, Response, StreamChunk}; +use crate::{Chat, Config, General, Message, Response, StreamChunk}; use anyhow::Result; use futures_core::Stream; use reqwest::Client; @@ -24,14 +24,14 @@ pub trait LLM: Sized + Clone { fn send( &mut self, config: &Self::ChatConfig, - messages: &[ChatMessage], + messages: &[Message], ) -> impl Future>; /// Send a message to the LLM with streaming fn stream( &mut self, config: Self::ChatConfig, - messages: &[ChatMessage], + messages: &[Message], usage: bool, ) -> impl Stream>; } diff --git a/crates/ullm/src/lib.rs b/crates/ullm/src/lib.rs index e083ff0..89ad822 100644 --- a/crates/ullm/src/lib.rs +++ b/crates/ullm/src/lib.rs @@ -4,6 +4,5 @@ pub use deepseek::DeepSeek; pub use ucore::{ - self, Agent, Chat, ChatMessage, Client, Config, General, LLM, Message, StreamChunk, Tool, - ToolCall, ToolMessage, + self, Agent, Chat, Client, Config, General, LLM, Message, StreamChunk, Tool, ToolCall, }; diff --git a/llm/deepseek/src/llm.rs b/llm/deepseek/src/llm.rs index 2fcb927..ecc8c2b 100644 --- a/llm/deepseek/src/llm.rs +++ b/llm/deepseek/src/llm.rs @@ -6,7 +6,7 @@ use async_stream::try_stream; use futures_core::Stream; use futures_util::StreamExt; use ucore::{ - ChatMessage, Client, LLM, Response, StreamChunk, + Client, LLM, Message, Response, StreamChunk, reqwest::{ Method, header::{self, HeaderMap}, @@ -29,7 +29,7 @@ impl LLM for DeepSeek { } /// Send a message to the LLM - async fn send(&mut self, req: &Request, messages: &[ChatMessage]) -> Result { + async fn send(&mut self, req: &Request, messages: &[Message]) -> Result { let body = req.messages(messages); tracing::debug!( "request: {}", @@ -62,7 +62,7 @@ impl LLM for DeepSeek { fn stream( &mut self, req: Request, - messages: &[ChatMessage], + messages: &[Message], usage: bool, ) -> impl Stream> { let request = self diff --git a/llm/deepseek/src/request.rs b/llm/deepseek/src/request.rs index beef9f2..b9ae213 100644 --- a/llm/deepseek/src/request.rs +++ b/llm/deepseek/src/request.rs @@ -2,13 +2,13 @@ use serde::Serialize; use serde_json::{Value, json}; -use ucore::{ChatMessage, Config, General, Tool, ToolChoice}; +use ucore::{Config, General, Message, Tool, ToolChoice}; /// The request body for the DeepSeek API #[derive(Debug, Clone, Serialize)] pub struct Request { /// The messages to send to the API - pub messages: Vec, + pub messages: Vec, /// The model we are using pub model: String, @@ -73,7 +73,7 @@ pub struct Request { impl Request { /// Construct the messages for the request - pub fn messages(&self, messages: &[ChatMessage]) -> Self { + pub fn messages(&self, messages: &[Message]) -> Self { Self { messages: messages.to_vec(), ..self.clone() From bc33d88b8f6299fc39255b9f98975b00332396a8 Mon Sep 17 00:00:00 2001 From: clearloop Date: Thu, 18 Dec 2025 22:59:57 +0800 Subject: [PATCH 06/18] chore(core): clean logs --- Cargo.lock | 82 +++++++++++++++++++++++++++++++++++ Cargo.toml | 1 + crates/cli/Cargo.toml | 1 + crates/cli/src/agents/anto.rs | 8 ++-- llm/deepseek/src/llm.rs | 14 +++--- 5 files changed, 94 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 34206ed..e4dd11d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -37,6 +37,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "0.6.21" @@ -298,6 +307,19 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chrono" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "clap" version = "4.5.53" @@ -1300,6 +1322,30 @@ dependencies = [ "windows-registry", ] +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.1.1" @@ -2960,6 +3006,7 @@ name = "ullm-cli" version = "0.0.9" dependencies = [ "anyhow", + "chrono", "clap", "dirs", "futures-util", @@ -3283,6 +3330,41 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index c7101e2..a8bec18 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ ucli = { path = "crates/cli", package = "ullm-cli" } # crates.io anyhow = "1" +chrono = "0.4" async-stream = "0.3" bytes = "1.11.0" clap = { version = "4.5", features = ["derive"] } diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index d8d03e8..0bbc271 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -17,6 +17,7 @@ ullm.workspace = true # crates-io dependencies anyhow.workspace = true +chrono.workspace = true clap.workspace = true dirs.workspace = true futures-util.workspace = true diff --git a/crates/cli/src/agents/anto.rs b/crates/cli/src/agents/anto.rs index be1cb83..30b4859 100644 --- a/crates/cli/src/agents/anto.rs +++ b/crates/cli/src/agents/anto.rs @@ -1,6 +1,7 @@ //! Anto agent - a basic agent to verify tool calling use anyhow::Result; +use chrono::Utc; use schemars::JsonSchema; use ullm::{Agent, Message, StreamChunk, Tool, ToolCall}; @@ -24,7 +25,7 @@ impl Agent for Anto { fn tools() -> Vec { vec![Tool { name: "get_time".into(), - description: "Gets the current date and time.".into(), + description: "Gets the current UTC time in ISO 8601 format.".into(), parameters: schemars::schema_for!(GetTimeParams).into(), strict: true, }] @@ -37,10 +38,7 @@ impl Agent for Anto { .map(|call| { let result = match call.function.name.as_str() { "get_time" => { - let now = std::time::SystemTime::now(); - let duration = now.duration_since(std::time::UNIX_EPOCH).unwrap(); - let secs = duration.as_secs(); - format!("Current Unix timestamp: {secs}") + Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string() } _ => format!("Unknown tool: {}", call.function.name), }; diff --git a/llm/deepseek/src/llm.rs b/llm/deepseek/src/llm.rs index ecc8c2b..1e4c0db 100644 --- a/llm/deepseek/src/llm.rs +++ b/llm/deepseek/src/llm.rs @@ -31,10 +31,6 @@ impl LLM for DeepSeek { /// Send a message to the LLM async fn send(&mut self, req: &Request, messages: &[Message]) -> Result { let body = req.messages(messages); - tracing::debug!( - "request: {}", - serde_json::to_string_pretty(&body).unwrap_or_default() - ); let text = self .client .request(Method::POST, ENDPOINT) @@ -45,7 +41,6 @@ impl LLM for DeepSeek { .text() .await?; - tracing::debug!("response: {text}"); serde_json::from_str(&text).map_err(Into::into) // self.client // .request(Method::POST, ENDPOINT) @@ -65,18 +60,23 @@ impl LLM for DeepSeek { messages: &[Message], usage: bool, ) -> impl Stream> { + let body = req.messages(messages).stream(usage); let request = self .client .request(Method::POST, ENDPOINT) .headers(self.headers.clone()) - .json(&req.messages(messages).stream(usage)); + .json(&body); try_stream! { let mut stream = request.send().await?.bytes_stream(); while let Some(chunk) = stream.next().await { let text = String::from_utf8_lossy(&chunk?).into_owned(); for data in text.split("data: ").skip(1).filter(|s| !s.starts_with("[DONE]")) { - yield serde_json::from_str(data.trim())?; + if let Ok(chunk) = serde_json::from_str(data.trim()) { + yield chunk; + } else { + continue; + } } } } From a4187d81fa08162b4d5a18c0ae5033e0f4b8a4c0 Mon Sep 17 00:00:00 2001 From: clearloop Date: Thu, 18 Dec 2025 23:22:11 +0800 Subject: [PATCH 07/18] feat(deepseek): handle thinking mode in non-streaming --- crates/cli/src/chat.rs | 6 +++++- crates/core/src/chat.rs | 16 +++++++++++++--- crates/core/src/config.rs | 6 +++++- crates/core/src/message.rs | 7 +++---- llm/deepseek/src/llm.rs | 7 +++++++ llm/deepseek/src/request.rs | 8 +++++++- 6 files changed, 40 insertions(+), 10 deletions(-) diff --git a/crates/cli/src/chat.rs b/crates/cli/src/chat.rs index b57ee7f..69a42f1 100644 --- a/crates/cli/src/chat.rs +++ b/crates/cli/src/chat.rs @@ -22,6 +22,10 @@ pub struct ChatCmd { #[arg(short, long)] pub agent: Option, + /// Whether to enable thinking + #[arg(short, long)] + pub think: bool, + /// The message to send (if empty, starts interactive mode) pub message: Option, } @@ -122,7 +126,7 @@ impl ChatCmd { } if let Some(content) = response.message() { - println!("{content}"); + println!("content: {content}"); } } Ok(()) diff --git a/crates/core/src/chat.rs b/crates/core/src/chat.rs index c92d0c6..48360bb 100644 --- a/crates/core/src/chat.rs +++ b/crates/core/src/chat.rs @@ -40,6 +40,18 @@ impl Chat { } impl Chat { + /// Get the chat messages + pub fn messages(&self) -> Vec { + self.messages + .clone() + .into_iter() + .map(|mut m| { + m.reasoning_content = String::new(); + m + }) + .collect() + } + /// Add the system prompt to the chat pub fn system(mut self, agent: B) -> Chat { let mut messages = self.messages; @@ -104,7 +116,7 @@ impl Chat { async_stream::try_stream! { for _ in 0..MAX_TOOL_CALLS { - let messages = self.messages.clone(); + let messages = self.messages(); let inner = self.provider.stream(config.clone(), &messages, self.usage); futures_util::pin_mut!(inner); @@ -147,8 +159,6 @@ impl Chat { break; } } - - Err(anyhow::anyhow!("max tool calls reached"))?; } } } diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index 062c6ef..90d8c1a 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -20,8 +20,10 @@ pub struct General { /// The model to use pub model: String, + /// Whether to enable thinking + pub think: bool, + /// The tools to use - #[serde(skip_serializing_if = "Option::is_none")] pub tools: Option>, /// Whether to return the usage information in stream mode @@ -33,6 +35,7 @@ impl General { pub fn new(model: impl Into) -> Self { Self { model: model.into(), + think: false, tools: None, usage: false, } @@ -43,6 +46,7 @@ impl Default for General { fn default() -> Self { Self { model: "deepseek-chat".into(), + think: false, tools: None, usage: false, } diff --git a/crates/core/src/message.rs b/crates/core/src/message.rs index 9d3bf32..7783658 100644 --- a/crates/core/src/message.rs +++ b/crates/core/src/message.rs @@ -1,8 +1,7 @@ //! Turbofish LLM message -use serde::{Deserialize, Serialize}; - use crate::ToolCall; +use serde::{Deserialize, Serialize}; /// A message in the chat #[derive(Debug, Clone, Deserialize, Serialize, Default)] @@ -24,7 +23,7 @@ pub struct Message { /// The reasoning content #[serde(skip_serializing_if = "String::is_empty")] - pub reasoning: String, + pub reasoning_content: String, /// The tool call id #[serde(skip_serializing_if = "String::is_empty")] @@ -63,7 +62,7 @@ impl Message { Self { role: Role::Assistant, content: content.into(), - reasoning: reasoning.unwrap_or_default().into(), + reasoning_content: reasoning.unwrap_or_default().into(), tool_calls: tool_calls.unwrap_or_default().to_vec(), ..Default::default() } diff --git a/llm/deepseek/src/llm.rs b/llm/deepseek/src/llm.rs index 1e4c0db..7f6ae28 100644 --- a/llm/deepseek/src/llm.rs +++ b/llm/deepseek/src/llm.rs @@ -31,6 +31,7 @@ impl LLM for DeepSeek { /// Send a message to the LLM async fn send(&mut self, req: &Request, messages: &[Message]) -> Result { let body = req.messages(messages); + tracing::debug!("request: {}", serde_json::to_string_pretty(&body)?); let text = self .client .request(Method::POST, ENDPOINT) @@ -41,6 +42,7 @@ impl LLM for DeepSeek { .text() .await?; + tracing::debug!("response: {text}"); serde_json::from_str(&text).map_err(Into::into) // self.client // .request(Method::POST, ENDPOINT) @@ -61,6 +63,10 @@ impl LLM for DeepSeek { usage: bool, ) -> impl Stream> { let body = req.messages(messages).stream(usage); + tracing::debug!( + "request: {}", + serde_json::to_string_pretty(&body).unwrap_or_default() + ); let request = self .client .request(Method::POST, ENDPOINT) @@ -72,6 +78,7 @@ impl LLM for DeepSeek { while let Some(chunk) = stream.next().await { let text = String::from_utf8_lossy(&chunk?).into_owned(); for data in text.split("data: ").skip(1).filter(|s| !s.starts_with("[DONE]")) { + tracing::debug!("response: {data}"); if let Ok(chunk) = serde_json::from_str(data.trim()) { yield chunk; } else { diff --git a/llm/deepseek/src/request.rs b/llm/deepseek/src/request.rs index b9ae213..8cd478e 100644 --- a/llm/deepseek/src/request.rs +++ b/llm/deepseek/src/request.rs @@ -105,7 +105,13 @@ impl From for Request { stop: None, stream: None, stream_options: None, - thinking: None, + thinking: if config.think { + Some(json!({ + "type": "enabled" + })) + } else { + None + }, temperature: None, tool_choice: None, tools: None, From ba4b9d1595c09ce0577e09997b4f23aa5d5345d9 Mon Sep 17 00:00:00 2001 From: clearloop Date: Fri, 19 Dec 2025 00:08:32 +0800 Subject: [PATCH 08/18] chore(tool): support tool parsing in stream --- crates/cli/src/agents/anto.rs | 17 ++++++++++++---- crates/cli/src/agents/mod.rs | 5 ++--- crates/core/src/chat.rs | 37 +++++++++++++++++++++++++---------- crates/core/src/tool.rs | 13 +++++++++--- llm/deepseek/src/llm.rs | 6 +++--- 5 files changed, 55 insertions(+), 23 deletions(-) diff --git a/crates/cli/src/agents/anto.rs b/crates/cli/src/agents/anto.rs index 30b4859..c925b41 100644 --- a/crates/cli/src/agents/anto.rs +++ b/crates/cli/src/agents/anto.rs @@ -3,6 +3,7 @@ use anyhow::Result; use chrono::Utc; use schemars::JsonSchema; +use serde::Deserialize; use ullm::{Agent, Message, StreamChunk, Tool, ToolCall}; /// Anto - a basic agent with tools for testing tool calls @@ -11,10 +12,10 @@ pub struct Anto; /// Parameters for the get_time tool #[allow(dead_code)] -#[derive(JsonSchema)] +#[derive(JsonSchema, Deserialize)] struct GetTimeParams { - /// Optional timezone (e.g., "UTC", "Local"). Defaults to local time. - timezone: Option, + /// If returns UNIX timestamp instead + timestamp: bool, } impl Agent for Anto { @@ -38,7 +39,15 @@ impl Agent for Anto { .map(|call| { let result = match call.function.name.as_str() { "get_time" => { - Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string() + tracing::debug!("get_time arguments: {}", call.function.arguments); + let args = + serde_json::from_str::(&call.function.arguments) + .unwrap(); + if args.timestamp { + Utc::now().timestamp().to_string() + } else { + Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string() + } } _ => format!("Unknown tool: {}", call.function.name), }; diff --git a/crates/cli/src/agents/mod.rs b/crates/cli/src/agents/mod.rs index 20f75d1..0418cd5 100644 --- a/crates/cli/src/agents/mod.rs +++ b/crates/cli/src/agents/mod.rs @@ -1,11 +1,10 @@ //! CLI Agents -mod anto; - pub use anto::Anto; - use clap::ValueEnum; +mod anto; + /// Available agent types #[derive(Debug, Clone, Copy, ValueEnum)] pub enum AgentKind { diff --git a/crates/core/src/chat.rs b/crates/core/src/chat.rs index 48360bb..df31afe 100644 --- a/crates/core/src/chat.rs +++ b/crates/core/src/chat.rs @@ -1,9 +1,12 @@ //! Chat abstractions for the unified LLM Interfaces -use crate::{Agent, Config, FinishReason, General, LLM, Response, Role, message::Message}; +use crate::{ + Agent, Config, FinishReason, General, LLM, Response, Role, ToolCall, message::Message, +}; use anyhow::Result; use futures_core::Stream; use futures_util::StreamExt; +use std::collections::HashMap; const MAX_TOOL_CALLS: usize = 16; @@ -120,13 +123,25 @@ impl Chat { let inner = self.provider.stream(config.clone(), &messages, self.usage); futures_util::pin_mut!(inner); - let mut tool_calls = None; + let mut tool_calls: HashMap = HashMap::new(); let mut message = String::new(); let mut reasoning = String::new(); while let Some(chunk) = inner.next().await { let chunk = chunk?; if let Some(calls) = chunk.tool_calls() { - tool_calls = Some(calls.to_vec()); + for call in calls { + let entry = tool_calls.entry(call.index).or_default(); + if !call.id.is_empty() { + entry.id.clone_from(&call.id); + } + if !call.call_type.is_empty() { + entry.call_type.clone_from(&call.call_type); + } + if !call.function.name.is_empty() { + entry.function.name.clone_from(&call.function.name); + } + entry.function.arguments.push_str(&call.function.arguments); + } } if let Some(content) = chunk.content() { @@ -147,15 +162,17 @@ impl Chat { } } - if !message.is_empty() { - let reasoning = if reasoning.is_empty() { None } else { Some(reasoning) }; - self.messages.push(Message::assistant(&message, reasoning, tool_calls.as_deref())); - } - - if let Some(calls) = tool_calls { + let reasoning = if reasoning.is_empty() { None } else { Some(reasoning) }; + if !tool_calls.is_empty() { + let mut calls: Vec<_> = tool_calls.into_values().collect(); + calls.sort_by_key(|c| c.index); + self.messages.push(Message::assistant(&message, reasoning, Some(&calls))); let result = self.agent.dispatch(&calls).await; - self.messages.extend(result.into_iter().map(Into::into)); + self.messages.extend(result); } else { + if !message.is_empty() { + self.messages.push(Message::assistant(&message, reasoning, None)); + } break; } } diff --git a/crates/core/src/tool.rs b/crates/core/src/tool.rs index 96a46fa..72e44d8 100644 --- a/crates/core/src/tool.rs +++ b/crates/core/src/tool.rs @@ -20,13 +20,18 @@ pub struct Tool { } /// A tool call made by the model -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Default, Deserialize, Serialize)] pub struct ToolCall { /// The ID of the tool call + #[serde(default, skip_serializing_if = "String::is_empty")] pub id: String, + /// The index of the tool call (used in streaming) + #[serde(skip_serializing)] + pub index: u32, + /// The type of tool (currently only "function") - #[serde(rename = "type")] + #[serde(default, rename = "type")] pub call_type: String, /// The function to call @@ -34,12 +39,14 @@ pub struct ToolCall { } /// A function call within a tool call -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Default, Deserialize, Serialize)] pub struct FunctionCall { /// The name of the function to call + #[serde(default, skip_serializing_if = "String::is_empty")] pub name: String, /// The arguments to pass to the function (JSON string) + #[serde(skip_serializing_if = "String::is_empty")] pub arguments: String, } diff --git a/llm/deepseek/src/llm.rs b/llm/deepseek/src/llm.rs index 7f6ae28..b56cbec 100644 --- a/llm/deepseek/src/llm.rs +++ b/llm/deepseek/src/llm.rs @@ -31,7 +31,7 @@ impl LLM for DeepSeek { /// Send a message to the LLM async fn send(&mut self, req: &Request, messages: &[Message]) -> Result { let body = req.messages(messages); - tracing::debug!("request: {}", serde_json::to_string_pretty(&body)?); + tracing::debug!("request: {}", serde_json::to_string(&body)?); let text = self .client .request(Method::POST, ENDPOINT) @@ -65,7 +65,7 @@ impl LLM for DeepSeek { let body = req.messages(messages).stream(usage); tracing::debug!( "request: {}", - serde_json::to_string_pretty(&body).unwrap_or_default() + serde_json::to_string(&body).unwrap_or_default() ); let request = self .client @@ -78,7 +78,7 @@ impl LLM for DeepSeek { while let Some(chunk) = stream.next().await { let text = String::from_utf8_lossy(&chunk?).into_owned(); for data in text.split("data: ").skip(1).filter(|s| !s.starts_with("[DONE]")) { - tracing::debug!("response: {data}"); + tracing::debug!("response: {}", data.trim()); if let Ok(chunk) = serde_json::from_str(data.trim()) { yield chunk; } else { From ff62d3820bb5c03e2b3a2cc6f7c949b703e26b25 Mon Sep 17 00:00:00 2001 From: clearloop Date: Fri, 19 Dec 2025 00:23:05 +0800 Subject: [PATCH 09/18] feat(llm): thinking with stream mode --- crates/core/src/chat.rs | 14 ++++++-------- llm/deepseek/src/llm.rs | 7 +++---- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/crates/core/src/chat.rs b/crates/core/src/chat.rs index df31afe..45078cd 100644 --- a/crates/core/src/chat.rs +++ b/crates/core/src/chat.rs @@ -48,8 +48,8 @@ impl Chat { self.messages .clone() .into_iter() - .map(|mut m| { - m.reasoning_content = String::new(); + .map(|m| { + // m.reasoning_content = String::new(); m }) .collect() @@ -163,17 +163,15 @@ impl Chat { } let reasoning = if reasoning.is_empty() { None } else { Some(reasoning) }; - if !tool_calls.is_empty() { + if tool_calls.is_empty() { + self.messages.push(Message::assistant(&message, reasoning, None)); + break; + } else { let mut calls: Vec<_> = tool_calls.into_values().collect(); calls.sort_by_key(|c| c.index); self.messages.push(Message::assistant(&message, reasoning, Some(&calls))); let result = self.agent.dispatch(&calls).await; self.messages.extend(result); - } else { - if !message.is_empty() { - self.messages.push(Message::assistant(&message, reasoning, None)); - } - break; } } } diff --git a/llm/deepseek/src/llm.rs b/llm/deepseek/src/llm.rs index b56cbec..4e6c6f7 100644 --- a/llm/deepseek/src/llm.rs +++ b/llm/deepseek/src/llm.rs @@ -79,10 +79,9 @@ impl LLM for DeepSeek { let text = String::from_utf8_lossy(&chunk?).into_owned(); for data in text.split("data: ").skip(1).filter(|s| !s.starts_with("[DONE]")) { tracing::debug!("response: {}", data.trim()); - if let Ok(chunk) = serde_json::from_str(data.trim()) { - yield chunk; - } else { - continue; + match serde_json::from_str::(data.trim()) { + Ok(chunk) => yield chunk, + Err(e) => tracing::warn!("failed to parse chunk: {e}, data: {}", data.trim()), } } } From 829d8f0e54938be3415dd75ccc3a4c9790c50091 Mon Sep 17 00:00:00 2001 From: clearloop Date: Fri, 19 Dec 2025 00:54:29 +0800 Subject: [PATCH 10/18] chore(cli): prettier the chat format --- crates/cli/src/chat.rs | 48 +++++++++++++++++++-------------------- crates/cli/src/config.rs | 2 +- crates/core/src/chat.rs | 13 ++++++----- crates/core/src/stream.rs | 14 ++++++++++++ 4 files changed, 46 insertions(+), 31 deletions(-) diff --git a/crates/cli/src/chat.rs b/crates/cli/src/chat.rs index 69a42f1..ef95c95 100644 --- a/crates/cli/src/chat.rs +++ b/crates/cli/src/chat.rs @@ -9,7 +9,7 @@ use std::{ fmt::{Display, Formatter}, io::{BufRead, Write}, }; -use ullm::{Agent, Chat, Client, Config as _, DeepSeek, LLM, Message, StreamChunk}; +use ullm::{Agent, Chat, Client, DeepSeek, LLM, Message, StreamChunk}; /// Chat command arguments #[derive(Debug, Args)] @@ -33,7 +33,7 @@ pub struct ChatCmd { impl ChatCmd { /// Run the chat command pub async fn run(&self, stream: bool) -> Result<()> { - let config = Config::load()?; + let mut config = Config::load()?; let key = config .key .get(&self.model.to_string()) @@ -42,10 +42,13 @@ impl ChatCmd { Model::Deepseek => DeepSeek::new(Client::new(), key)?, }; + // override the think flag in the config + config.config.think = self.think; + + // run the chat match self.agent { Some(AgentKind::Anto) => { let mut chat = provider.chat(config.config().clone()).system(Anto); - chat.config = chat.config.with_tools(Anto::tools()); self.run_chat(&mut chat, stream).await } None => { @@ -94,39 +97,36 @@ impl ChatCmd { { if stream { let mut response_content = String::new(); - { - let mut reasoning = false; - let mut stream = std::pin::pin!(chat.stream(message)); - while let Some(chunk) = stream.next().await { - let chunk = chunk?; - if let Some(content) = chunk.content() { - if reasoning { - print!("\ncontent: "); - reasoning = false; - } - print!("{content}"); - response_content.push_str(content); + let mut reasoning = false; + let mut stream = std::pin::pin!(chat.stream(message)); + while let Some(Ok(chunk)) = stream.next().await { + if let Some(content) = chunk.content() { + if reasoning { + print!("\n\n\nCONTENT\n"); + reasoning = false; } + print!("{content}"); + response_content.push_str(content); + } - if let Some(reasoning_content) = chunk.reasoning_content() { - if !reasoning { - print!("thinking: "); - reasoning = true; - } - print!("{reasoning_content}"); - response_content.push_str(reasoning_content); + if let Some(reasoning_content) = chunk.reasoning_content() { + if !reasoning { + print!("REASONING\n"); + reasoning = true; } + print!("{reasoning_content}"); + response_content.push_str(reasoning_content); } } println!(); } else { let response = chat.send(message).await?; if let Some(reasoning_content) = response.reasoning() { - println!("reasoning: {reasoning_content}"); + println!("REASONING\n{reasoning_content}"); } if let Some(content) = response.message() { - println!("content: {content}"); + println!("\n\nCONTENT\n{content}"); } } Ok(()) diff --git a/crates/cli/src/config.rs b/crates/cli/src/config.rs index 6916820..db7e68e 100644 --- a/crates/cli/src/config.rs +++ b/crates/cli/src/config.rs @@ -10,7 +10,7 @@ static CONFIG: LazyLock = #[derive(Debug, Clone, Deserialize, Serialize)] pub struct Config { /// The configuration for the CLI - config: ullm::General, + pub config: ullm::General, /// The API keys for LLMs pub key: BTreeMap, diff --git a/crates/core/src/chat.rs b/crates/core/src/chat.rs index 45078cd..1de54d5 100644 --- a/crates/core/src/chat.rs +++ b/crates/core/src/chat.rs @@ -43,13 +43,15 @@ impl Chat { } impl Chat { - /// Get the chat messages + /// Get the chat messages for API requests. pub fn messages(&self) -> Vec { self.messages .clone() .into_iter() - .map(|m| { - // m.reasoning_content = String::new(); + .map(|mut m| { + if m.tool_calls.is_empty() { + m.reasoning_content = String::new(); + } m }) .collect() @@ -69,7 +71,7 @@ impl Chat { .collect(); } - self.config = self.config.with_tools(A::tools()); + self.config = self.config.with_tools(B::tools()); Chat { messages, provider: self.provider, @@ -85,9 +87,8 @@ impl Chat { .config .with_tool_choice(self.agent.filter(message.content.as_str())); self.messages.push(message); - for _ in 0..MAX_TOOL_CALLS { - let response = self.provider.send(&config, &self.messages).await?; + let response = self.provider.send(&config, &self.messages()).await?; if let Some(message) = response.message() { self.messages.push(Message::assistant( message, diff --git a/crates/core/src/stream.rs b/crates/core/src/stream.rs index 559b5b0..eb84833 100644 --- a/crates/core/src/stream.rs +++ b/crates/core/src/stream.rs @@ -34,6 +34,13 @@ impl StreamChunk { self.choices .first() .and_then(|choice| choice.delta.content.as_deref()) + .and_then(|content| { + if content.is_empty() { + None + } else { + Some(content) + } + }) } /// Get the reasoning content of the first choice @@ -41,6 +48,13 @@ impl StreamChunk { self.choices .first() .and_then(|choice| choice.delta.reasoning_content.as_deref()) + .and_then(|reasoning| { + if reasoning.is_empty() { + None + } else { + Some(reasoning) + } + }) } /// Get the tool calls of the first choice From c83024948fb992633e234ad34177cebca632dd3b Mon Sep 17 00:00:00 2001 From: clearloop Date: Fri, 19 Dec 2025 00:56:54 +0800 Subject: [PATCH 11/18] chore(clippy): make clippy happy --- README.md | 27 ++++++++++++++++++++++ crates/cli/src/agents/anto.rs | 43 ++++++++++++++++------------------- crates/cli/src/chat.rs | 4 ++-- crates/core/src/message.rs | 2 +- crates/core/src/stream.rs | 20 ++++------------ 5 files changed, 54 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index 210c98f..d6650ce 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,30 @@ # Cydonia The Agent framework. + +## Command line interface + +``` +$ cargo b -p ullm-cli +$ ./target/debug/ullm chat --help +Unified LLM Interface CLI + +Usage: ullm [OPTIONS] + +Commands: + chat Chat with an LLM + generate Generate the configuration file + help Print this message or the help of the given subcommand(s) + +Options: + -s, --stream Enable streaming mode + -v, --verbose... Verbosity level (use -v, -vv, -vvv, etc.) + -h, --help Print help + -V, --version Print version +``` + +For the ullm CLI, the config is located at `~/.config/ullm.toml`. + +## LICENSE + +GLP-3.0 diff --git a/crates/cli/src/agents/anto.rs b/crates/cli/src/agents/anto.rs index c925b41..4449b69 100644 --- a/crates/cli/src/agents/anto.rs +++ b/crates/cli/src/agents/anto.rs @@ -27,34 +27,31 @@ impl Agent for Anto { vec![Tool { name: "get_time".into(), description: "Gets the current UTC time in ISO 8601 format.".into(), - parameters: schemars::schema_for!(GetTimeParams).into(), + parameters: schemars::schema_for!(GetTimeParams), strict: true, }] } - fn dispatch(&self, tools: &[ToolCall]) -> impl Future> { - async move { - tools - .iter() - .map(|call| { - let result = match call.function.name.as_str() { - "get_time" => { - tracing::debug!("get_time arguments: {}", call.function.arguments); - let args = - serde_json::from_str::(&call.function.arguments) - .unwrap(); - if args.timestamp { - Utc::now().timestamp().to_string() - } else { - Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string() - } + async fn dispatch(&self, tools: &[ToolCall]) -> Vec { + tools + .iter() + .map(|call| { + let result = match call.function.name.as_str() { + "get_time" => { + tracing::debug!("get_time arguments: {}", call.function.arguments); + let args = serde_json::from_str::(&call.function.arguments) + .unwrap(); + if args.timestamp { + Utc::now().timestamp().to_string() + } else { + Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string() } - _ => format!("Unknown tool: {}", call.function.name), - }; - Message::tool(result, call.id.clone()) - }) - .collect() - } + } + _ => format!("Unknown tool: {}", call.function.name), + }; + Message::tool(result, call.id.clone()) + }) + .collect() } async fn chunk(&self, chunk: &StreamChunk) -> Result { diff --git a/crates/cli/src/chat.rs b/crates/cli/src/chat.rs index ef95c95..b3e59b4 100644 --- a/crates/cli/src/chat.rs +++ b/crates/cli/src/chat.rs @@ -102,7 +102,7 @@ impl ChatCmd { while let Some(Ok(chunk)) = stream.next().await { if let Some(content) = chunk.content() { if reasoning { - print!("\n\n\nCONTENT\n"); + println!("\n\n\nCONTENT"); reasoning = false; } print!("{content}"); @@ -111,7 +111,7 @@ impl ChatCmd { if let Some(reasoning_content) = chunk.reasoning_content() { if !reasoning { - print!("REASONING\n"); + println!("REASONING"); reasoning = true; } print!("{reasoning_content}"); diff --git a/crates/core/src/message.rs b/crates/core/src/message.rs index 7783658..eb0f854 100644 --- a/crates/core/src/message.rs +++ b/crates/core/src/message.rs @@ -62,7 +62,7 @@ impl Message { Self { role: Role::Assistant, content: content.into(), - reasoning_content: reasoning.unwrap_or_default().into(), + reasoning_content: reasoning.unwrap_or_default(), tool_calls: tool_calls.unwrap_or_default().to_vec(), ..Default::default() } diff --git a/crates/core/src/stream.rs b/crates/core/src/stream.rs index eb84833..be602d2 100644 --- a/crates/core/src/stream.rs +++ b/crates/core/src/stream.rs @@ -33,28 +33,16 @@ impl StreamChunk { pub fn content(&self) -> Option<&str> { self.choices .first() - .and_then(|choice| choice.delta.content.as_deref()) - .and_then(|content| { - if content.is_empty() { - None - } else { - Some(content) - } - }) + .and_then(|c| c.delta.content.as_deref()) + .filter(|s| !s.is_empty()) } /// Get the reasoning content of the first choice pub fn reasoning_content(&self) -> Option<&str> { self.choices .first() - .and_then(|choice| choice.delta.reasoning_content.as_deref()) - .and_then(|reasoning| { - if reasoning.is_empty() { - None - } else { - Some(reasoning) - } - }) + .and_then(|c| c.delta.reasoning_content.as_deref()) + .filter(|s| !s.is_empty()) } /// Get the tool calls of the first choice From 20ff5b0e0faba63ed27c7d642c09c8e3c65cc1f5 Mon Sep 17 00:00:00 2001 From: clearloop Date: Fri, 19 Dec 2025 01:05:42 +0800 Subject: [PATCH 12/18] feat(workspace): rebranding to cydonia --- Cargo.lock | 112 ++++++++++++------------- Cargo.toml | 15 ++-- crates/cli/Cargo.toml | 8 +- crates/cli/bin/{ullm.rs => cydonia.rs} | 2 +- crates/cli/src/agents/anto.rs | 2 +- crates/cli/src/chat.rs | 2 +- crates/cli/src/config.rs | 6 +- crates/core/Cargo.toml | 2 +- crates/core/tests/response.rs | 2 +- crates/{ullm => cydonia}/Cargo.toml | 4 +- crates/{ullm => cydonia}/src/lib.rs | 4 +- llm/deepseek/Cargo.toml | 4 +- llm/deepseek/src/lib.rs | 2 +- llm/deepseek/src/llm.rs | 6 +- llm/deepseek/src/request.rs | 2 +- 15 files changed, 86 insertions(+), 87 deletions(-) rename crates/cli/bin/{ullm.rs => cydonia.rs} (87%) rename crates/{ullm => cydonia}/Cargo.toml (85%) rename crates/{ullm => cydonia}/src/lib.rs (92%) diff --git a/Cargo.lock b/Cargo.lock index e4dd11d..13ac58d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -459,6 +459,14 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "cydonia" +version = "0.0.9" +dependencies = [ + "cydonia-core", + "cydonia-deepseek", +] + [[package]] name = "cydonia-candle" version = "0.0.0" @@ -475,6 +483,54 @@ dependencies = [ "tracing", ] +[[package]] +name = "cydonia-cli" +version = "0.0.9" +dependencies = [ + "anyhow", + "chrono", + "clap", + "cydonia", + "dirs", + "futures-util", + "schemars", + "serde", + "serde_json", + "tokio", + "toml", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "cydonia-core" +version = "0.0.9" +dependencies = [ + "anyhow", + "async-stream", + "derive_more", + "futures-core", + "futures-util", + "reqwest", + "schemars", + "serde", + "serde_json", +] + +[[package]] +name = "cydonia-deepseek" +version = "0.0.9" +dependencies = [ + "anyhow", + "async-stream", + "cydonia-core", + "futures-core", + "futures-util", + "serde", + "serde_json", + "tracing", +] + [[package]] name = "cydonia-model" version = "0.0.0" @@ -2993,62 +3049,6 @@ dependencies = [ "yoke 0.7.5", ] -[[package]] -name = "ullm" -version = "0.0.9" -dependencies = [ - "ullm-core", - "ullm-deepseek", -] - -[[package]] -name = "ullm-cli" -version = "0.0.9" -dependencies = [ - "anyhow", - "chrono", - "clap", - "dirs", - "futures-util", - "schemars", - "serde", - "serde_json", - "tokio", - "toml", - "tracing", - "tracing-subscriber", - "ullm", -] - -[[package]] -name = "ullm-core" -version = "0.0.9" -dependencies = [ - "anyhow", - "async-stream", - "derive_more", - "futures-core", - "futures-util", - "reqwest", - "schemars", - "serde", - "serde_json", -] - -[[package]] -name = "ullm-deepseek" -version = "0.0.9" -dependencies = [ - "anyhow", - "async-stream", - "futures-core", - "futures-util", - "serde", - "serde_json", - "tracing", - "ullm-core", -] - [[package]] name = "unicode-ident" version = "1.0.22" diff --git a/Cargo.toml b/Cargo.toml index a8bec18..c4b069c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,12 +12,12 @@ documentation = "https://cydonia.docs.rs" keywords = ["llm", "agent", "ai"] [workspace.dependencies] -candle = { path = "crates/candle", package = "cydonia-candle" } -deepseek = { path = "llm/deepseek", package = "ullm-deepseek" } -model = { path = "legacy/model", package = "cydonia-model" } -ullm = { path = "crates/ullm" } -ucore = { path = "crates/core", package = "ullm-core" } -ucli = { path = "crates/cli", package = "ullm-cli" } +candle = { path = "crates/candle", package = "cydonia-candle", version = "0.0.9" } +cli = { path = "crates/cli", package = "cydonia-cli", version = "0.0.9" } +deepseek = { path = "llm/deepseek", package = "cydonia-deepseek", version = "0.0.9" } +model = { path = "legacy/model", package = "cydonia-model", version = "0.0.9" } +cydonia = { path = "crates/cydonia", version = "0.0.9" } +ccore = { path = "crates/core", package = "cydonia-core", version = "0.0.9" } # crates.io anyhow = "1" @@ -48,3 +48,6 @@ llamac-sys = { version = "0.1.86", package = "llama-cpp-sys-2" } once_cell = "1.21" rand = "0.9.2" tokenizers = "0.21.0" + +[workspace.metadata.conta] +packages = ["ccore", "deepseek", "cydonia", "cli"] diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 0bbc271..4978aeb 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "ullm-cli" +name = "cydonia-cli" version.workspace = true edition.workspace = true authors.workspace = true @@ -8,12 +8,8 @@ repository.workspace = true documentation.workspace = true keywords.workspace = true -[[bin]] -name = "ullm" -path = "bin/ullm.rs" - [dependencies] -ullm.workspace = true +cydonia.workspace = true # crates-io dependencies anyhow.workspace = true diff --git a/crates/cli/bin/ullm.rs b/crates/cli/bin/cydonia.rs similarity index 87% rename from crates/cli/bin/ullm.rs rename to crates/cli/bin/cydonia.rs index 87fe547..2a51b25 100644 --- a/crates/cli/bin/ullm.rs +++ b/crates/cli/bin/cydonia.rs @@ -1,6 +1,6 @@ use anyhow::Result; use clap::Parser; -use ullm_cli::{App, Command, Config}; +use cydonia_cli::{App, Command, Config}; #[tokio::main] async fn main() -> Result<()> { diff --git a/crates/cli/src/agents/anto.rs b/crates/cli/src/agents/anto.rs index 4449b69..de80bb0 100644 --- a/crates/cli/src/agents/anto.rs +++ b/crates/cli/src/agents/anto.rs @@ -2,9 +2,9 @@ use anyhow::Result; use chrono::Utc; +use cydonia::{Agent, Message, StreamChunk, Tool, ToolCall}; use schemars::JsonSchema; use serde::Deserialize; -use ullm::{Agent, Message, StreamChunk, Tool, ToolCall}; /// Anto - a basic agent with tools for testing tool calls #[derive(Clone)] diff --git a/crates/cli/src/chat.rs b/crates/cli/src/chat.rs index b3e59b4..51e92ba 100644 --- a/crates/cli/src/chat.rs +++ b/crates/cli/src/chat.rs @@ -4,12 +4,12 @@ use super::Config; use crate::agents::{AgentKind, Anto}; use anyhow::Result; use clap::{Args, ValueEnum}; +use cydonia::{Agent, Chat, Client, DeepSeek, LLM, Message, StreamChunk}; use futures_util::StreamExt; use std::{ fmt::{Display, Formatter}, io::{BufRead, Write}, }; -use ullm::{Agent, Chat, Client, DeepSeek, LLM, Message, StreamChunk}; /// Chat command arguments #[derive(Debug, Args)] diff --git a/crates/cli/src/config.rs b/crates/cli/src/config.rs index db7e68e..3880276 100644 --- a/crates/cli/src/config.rs +++ b/crates/cli/src/config.rs @@ -10,7 +10,7 @@ static CONFIG: LazyLock = #[derive(Debug, Clone, Deserialize, Serialize)] pub struct Config { /// The configuration for the CLI - pub config: ullm::General, + pub config: cydonia::General, /// The API keys for LLMs pub key: BTreeMap, @@ -31,7 +31,7 @@ impl Config { } /// Get the core config - pub fn config(&self) -> &ullm::General { + pub fn config(&self) -> &cydonia::General { &self.config } } @@ -39,7 +39,7 @@ impl Config { impl Default for Config { fn default() -> Self { Self { - config: ullm::General::default(), + config: cydonia::General::default(), key: [("deepseek".to_string(), "YOUR_API_KEY".to_string())] .into_iter() .collect::<_>(), diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 52fb1e1..cf8294f 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "ullm-core" +name = "cydonia-core" version.workspace = true edition.workspace = true authors.workspace = true diff --git a/crates/core/tests/response.rs b/crates/core/tests/response.rs index 607b42f..15b9cff 100644 --- a/crates/core/tests/response.rs +++ b/crates/core/tests/response.rs @@ -1,6 +1,6 @@ //! Tests for the response module -use ullm_core::{Response, StreamChunk}; +use cydonia_core::{Response, StreamChunk}; const DEEPSEEK_RESPONSE_JSON: &str = include_str!("../templates/deepseek/response.json"); const DEEPSEEK_STREAM_CHUNK_JSON: &str = include_str!("../templates/deepseek/stream.json"); diff --git a/crates/ullm/Cargo.toml b/crates/cydonia/Cargo.toml similarity index 85% rename from crates/ullm/Cargo.toml rename to crates/cydonia/Cargo.toml index 5dfdaa5..a6b93a2 100644 --- a/crates/ullm/Cargo.toml +++ b/crates/cydonia/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "ullm" +name = "cydonia" version.workspace = true edition.workspace = true authors.workspace = true @@ -10,4 +10,4 @@ keywords.workspace = true [dependencies] deepseek.workspace = true -ucore.workspace = true +ccore.workspace = true diff --git a/crates/ullm/src/lib.rs b/crates/cydonia/src/lib.rs similarity index 92% rename from crates/ullm/src/lib.rs rename to crates/cydonia/src/lib.rs index 89ad822..1410e62 100644 --- a/crates/ullm/src/lib.rs +++ b/crates/cydonia/src/lib.rs @@ -2,7 +2,7 @@ //! //! This is the umbrella crate that re-exports all ullm components. -pub use deepseek::DeepSeek; -pub use ucore::{ +pub use ccore::{ self, Agent, Chat, Client, Config, General, LLM, Message, StreamChunk, Tool, ToolCall, }; +pub use deepseek::DeepSeek; diff --git a/llm/deepseek/Cargo.toml b/llm/deepseek/Cargo.toml index 613940f..39973c5 100644 --- a/llm/deepseek/Cargo.toml +++ b/llm/deepseek/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "ullm-deepseek" +name = "cydonia-deepseek" version.workspace = true edition.workspace = true authors.workspace = true @@ -9,7 +9,7 @@ documentation.workspace = true keywords.workspace = true [dependencies] -ucore.workspace = true +ccore.workspace = true # crates-io dependencies anyhow.workspace = true diff --git a/llm/deepseek/src/lib.rs b/llm/deepseek/src/lib.rs index c4c9461..23c3315 100644 --- a/llm/deepseek/src/lib.rs +++ b/llm/deepseek/src/lib.rs @@ -1,7 +1,7 @@ //! The LLM provider +use ccore::{Client, reqwest::header::HeaderMap}; pub use request::Request; -use ucore::{Client, reqwest::header::HeaderMap}; mod llm; mod request; diff --git a/llm/deepseek/src/llm.rs b/llm/deepseek/src/llm.rs index 4e6c6f7..472cceb 100644 --- a/llm/deepseek/src/llm.rs +++ b/llm/deepseek/src/llm.rs @@ -3,15 +3,15 @@ use crate::{DeepSeek, Request}; use anyhow::Result; use async_stream::try_stream; -use futures_core::Stream; -use futures_util::StreamExt; -use ucore::{ +use ccore::{ Client, LLM, Message, Response, StreamChunk, reqwest::{ Method, header::{self, HeaderMap}, }, }; +use futures_core::Stream; +use futures_util::StreamExt; const ENDPOINT: &str = "https://api.deepseek.com/chat/completions"; diff --git a/llm/deepseek/src/request.rs b/llm/deepseek/src/request.rs index 8cd478e..cbf49c3 100644 --- a/llm/deepseek/src/request.rs +++ b/llm/deepseek/src/request.rs @@ -1,8 +1,8 @@ //! The request body for the DeepSeek API +use ccore::{Config, General, Message, Tool, ToolChoice}; use serde::Serialize; use serde_json::{Value, json}; -use ucore::{Config, General, Message, Tool, ToolChoice}; /// The request body for the DeepSeek API #[derive(Debug, Clone, Serialize)] From bbc8327fcc46784b0043ad01dea22092ebb63424 Mon Sep 17 00:00:00 2001 From: clearloop Date: Fri, 19 Dec 2025 01:07:51 +0800 Subject: [PATCH 13/18] chore(packages): add descriptions --- Cargo.lock | 4 ++-- README.md | 23 ----------------------- crates/cli/Cargo.toml | 1 + crates/core/Cargo.toml | 1 + crates/cydonia/Cargo.toml | 1 + legacy/candle/Cargo.toml | 9 +++++++-- legacy/model/Cargo.toml | 10 ++++++++-- 7 files changed, 20 insertions(+), 29 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 13ac58d..b652ea1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -469,7 +469,7 @@ dependencies = [ [[package]] name = "cydonia-candle" -version = "0.0.0" +version = "0.0.9" dependencies = [ "anyhow", "candle-core", @@ -533,7 +533,7 @@ dependencies = [ [[package]] name = "cydonia-model" -version = "0.0.0" +version = "0.0.9" dependencies = [ "anyhow", "serde", diff --git a/README.md b/README.md index d6650ce..32d8416 100644 --- a/README.md +++ b/README.md @@ -2,29 +2,6 @@ The Agent framework. -## Command line interface - -``` -$ cargo b -p ullm-cli -$ ./target/debug/ullm chat --help -Unified LLM Interface CLI - -Usage: ullm [OPTIONS] - -Commands: - chat Chat with an LLM - generate Generate the configuration file - help Print this message or the help of the given subcommand(s) - -Options: - -s, --stream Enable streaming mode - -v, --verbose... Verbosity level (use -v, -vv, -vvv, etc.) - -h, --help Print help - -V, --version Print version -``` - -For the ullm CLI, the config is located at `~/.config/ullm.toml`. - ## LICENSE GLP-3.0 diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 4978aeb..e4bbc67 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -1,5 +1,6 @@ [package] name = "cydonia-cli" +description = "Cydonia command line interfaces" version.workspace = true edition.workspace = true authors.workspace = true diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index cf8294f..e831b14 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -1,5 +1,6 @@ [package] name = "cydonia-core" +description = "Cydonia core abstractions" version.workspace = true edition.workspace = true authors.workspace = true diff --git a/crates/cydonia/Cargo.toml b/crates/cydonia/Cargo.toml index a6b93a2..dad99eb 100644 --- a/crates/cydonia/Cargo.toml +++ b/crates/cydonia/Cargo.toml @@ -1,5 +1,6 @@ [package] name = "cydonia" +description = "Cydonia umbrella crate" version.workspace = true edition.workspace = true authors.workspace = true diff --git a/legacy/candle/Cargo.toml b/legacy/candle/Cargo.toml index 8139948..5dfeac4 100644 --- a/legacy/candle/Cargo.toml +++ b/legacy/candle/Cargo.toml @@ -1,8 +1,13 @@ [package] name = "cydonia-candle" -version = "0.0.0" -edition = "2021" description = "Cydonia candle utils re-exports" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +documentation.workspace = true +keywords.workspace = true [dependencies] anyhow.workspace = true diff --git a/legacy/model/Cargo.toml b/legacy/model/Cargo.toml index fe22c22..43c7739 100644 --- a/legacy/model/Cargo.toml +++ b/legacy/model/Cargo.toml @@ -1,7 +1,13 @@ [package] name = "cydonia-model" -version = "0.0.0" -edition = "2021" +description = "Cydonia model utils" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +documentation.workspace = true +keywords.workspace = true [dependencies] anyhow.workspace = true From 49d0092b8fa7a948a8f18ba307959ce8252943a5 Mon Sep 17 00:00:00 2001 From: clearloop Date: Fri, 19 Dec 2025 01:11:12 +0800 Subject: [PATCH 14/18] docs(README): introduce readmes --- README.md | 34 +++++++++++++++++++++++++++++++--- crates/cli/README.md | 19 +++++++++++++++++++ crates/core/README.md | 18 ++++++++++++++++++ crates/cydonia/README.md | 19 +++++++++++++++++++ llm/deepseek/README.md | 24 ++++++++++++++++++++++++ 5 files changed, 111 insertions(+), 3 deletions(-) create mode 100644 crates/cli/README.md create mode 100644 crates/core/README.md create mode 100644 crates/cydonia/README.md create mode 100644 llm/deepseek/README.md diff --git a/README.md b/README.md index 32d8416..f3e6783 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,35 @@ # Cydonia -The Agent framework. +Unified LLM Interface - A Rust framework for building LLM-powered agents. -## LICENSE +## Features -GLP-3.0 +- Unified interface for multiple LLM providers +- Streaming support with thinking mode +- Tool calling with automatic argument accumulation +- Agent framework for building autonomous assistants + +## Crates + +| Crate | Description | +|-------|-------------| +| `cydonia` | Umbrella crate re-exporting all components | +| `cydonia-core` | Core abstractions (LLM, Agent, Chat, Message) | +| `cydonia-deepseek` | DeepSeek provider implementation | +| `cydonia-cli` | Command line interface | + +## Quick Start + +```rust +use cydonia::{Chat, DeepSeek, LLM, Message}; + +let client = reqwest::Client::new(); +let provider = DeepSeek::new(client, "your-api-key")?; +let mut chat = provider.chat(config); + +let response = chat.send(Message::user("Hello!")).await?; +``` + +## License + +MIT diff --git a/crates/cli/README.md b/crates/cli/README.md new file mode 100644 index 0000000..64ceae3 --- /dev/null +++ b/crates/cli/README.md @@ -0,0 +1,19 @@ +# cydonia-cli + +Command line interface for cydonia. + +## Overview + +Provides CLI tools for interacting with LLMs, including: + +- Chat interface with streaming support +- Agent framework with tool calling +- Configuration management + +## Agents + +- `Anto` - Test agent with `get_time` tool for verifying tool calling + +## License + +MIT diff --git a/crates/core/README.md b/crates/core/README.md new file mode 100644 index 0000000..3f7ffac --- /dev/null +++ b/crates/core/README.md @@ -0,0 +1,18 @@ +# cydonia-core + +Core abstractions for the Unified LLM Interface. + +## Overview + +This crate provides the foundational types and traits for building LLM applications: + +- `LLM` - Provider trait for LLM backends +- `Agent` - Trait for building tool-using agents +- `Chat` - Chat session management with streaming support +- `Message` - Chat message types (user, assistant, system, tool) +- `Tool` / `ToolCall` - Function calling abstractions +- `StreamChunk` - Streaming response handling + +## License + +MIT diff --git a/crates/cydonia/README.md b/crates/cydonia/README.md new file mode 100644 index 0000000..81c9afb --- /dev/null +++ b/crates/cydonia/README.md @@ -0,0 +1,19 @@ +# cydonia + +Unified LLM Interface - the umbrella crate. + +## Overview + +This crate re-exports all cydonia components for convenient usage: + +```rust +use cydonia::{Agent, Chat, DeepSeek, LLM, Message}; +``` + +## Supported Providers + +- DeepSeek (`deepseek-chat`, `deepseek-reasoner`) + +## License + +MIT diff --git a/llm/deepseek/README.md b/llm/deepseek/README.md new file mode 100644 index 0000000..87d932b --- /dev/null +++ b/llm/deepseek/README.md @@ -0,0 +1,24 @@ +# cydonia-deepseek + +DeepSeek LLM provider for cydonia. + +## Overview + +Implements the `LLM` trait for DeepSeek API, supporting: + +- `deepseek-chat` - Standard chat model +- `deepseek-reasoner` - Reasoning model with thinking mode +- Streaming responses +- Tool calling with thinking mode + +## Usage + +```rust +use cydonia::{DeepSeek, LLM}; + +let provider = DeepSeek::new(client, "your-api-key")?; +``` + +## License + +MIT From c70e7a50b82c3020ba12efc48586c8f66b11e9eb Mon Sep 17 00:00:00 2001 From: clearloop Date: Fri, 19 Dec 2025 01:13:07 +0800 Subject: [PATCH 15/18] chore(clippy): make clippy happy --- legacy/candle/src/device.rs | 2 +- legacy/candle/src/inference.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/legacy/candle/src/device.rs b/legacy/candle/src/device.rs index 5183244..5b0cb01 100644 --- a/legacy/candle/src/device.rs +++ b/legacy/candle/src/device.rs @@ -1,6 +1,6 @@ //! Device detection -use candle_core::{utils, Device}; +use candle_core::{Device, utils}; /// Detect the device pub fn detect(cpu: bool) -> anyhow::Result { diff --git a/legacy/candle/src/inference.rs b/legacy/candle/src/inference.rs index 0e34efd..397d4ec 100644 --- a/legacy/candle/src/inference.rs +++ b/legacy/candle/src/inference.rs @@ -1,8 +1,8 @@ //! Cydonia inference interface use anyhow::Result; -use candle_core::{quantized::gguf_file::Content, Device, Tensor}; +use candle_core::{Device, Tensor, quantized::gguf_file::Content}; use candle_transformers::models::quantized_llama; -use model::{chat, Message}; +use model::{Message, chat}; use std::fs::File; /// The inference interface for language models From 37f5adb96a489348af11e192b0efd185f6f201a1 Mon Sep 17 00:00:00 2001 From: clearloop Date: Fri, 19 Dec 2025 01:16:43 +0800 Subject: [PATCH 16/18] chore(workspace): correct links of docs --- Cargo.toml | 3 +-- crates/cli/Cargo.toml | 2 +- crates/core/Cargo.toml | 2 +- crates/cydonia/Cargo.toml | 2 +- llm/deepseek/Cargo.toml | 3 ++- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index c4b069c..dee1ba7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,9 +6,8 @@ members = ["crates/*", "llm/*", "legacy/*"] version = "0.0.9" edition = "2024" authors = ["clearloop "] -license = "MIT" +license = "GPL-3.0" repository = "https://github.com/clearloop/cydonia" -documentation = "https://cydonia.docs.rs" keywords = ["llm", "agent", "ai"] [workspace.dependencies] diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index e4bbc67..a1cc846 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -1,12 +1,12 @@ [package] name = "cydonia-cli" description = "Cydonia command line interfaces" +documentation = "https://docs.rs/cydonia-cli" version.workspace = true edition.workspace = true authors.workspace = true license.workspace = true repository.workspace = true -documentation.workspace = true keywords.workspace = true [dependencies] diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index e831b14..53e61a2 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -1,12 +1,12 @@ [package] name = "cydonia-core" description = "Cydonia core abstractions" +documentation = "https://docs.rs/cydonia-core" version.workspace = true edition.workspace = true authors.workspace = true license.workspace = true repository.workspace = true -documentation.workspace = true keywords.workspace = true [dependencies] diff --git a/crates/cydonia/Cargo.toml b/crates/cydonia/Cargo.toml index dad99eb..73bfc64 100644 --- a/crates/cydonia/Cargo.toml +++ b/crates/cydonia/Cargo.toml @@ -1,12 +1,12 @@ [package] name = "cydonia" description = "Cydonia umbrella crate" +documentation = "https://docs.rs/cydonia" version.workspace = true edition.workspace = true authors.workspace = true license.workspace = true repository.workspace = true -documentation.workspace = true keywords.workspace = true [dependencies] diff --git a/llm/deepseek/Cargo.toml b/llm/deepseek/Cargo.toml index 39973c5..de005cc 100644 --- a/llm/deepseek/Cargo.toml +++ b/llm/deepseek/Cargo.toml @@ -1,11 +1,12 @@ [package] name = "cydonia-deepseek" +description = "Cydonia DeepSeek provider implementation" +documentation = "https://docs.rs/cydonia-deepseek" version.workspace = true edition.workspace = true authors.workspace = true license.workspace = true repository.workspace = true -documentation.workspace = true keywords.workspace = true [dependencies] From d50a71c9f34e66b1035ffd11212a69dde53c2c2e Mon Sep 17 00:00:00 2001 From: clearloop Date: Fri, 19 Dec 2025 01:18:34 +0800 Subject: [PATCH 17/18] chore(legacy): update docs --- legacy/candle/Cargo.toml | 2 +- legacy/model/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/legacy/candle/Cargo.toml b/legacy/candle/Cargo.toml index 5dfeac4..dbc4811 100644 --- a/legacy/candle/Cargo.toml +++ b/legacy/candle/Cargo.toml @@ -1,12 +1,12 @@ [package] name = "cydonia-candle" description = "Cydonia candle utils re-exports" +documentation = "https://docs.rs/cydonia-candle" version.workspace = true edition.workspace = true authors.workspace = true license.workspace = true repository.workspace = true -documentation.workspace = true keywords.workspace = true [dependencies] diff --git a/legacy/model/Cargo.toml b/legacy/model/Cargo.toml index 43c7739..c27941f 100644 --- a/legacy/model/Cargo.toml +++ b/legacy/model/Cargo.toml @@ -1,12 +1,12 @@ [package] name = "cydonia-model" description = "Cydonia model utils" +documentation = "https://docs.rs/cydonia-model" version.workspace = true edition.workspace = true authors.workspace = true license.workspace = true repository.workspace = true -documentation.workspace = true keywords.workspace = true [dependencies] From 75dc9ce7cb896840dfb42a444c04e5eddcef1072 Mon Sep 17 00:00:00 2001 From: clearloop Date: Fri, 19 Dec 2025 01:25:03 +0800 Subject: [PATCH 18/18] chore(core): update tests for deepseek responses --- crates/core/src/tool.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/core/src/tool.rs b/crates/core/src/tool.rs index 72e44d8..25e015a 100644 --- a/crates/core/src/tool.rs +++ b/crates/core/src/tool.rs @@ -27,7 +27,7 @@ pub struct ToolCall { pub id: String, /// The index of the tool call (used in streaming) - #[serde(skip_serializing)] + #[serde(default, skip_serializing)] pub index: u32, /// The type of tool (currently only "function")