Skip to content

Commit f570b6b

Browse files
authored
Merge pull request #5 from dnacenta/release/v0.2.0
release: v0.2.0 — tool execution system
2 parents 96122c1 + 9a36772 commit f570b6b

15 files changed

Lines changed: 688 additions & 58 deletions

File tree

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "echo-system"
3-
version = "0.1.0"
3+
version = "0.2.0"
44
edition = "2021"
55
rust-version = "1.80"
66
license = "AGPL-3.0-only"

src/llm/claude_api.rs

Lines changed: 72 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
use super::{LlmResponse, LlmResult, LmProvider, Message};
1+
use super::{
2+
ContentBlock, LlmResponse, LlmResult, LmProvider, Message, MessageContent, StopReason,
3+
};
24

35
pub struct ClaudeProvider {
46
api_key: String,
@@ -17,30 +19,51 @@ impl ClaudeProvider {
1719
}
1820

1921
impl LmProvider for ClaudeProvider {
20-
fn invoke(&self, system_prompt: &str, messages: &[Message], max_tokens: u32) -> LlmResult<'_> {
22+
fn invoke(
23+
&self,
24+
system_prompt: &str,
25+
messages: &[Message],
26+
max_tokens: u32,
27+
tools: Option<&[serde_json::Value]>,
28+
) -> LlmResult<'_> {
2129
let system_prompt = system_prompt.to_string();
2230
let messages = messages.to_vec();
31+
let tools = tools.map(|t| t.to_vec());
2332
Box::pin(async move {
2433
let api_messages: Vec<serde_json::Value> = messages
2534
.iter()
2635
.map(|m| {
36+
let role = match m.role {
37+
super::Role::User => "user",
38+
super::Role::Assistant => "assistant",
39+
};
40+
let content = match &m.content {
41+
MessageContent::Text(s) => serde_json::Value::String(s.clone()),
42+
MessageContent::Blocks(blocks) => {
43+
serde_json::to_value(blocks).unwrap_or(serde_json::Value::Null)
44+
}
45+
};
2746
serde_json::json!({
28-
"role": match m.role {
29-
super::Role::User => "user",
30-
super::Role::Assistant => "assistant",
31-
},
32-
"content": m.content,
47+
"role": role,
48+
"content": content,
3349
})
3450
})
3551
.collect();
3652

37-
let body = serde_json::json!({
53+
let mut body = serde_json::json!({
3854
"model": self.model,
3955
"max_tokens": max_tokens,
4056
"system": system_prompt,
4157
"messages": api_messages,
4258
});
4359

60+
// Include tool definitions if provided
61+
if let Some(ref tool_defs) = tools {
62+
if !tool_defs.is_empty() {
63+
body["tools"] = serde_json::Value::Array(tool_defs.clone());
64+
}
65+
}
66+
4467
let response = self
4568
.client
4669
.post("https://api.anthropic.com/v1/messages")
@@ -65,12 +88,18 @@ impl LmProvider for ClaudeProvider {
6588
let response_json: serde_json::Value = serde_json::from_str(&response_text)
6689
.map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)?;
6790

68-
let content = response_json["content"]
69-
.as_array()
70-
.and_then(|arr| arr.first())
71-
.and_then(|block| block["text"].as_str())
72-
.unwrap_or("")
73-
.to_string();
91+
// Parse content blocks from the response
92+
let content_blocks = parse_content_blocks(&response_json);
93+
94+
// Parse stop_reason
95+
let stop_reason = match response_json["stop_reason"].as_str() {
96+
Some("end_turn") => StopReason::EndTurn,
97+
Some("tool_use") => StopReason::ToolUse,
98+
Some("max_tokens") => StopReason::MaxTokens,
99+
Some("stop_sequence") => StopReason::StopSequence,
100+
Some(other) => StopReason::Other(other.to_string()),
101+
None => StopReason::EndTurn,
102+
};
74103

75104
let model = response_json["model"]
76105
.as_str()
@@ -85,7 +114,8 @@ impl LmProvider for ClaudeProvider {
85114
.map(|v| v as u32);
86115

87116
Ok(LlmResponse {
88-
content,
117+
content: content_blocks,
118+
stop_reason,
89119
model,
90120
input_tokens,
91121
output_tokens,
@@ -101,3 +131,30 @@ impl LmProvider for ClaudeProvider {
101131
true
102132
}
103133
}
134+
135+
/// Parse the `content` array from a Claude API response into ContentBlock values.
136+
fn parse_content_blocks(response_json: &serde_json::Value) -> Vec<ContentBlock> {
137+
let Some(content_array) = response_json["content"].as_array() else {
138+
return vec![];
139+
};
140+
141+
content_array
142+
.iter()
143+
.filter_map(|block| {
144+
let block_type = block["type"].as_str()?;
145+
match block_type {
146+
"text" => {
147+
let text = block["text"].as_str().unwrap_or("").to_string();
148+
Some(ContentBlock::Text { text })
149+
}
150+
"tool_use" => {
151+
let id = block["id"].as_str()?.to_string();
152+
let name = block["name"].as_str()?.to_string();
153+
let input = block["input"].clone();
154+
Some(ContentBlock::ToolUse { id, name, input })
155+
}
156+
_ => None,
157+
}
158+
})
159+
.collect()
160+
}

src/llm/mod.rs

Lines changed: 76 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,19 +12,91 @@ pub type LlmResult<'a> = Pin<
1212
>,
1313
>;
1414

15+
/// A content block in a message or response.
16+
/// Claude API uses tagged unions — each block has a "type" field.
17+
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
18+
#[serde(tag = "type")]
19+
pub enum ContentBlock {
20+
#[serde(rename = "text")]
21+
Text { text: String },
22+
23+
#[serde(rename = "tool_use")]
24+
ToolUse {
25+
id: String,
26+
name: String,
27+
input: serde_json::Value,
28+
},
29+
30+
#[serde(rename = "tool_result")]
31+
ToolResult {
32+
tool_use_id: String,
33+
content: String,
34+
#[serde(skip_serializing_if = "Option::is_none")]
35+
is_error: Option<bool>,
36+
},
37+
}
38+
39+
/// Message content can be a simple string or structured content blocks.
40+
/// The Claude API accepts both formats.
41+
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
42+
#[serde(untagged)]
43+
pub enum MessageContent {
44+
Text(String),
45+
Blocks(Vec<ContentBlock>),
46+
}
47+
48+
/// Why the model stopped generating.
49+
#[derive(Debug, Clone, PartialEq)]
50+
pub enum StopReason {
51+
EndTurn,
52+
ToolUse,
53+
MaxTokens,
54+
StopSequence,
55+
Other(String),
56+
}
57+
1558
/// Response from an LLM invocation
1659
#[derive(Debug, Clone)]
1760
pub struct LlmResponse {
18-
pub content: String,
61+
pub content: Vec<ContentBlock>,
62+
pub stop_reason: StopReason,
1963
pub model: String,
2064
pub input_tokens: Option<u32>,
2165
pub output_tokens: Option<u32>,
2266
}
2367

68+
impl LlmResponse {
69+
/// Extract all text content from the response, concatenated.
70+
pub fn text(&self) -> String {
71+
self.content
72+
.iter()
73+
.filter_map(|block| match block {
74+
ContentBlock::Text { text } => Some(text.as_str()),
75+
_ => None,
76+
})
77+
.collect::<Vec<_>>()
78+
.join("")
79+
}
80+
81+
/// Check if the response contains any tool_use blocks.
82+
pub fn has_tool_use(&self) -> bool {
83+
self.content
84+
.iter()
85+
.any(|block| matches!(block, ContentBlock::ToolUse { .. }))
86+
}
87+
}
88+
2489
/// Trait for LLM providers — the core abstraction for model-agnostic design
2590
pub trait LmProvider: Send + Sync {
26-
/// Send a message and get a response
27-
fn invoke(&self, system_prompt: &str, messages: &[Message], max_tokens: u32) -> LlmResult<'_>;
91+
/// Send a message and get a response.
92+
/// `tools` is an optional slice of tool definitions (JSON objects).
93+
fn invoke(
94+
&self,
95+
system_prompt: &str,
96+
messages: &[Message],
97+
max_tokens: u32,
98+
tools: Option<&[serde_json::Value]>,
99+
) -> LlmResult<'_>;
28100

29101
/// Provider name
30102
fn name(&self) -> &str;
@@ -39,7 +111,7 @@ pub trait LmProvider: Send + Sync {
39111
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
40112
pub struct Message {
41113
pub role: Role,
42-
pub content: String,
114+
pub content: MessageContent,
43115
}
44116

45117
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]

src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ mod pipeline;
1010
mod plugins;
1111
mod scheduler;
1212
mod server;
13+
mod tools;
1314

1415
#[derive(Parser)]
1516
#[command(name = "echo-system")]

src/scheduler/dynamic.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ pub fn create_task_from_marker(
2525
.ok_or("Missing 'prompt' in schedule marker")?
2626
.to_string();
2727

28-
// Validate the cron expression
28+
// Normalize and validate the cron expression
29+
let cron = super::normalize_cron(&cron);
2930
CronSchedule::from_str(&cron)
3031
.map_err(|e| format!("Invalid cron expression '{}': {}", cron, e))?;
3132

src/scheduler/mod.rs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,3 +170,41 @@ pub async fn start(
170170
fn default_true() -> bool {
171171
true
172172
}
173+
174+
/// Normalize a 6-field cron expression so that Sunday `0` becomes `7`.
175+
/// The `cron` crate requires day-of-week in 1-7 (Mon-Sun), but most users
176+
/// expect 0 = Sunday (the POSIX convention).
177+
pub fn normalize_cron(expr: &str) -> String {
178+
let fields: Vec<&str> = expr.split_whitespace().collect();
179+
if fields.len() == 6 {
180+
let dow = fields[5];
181+
if dow == "0" {
182+
return format!(
183+
"{} {} {} {} {} 7",
184+
fields[0], fields[1], fields[2], fields[3], fields[4]
185+
);
186+
}
187+
}
188+
expr.to_string()
189+
}
190+
191+
#[cfg(test)]
192+
mod tests {
193+
use super::*;
194+
195+
#[test]
196+
fn normalize_sunday_zero_to_seven() {
197+
assert_eq!(normalize_cron("0 0 11 * * 0"), "0 0 11 * * 7");
198+
}
199+
200+
#[test]
201+
fn leave_other_days_unchanged() {
202+
assert_eq!(normalize_cron("0 0 11 * * 1"), "0 0 11 * * 1");
203+
assert_eq!(normalize_cron("0 0 11 * * 7"), "0 0 11 * * 7");
204+
}
205+
206+
#[test]
207+
fn leave_wildcard_unchanged() {
208+
assert_eq!(normalize_cron("0 0 8 * * *"), "0 0 8 * * *");
209+
}
210+
}

src/scheduler/runner.rs

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use tokio::sync::RwLock;
77

88
use super::output;
99
use super::{Schedule, ScheduledTask};
10-
use crate::llm::{Message, Role};
10+
use crate::llm::{Message, MessageContent, Role};
1111
use crate::monitoring::signals;
1212
use crate::pipeline;
1313
use crate::pipeline::health as pipeline_health;
@@ -21,7 +21,8 @@ pub async fn run_task_loop(
2121
schedule: Arc<RwLock<Schedule>>,
2222
tz: chrono_tz::Tz,
2323
) {
24-
let cron_expr = match CronSchedule::from_str(&task.cron) {
24+
let normalized_cron = super::normalize_cron(&task.cron);
25+
let cron_expr = match CronSchedule::from_str(&normalized_cron) {
2526
Ok(c) => c,
2627
Err(e) => {
2728
tracing::error!("Invalid cron for task '{}': {} — {}", task.id, task.cron, e);
@@ -104,13 +105,13 @@ async fn execute_task(
104105
// Create a fresh conversation (no shared state with chat)
105106
let messages = vec![Message {
106107
role: Role::User,
107-
content: user_message,
108+
content: MessageContent::Text(user_message),
108109
}];
109110

110-
// Invoke LLM
111+
// Invoke LLM (no tools for scheduled tasks)
111112
let result = match state
112113
.provider
113-
.invoke(&system_prompt, &messages, state.config.llm.max_tokens)
114+
.invoke(&system_prompt, &messages, state.config.llm.max_tokens, None)
114115
.await
115116
{
116117
Ok(r) => r,
@@ -128,7 +129,8 @@ async fn execute_task(
128129
);
129130

130131
// Parse and route output
131-
let parsed = output::parse_output(&result.content);
132+
let response_text = result.text();
133+
let parsed = output::parse_output(&response_text);
132134

133135
// Handle [SCHEDULE:] markers — create new dynamic tasks
134136
for schedule_json in &parsed.schedule_requests {
@@ -166,7 +168,7 @@ async fn execute_task(
166168

167169
// Post-execution: extract cognitive signals
168170
if state.config.monitoring.enabled {
169-
let frame = signals::extract(&result.content, &task.id);
171+
let frame = signals::extract(&response_text, &task.id);
170172
if let Err(e) = signals::record(&root_dir, frame, state.config.monitoring.window_size) {
171173
tracing::error!("Failed to record signals for task '{}': {}", task.id, e);
172174
}

src/scheduler/tasks.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ pub fn default_tasks() -> Vec<ScheduledTask> {
8282
ScheduledTask {
8383
id: "weekly-synthesis".to_string(),
8484
name: "Weekly Synthesis".to_string(),
85-
cron: "0 0 11 * * 0".to_string(),
85+
cron: "0 0 11 * * 7".to_string(),
8686
channel: "system".to_string(),
8787
prompt: concat!(
8888
"This is your weekly synthesis. Review the entire week: ",

src/server/auth.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ mod tests {
6363
SchedulerConfig, SecurityConfig, ServerConfig, TrustConfig,
6464
};
6565
use crate::llm::Message;
66+
use crate::tools::ToolRegistry;
6667

6768
fn test_state(secret: Option<String>) -> Arc<AppState> {
6869
Arc::new(AppState {
@@ -96,6 +97,7 @@ mod tests {
9697
)),
9798
conversation: RwLock::new(Vec::<Message>::new()),
9899
system_prompt: RwLock::new(String::new()),
100+
tools: ToolRegistry::new(),
99101
})
100102
}
101103

0 commit comments

Comments
 (0)