first commit

This commit is contained in:
aiyezi committed 2026-11-05 17:33:18 +08:00
commit 91b44fcc16
27 files changed
+4104

No files matched your search

+1
View File
@@ -0,0 +1 @@
/target
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "agent-core"
version = "0.1.0"
edition = "2024"
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
async-trait = { workspace = true }
reqwest = { workspace = true }
+2
View File
@@ -0,0 +1,2 @@
mod react;
pub use react::{ReActAgent, ReActAgentBuilder};
+146
View File
@@ -0,0 +1,146 @@
use std::sync::Arc;
use crate::{
AgentError, ChatRequest, LlmProvider, Message, Role,
message::ToolCall,
provider::ToolSpce,
tool::{Tool, ToolRegistry},
};
pub struct ReActAgent {
provider: Arc<dyn LlmProvider>,
model: String,
tools: ToolRegistry,
system_prompt: Option<String>,
max_iterations: usize,
temperature: Option<f32>,
}
pub struct ReActOutput {
pub final_message: Message,
pub history: Vec<Message>,
pub iterations: usize,
}
impl ReActAgent {
pub fn builder(provider: Arc<dyn LlmProvider>, model: impl Into<String>) -> ReActAgentBuilder {
ReActAgentBuilder {
provider,
model: model.into(),
tools: ToolRegistry::new(),
system_prompt: None,
max_iterations: 5,
temperature: None,
}
}
pub async fn run(&self, user_input: impl Into<String>) -> Result<ReActOutput, AgentError> {
let mut history: Vec<Message> = Vec::new();
if let Some(prompt) = &self.system_prompt {
history.push(Message::system(prompt));
}
history.push(Message::user(user_input.into()));
let tool_specs: Vec<ToolSpce> = if self.tools.is_empty() {
Vec::new()
} else {
self.tools.spec()
};
for iteration in 0..self.max_iterations {
let request = ChatRequest {
model: self.model.clone(),
messages: history.clone(),
temperature: self.temperature,
max_tokens: None,
tools: tool_specs.clone(),
};
let response = self.provider.chat(request).await?;
if !response.tool_calls.is_empty() {
history.push(Message {
role: Role::Assistant,
content: response.content,
tool_calls: Some(response.tool_calls.clone()),
tool_call_id: None,
});
for call in &response.tool_calls {
let result = self.execute_tool(call).await;
history.push(Message::tool(result, call.id.clone()));
}
continue;
}
let content = response.content.unwrap_or_default();
let final_message = Message::assistant(content);
history.push(final_message.clone());
return Ok(ReActOutput {
final_message,
history,
iterations: iteration + 1,
});
}
Err(AgentError::Provider(format!(
"agent exceeded max_iterations {}",
self.max_iterations
)))
}
async fn execute_tool(&self, call: &ToolCall) -> String {
let Some(tool) = self.tools.get(&call.name) else {
return format!("Error: tool {} not found", call.name);
};
match tool.run(&call.arguments).await {
Ok(out) => out,
Err(e) => format!("Error executing tool {}: {}", call.name, e),
}
}
}
pub struct ReActAgentBuilder {
provider: Arc<dyn LlmProvider>,
model: String,
tools: ToolRegistry,
system_prompt: Option<String>,
max_iterations: usize,
temperature: Option<f32>,
}
impl ReActAgentBuilder {
pub fn tool(mut self, tool: Arc<dyn Tool>) -> Self {
self.tools.register(tool);
self
}
pub fn system_prompt(mut self, prompt: impl Into<String>) -> Self {
self.system_prompt = Some(prompt.into());
self
}
pub fn max_iterations(mut self, n: usize) -> Self {
self.max_iterations = n;
self
}
pub fn temperature(mut self, temperature: f32) -> Self {
self.temperature = Some(temperature);
self
}
pub fn build(self) -> ReActAgent {
ReActAgent {
provider: self.provider,
tools: self.tools,
model: self.model,
system_prompt: self.system_prompt,
max_iterations: self.max_iterations,
temperature: self.temperature,
}
}
}
+22
View File
@@ -0,0 +1,22 @@
use thiserror::Error;
#[derive(Debug, Error)]
pub enum AgentError {
#[error("provider error: {0}")]
Provider(String),
#[error("http errpr: {0}")]
Http(#[from] reqwest::Error),
#[error("serialization error {0}")]
Serialization(#[from] serde_json::Error),
#[error("Api error {status}: {message}")]
Api { status: u16, message: String },
}
impl From<std::fmt::Error> for AgentError {
fn from(err: std::fmt::Error) -> Self {
AgentError::Provider(format!("write fail: {}", err))
}
}
+3
View File
@@ -0,0 +1,3 @@
pub enum AgentEvent {
SessionCreated { session_id: SessionId },
}
+27
View File
@@ -0,0 +1,27 @@
mod agent;
mod error;
mod event;
mod message;
mod provider;
mod tool;
pub use agent::{ReActAgent, ReActAgentBuilder};
pub use error::AgentError;
pub use message::{Message, Role, ToolCall};
pub use provider::{ChatRequest, ChatResponse, LlmProvider};
pub use tool::Tool;
pub fn add(left: u64, right: u64) -> u64 {
left + right
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_works() {
let result = add(2, 2);
assert_eq!(result, 4);
}
}
+59
View File
@@ -0,0 +1,59 @@
use serde::{Deserialize, Serialize, de};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum Role {
#[default]
System,
User,
Assistant,
Tool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Message {
pub role: Role,
pub content: Option<String>,
pub tool_calls: Option<Vec<ToolCall>>,
pub tool_call_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolCall {
pub id: String,
pub name: String,
pub arguments: String,
}
impl Message {
pub fn system(content: impl Into<String>) -> Self {
Self {
content: Some(content.into()),
..Default::default()
}
}
pub fn user(content: impl Into<String>) -> Self {
Self {
role: Role::User,
content: Some(content.into()),
..Default::default()
}
}
pub fn assistant(content: impl Into<String>) -> Self {
Self {
role: Role::Assistant,
content: Some(content.into()),
..Default::default()
}
}
pub fn tool(content: impl Into<String>, tool_call_id: impl Into<String>) -> Self {
Self {
role: Role::Tool,
content: Some(content.into()),
tool_call_id: Some(tool_call_id.into()),
..Default::default()
}
}
}
+34
View File
@@ -0,0 +1,34 @@
use crate::{
error::AgentError,
message::{Message, ToolCall},
};
#[derive(Debug, Clone)]
pub struct ChatRequest {
pub model: String,
pub messages: Vec<Message>,
pub temperature: Option<f32>,
pub max_tokens: Option<u32>,
pub tools: Vec<ToolSpce>,
}
#[derive(Debug, Clone)]
pub struct ChatResponse {
pub content: Option<String>,
pub model: String,
pub prompt_tokens: Option<u32>,
pub completion_tokens: Option<u32>,
pub tool_calls: Vec<ToolCall>,
}
#[derive(Debug, Clone)]
pub struct ToolSpce {
pub name: String,
pub description: String,
pub parameters: serde_json::Value,
}
#[async_trait::async_trait]
pub trait LlmProvider: Send + Sync {
async fn chat(&self, request: ChatRequest) -> Result<ChatResponse, AgentError>;
}
+47
View File
@@ -0,0 +1,47 @@
use std::{collections::HashMap, sync::Arc};
use async_trait::async_trait;
use crate::{AgentError, provider::ToolSpce};
#[async_trait]
pub trait Tool: Send + Sync {
fn name(&self) -> &str;
fn description(&self) -> &str;
fn parameters(&self) -> serde_json::Value;
async fn run(&self, arguments: &str) -> Result<String, AgentError>;
fn spec(&self) -> ToolSpce {
ToolSpce {
name: self.name().to_string(),
description: self.description().to_string(),
parameters: self.parameters(),
}
}
}
#[derive(Default, Clone)]
pub struct ToolRegistry {
tools: HashMap<String, Arc<dyn Tool>>,
}
impl ToolRegistry {
pub fn new() -> ToolRegistry {
Self::default()
}
pub fn register(&mut self, tool: Arc<dyn Tool>) {
self.tools.insert(tool.name().into(), tool);
}
pub fn get(&self, name: &str) -> Option<Arc<dyn Tool>> {
self.tools.get(name).cloned()
}
pub fn spec(&self) -> Vec<ToolSpce> {
self.tools.values().map(|t| t.spec()).collect()
}
pub fn is_empty(&self) -> bool {
self.tools.is_empty()
}
}
Submodule crates/agent-providers added at d360b650d8.
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "agent-tools"
version = "0.1.0"
edition = "2024"
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
agent-core = { path = "../agent-core" }
async-trait = { workspace = true }
jiff = "0.2.37"
+2
View File
@@ -0,0 +1,2 @@
mod read;
pub use read::ReadFileTool;
+99
View File
@@ -0,0 +1,99 @@
use std::{
fmt::Write,
fs::File,
io::{BufRead, BufReader},
};
use agent_core::{AgentError, Tool};
use async_trait::async_trait;
use serde::Deserialize;
pub struct ReadFileTool;
#[derive(Deserialize)]
struct ToolArgs {
pub path: String,
pub range: Option<(usize, usize)>,
}
#[async_trait]
impl Tool for ReadFileTool {
fn name(&self) -> &str {
"read_file"
}
fn description(&self) -> &str {
"Reads the contents of a file at the specified path."
}
fn parameters(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "The path to the file to read."
},
"range": {
"type": "array",
"description": "Optional line range [start, count], Number of lines to read. For example, start=100, count=50 reads lines 100 to 149 (inclusive)",
"items": {
"type": "integer"
},
"maxItems": 2,
"minItems": 2
}
},
"required": ["path"]
})
}
async fn run(&self, arguments: &str) -> Result<String, AgentError> {
let args: ToolArgs = serde_json::from_str(arguments)
.map_err(|e| AgentError::Provider(format!("Invalid arguments {e}")))?;
let file =
File::open(args.path).map_err(|e| AgentError::Provider(format!("Invalid path {e}")))?;
read_line(args.range, file)
}
}
fn read_line(range: Option<(usize, usize)>, file: File) -> Result<String, AgentError> {
let reader = BufReader::new(file);
let mut ans = String::new();
match range {
Some((start, count)) => {
if start == 0 {
return Err(AgentError::Provider(format!(
"Invalid arguments: start must be >= 1, got {}",
start
)));
}
if count > 200 {
return Err(AgentError::Provider(format!(
"Invalid arguments: count must be <= 200, got {}",
count
)));
}
for (i, line) in reader.lines().skip(start - 1).take(count).enumerate() {
let line =
line.map_err(|e| AgentError::Provider(format!("failed to read file: {}", e)))?;
writeln!(ans, "[{}]: {}", start + i, line)?
}
}
None => {
// 明确上限,避免模型无界读取
const MAX_LINES: usize = 1000;
for (i, line) in reader.lines().take(MAX_LINES).enumerate() {
let line =
line.map_err(|e| AgentError::Provider(format!("failed to read file: {}", e)))?;
writeln!(ans, "[{}]: {}", i + 1, line)?
}
}
}
Ok(ans)
}
+19
View File
@@ -0,0 +1,19 @@
mod fs;
mod time;
pub use fs::ReadFileTool;
pub use time::CurrentTimeTool;
pub fn add(left: u64, right: u64) -> u64 {
left + right
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_works() {
let result = add(2, 2);
assert_eq!(result, 4);
}
}
+60
View File
@@ -0,0 +1,60 @@
use agent_core::{AgentError, Tool};
use async_trait::async_trait;
use jiff::Zoned;
use serde::Deserialize;
pub struct CurrentTimeTool;
#[derive(Deserialize, Default)]
pub struct TimeArgs {
#[serde(default)]
timezone: Option<String>,
}
#[async_trait]
impl Tool for CurrentTimeTool {
fn name(&self) -> &str {
"get_current_time"
}
fn description(&self) -> &str {
"get system now time"
}
fn parameters(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"timezone": {
"type": "string",
"description": "Optional IANA timezone name (e.g., 'Asia/Shanghai', 'America/New_York', 'UTC'). If not provided, the local system timezone is used."
}
}
})
}
async fn run(&self, arguments: &str) -> Result<String, AgentError> {
let args: TimeArgs = if arguments.trim().is_empty() {
TimeArgs::default()
} else {
serde_json::from_str(arguments)
.map_err(|e| AgentError::Provider(format!("invaild argments {e}")))?
};
let now = Zoned::now();
let target_time = match args.timezone.as_deref() {
Some(tz) if !tz.trim().is_empty() => now
.in_tz(tz)
.map_err(|e| AgentError::Provider(format!("invalid timezone '{tz}': {e}")))?,
_ => now,
};
let formatted = format!(
"{} [{}]",
target_time.strftime("%Y-%m-%d %H:%M:%S %A"),
target_time.time_zone().iana_name().unwrap_or("Unknown")
);
Ok(formatted)
}
}
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "agent-tui"
version = "0.1.0"
edition = "2024"
[dependencies]
agent-core = { path = "../agent-core" }
crossterm = { workspace = true }
ratatui = { workspace = true }
color-eyre = { workspace = true }
dotenvy = { workspace = true }
+13
View File
@@ -0,0 +1,13 @@
use std::sync::Arc;
use agent_core::ReActAgent;
use agent_providers::ChatCompletionsProvider;
use agent_tui::App;
use color_eyre::eyre::Ok;
fn main() -> color_eyre::Result<()> {
color_eyre::install()?;
let app = App::default();
app.run()?;
Ok(())
}
+17
View File
@@ -0,0 +1,17 @@
mod ui;
pub use ui::App;
pub fn add(left: u64, right: u64) -> u64 {
left + right
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_works() {
let result = add(2, 2);
assert_eq!(result, 4);
}
}
+139
View File
@@ -0,0 +1,139 @@
use std::time::Duration;
use crossterm::event::{Event, KeyCode, KeyEvent};
use ratatui::{
Frame,
layout::{Constraint, Direction, Layout},
text::Line,
widgets::{Block, List, ListItem, Paragraph},
};
#[derive(Default)]
pub struct App {
messages: Vec<Message>,
input: String,
pending: bool,
}
struct Message {
role: Role,
content: String,
}
#[derive(PartialEq, Eq)]
enum Role {
System,
User,
Assistant,
Tool,
}
#[derive(PartialEq, Eq)]
enum Flow {
Continue,
Quit,
}
impl App {
pub fn new() -> Self {
Self {
messages: vec![Message {
role: Role::System,
content: "hi there ~".into(),
}],
..Default::default()
}
}
pub fn resume(messages: Vec<Message>) -> Self {
Self {
messages,
..Default::default()
}
}
pub fn run(mut self, act: ) -> std::io::Result<()> {
ratatui::run(|terminal| {
loop {
terminal.draw(|frame| self.render(frame))?;
if crossterm::event::poll(Duration::from_millis(16))? {
match crossterm::event::read()? {
Event::Key(key) => {
if self.handle_key(key) == Flow::Quit {
break Ok(());
}
}
_ => {}
}
}
}
})
}
fn handle_key(&mut self, key: KeyEvent) -> Flow {
match key.code {
KeyCode::Esc => Flow::Quit,
KeyCode::Backspace => {
self.input.pop();
Flow::Continue
}
KeyCode::Char(ch) => {
self.input.push(ch);
Flow::Continue
}
KeyCode::Enter => {
self.submit();
Flow::Continue
}
_ => Flow::Continue,
}
}
fn submit(&mut self) {
self.messages.push(Message {
role: Role::User,
content: self.input.clone(),
});
self.input.clear();
}
fn render(&self, frame: &mut Frame) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(3),
])
.split(frame.area());
let header = Paragraph::new("Agent Chat").block(Block::bordered().title("Title"));
frame.render_widget(header, chunks[0]);
let items: Vec<ListItem> = self
.messages
.iter()
.map(|msg| {
let name = match msg.role {
Role::System => "System",
Role::User => "User",
Role::Assistant => "Assistant",
Role::Tool => "Tool",
};
let mut lines = vec![Line::from(name)];
for line in msg.content.lines() {
lines.push(Line::from(line));
}
ListItem::new(lines)
})
.collect();
let messages = List::new(items).block(Block::bordered().title("Messages"));
frame.render_widget(messages, chunks[1]);
let input = Paragraph::new(self.input.as_str()).block(Block::bordered().title("Input"));
frame.render_widget(input, chunks[2]);
}
}