feat(agent): implement Agent Orchestrator with LLM tool calling

Add complete agent orchestration system that enables LLM to call tools:

**Core Agent System** (`crates/core/agent`):
- Agent execution loop with tool call/result cycle
- Tool definitions in Ollama-compatible format (6 tools)
- Tool execution with permission checking
- Multi-iteration support with max iteration safety

**Tool Definitions**:
- read: Read file contents
- glob: Find files by pattern
- grep: Search for patterns in files
- write: Write content to files
- edit: Edit files with find/replace
- bash: Execute bash commands

**Ollama Integration Updates**:
- Extended ChatMessage to support tool_calls
- Added Tool, ToolCall, ToolFunction types
- Updated chat_stream to accept tools parameter
- Made tool call fields optional for Ollama compatibility

**CLI Integration**:
- Wired agent loop into all output formats (Text, JSON, StreamJSON)
- Tool calls displayed with 🔧 icon, results with 
- Replaced simple chat with agent orchestrator

**Permission Integration**:
- All tool executions check permissions before running
- Respects plan/acceptEdits/code modes
- Returns clear error messages for denied operations

**Example**:
User: "Find all Cargo.toml files in the workspace"
LLM: Calls glob("**/Cargo.toml")
Agent: Executes and returns 14 files
LLM: Formats human-readable response

This transforms owlen from a passive chatbot into an active agent that
can autonomously use tools to accomplish user goals.

Tested with: qwen3:8b successfully calling glob tool

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-01 20:56:56 +01:00
parent f87e5d2796
commit e77e33ce2f
8 changed files with 460 additions and 62 deletions

View File

@@ -1,6 +1,7 @@
[workspace]
members = [
"crates/app/cli",
"crates/core/agent",
"crates/llm/ollama",
"crates/platform/config",
"crates/platform/hooks",

View File

@@ -11,6 +11,7 @@ tokio = { version = "1.39", features = ["macros", "rt-multi-thread"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
color-eyre = "0.6"
agent-core = { path = "../../core/agent" }
llm-ollama = { path = "../../llm/ollama" }
tools-fs = { path = "../../tools/fs" }
tools-bash = { path = "../../tools/bash" }

View File

@@ -461,50 +461,20 @@ async fn main() -> Result<()> {
stream: true,
};
let msgs = vec![ChatMessage {
role: "user".into(),
content: prompt.clone(),
}];
let start_time = SystemTime::now();
// Handle different output formats
match output_format {
OutputFormat::Text => {
// Text format: stream to stdout as before
let mut stream = client.chat_stream(&msgs, &opts).await?;
while let Some(chunk) = stream.try_next().await? {
if let Some(m) = chunk.message {
if let Some(c) = m.content {
print!("{c}");
io::stdout().flush()?;
}
}
if matches!(chunk.done, Some(true)) {
break;
}
}
println!(); // Newline after response
// Text format: Use agent orchestrator with tool calling
let response = agent_core::run_agent_loop(&client, &prompt, &opts, &perms).await?;
println!("{}", response);
}
OutputFormat::Json => {
// JSON format: collect all chunks, then output final JSON
let mut stream = client.chat_stream(&msgs, &opts).await?;
let mut response = String::new();
while let Some(chunk) = stream.try_next().await? {
if let Some(m) = chunk.message {
if let Some(c) = m.content {
response.push_str(&c);
}
}
if matches!(chunk.done, Some(true)) {
break;
}
}
// JSON format: Use agent loop and output as JSON
let response = agent_core::run_agent_loop(&client, &prompt, &opts, &perms).await?;
let duration_ms = start_time.elapsed().unwrap().as_millis() as u64;
// Rough token estimate (tokens ~= chars / 4)
let estimated_tokens = ((prompt.len() + response.len()) / 4) as u64;
let output = SessionOutput {
@@ -526,7 +496,7 @@ async fn main() -> Result<()> {
println!("{}", serde_json::to_string(&output)?);
}
OutputFormat::StreamJson => {
// Stream-JSON format: emit session_start, chunks, and session_end
// Stream-JSON format: emit session_start, response, and session_end
let session_start = StreamEvent {
event_type: "session_start".to_string(),
session_id: Some(session_id.clone()),
@@ -535,30 +505,17 @@ async fn main() -> Result<()> {
};
println!("{}", serde_json::to_string(&session_start)?);
let mut stream = client.chat_stream(&msgs, &opts).await?;
let mut response = String::new();
let response = agent_core::run_agent_loop(&client, &prompt, &opts, &perms).await?;
while let Some(chunk) = stream.try_next().await? {
if let Some(m) = chunk.message {
if let Some(c) = m.content {
response.push_str(&c);
let chunk_event = StreamEvent {
event_type: "chunk".to_string(),
session_id: None,
content: Some(c),
content: Some(response.clone()),
stats: None,
};
println!("{}", serde_json::to_string(&chunk_event)?);
}
}
if matches!(chunk.done, Some(true)) {
break;
}
}
let duration_ms = start_time.elapsed().unwrap().as_millis() as u64;
// Rough token estimate
let estimated_tokens = ((prompt.len() + response.len()) / 4) as u64;
let session_end = StreamEvent {

View File

@@ -0,0 +1,21 @@
[package]
name = "agent-core"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version.workspace = true
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
color-eyre = "0.6"
tokio = { version = "1", features = ["full"] }
futures-util = "0.3"
# Internal dependencies
llm-ollama = { path = "../../llm/ollama" }
permissions = { path = "../../platform/permissions" }
tools-fs = { path = "../../tools/fs" }
tools-bash = { path = "../../tools/bash" }
[dev-dependencies]

View File

@@ -0,0 +1,372 @@
use color_eyre::eyre::{Result, eyre};
use futures_util::TryStreamExt;
use llm_ollama::{ChatMessage, OllamaClient, OllamaOptions, Tool, ToolFunction, ToolParameters};
use permissions::{PermissionDecision, PermissionManager, Tool as PermTool};
use serde_json::{json, Value};
/// Define all available tools for the LLM
pub fn get_tool_definitions() -> Vec<Tool> {
vec![
Tool {
tool_type: "function".to_string(),
function: ToolFunction {
name: "read".to_string(),
description: "Read the contents of a file".to_string(),
parameters: ToolParameters {
param_type: "object".to_string(),
properties: json!({
"path": {
"type": "string",
"description": "The path to the file to read"
}
}),
required: vec!["path".to_string()],
},
},
},
Tool {
tool_type: "function".to_string(),
function: ToolFunction {
name: "glob".to_string(),
description: "Find files matching a glob pattern (e.g., '**/*.rs' for all Rust files)".to_string(),
parameters: ToolParameters {
param_type: "object".to_string(),
properties: json!({
"pattern": {
"type": "string",
"description": "Glob pattern to match files (e.g., '**/*.toml', '*.md')"
}
}),
required: vec!["pattern".to_string()],
},
},
},
Tool {
tool_type: "function".to_string(),
function: ToolFunction {
name: "grep".to_string(),
description: "Search for a pattern in files within a directory".to_string(),
parameters: ToolParameters {
param_type: "object".to_string(),
properties: json!({
"root": {
"type": "string",
"description": "Root directory to search in"
},
"pattern": {
"type": "string",
"description": "Pattern to search for"
}
}),
required: vec!["root".to_string(), "pattern".to_string()],
},
},
},
Tool {
tool_type: "function".to_string(),
function: ToolFunction {
name: "write".to_string(),
description: "Write content to a file".to_string(),
parameters: ToolParameters {
param_type: "object".to_string(),
properties: json!({
"path": {
"type": "string",
"description": "Path where the file should be written"
},
"content": {
"type": "string",
"description": "Content to write to the file"
}
}),
required: vec!["path".to_string(), "content".to_string()],
},
},
},
Tool {
tool_type: "function".to_string(),
function: ToolFunction {
name: "edit".to_string(),
description: "Edit a file by replacing old text with new text".to_string(),
parameters: ToolParameters {
param_type: "object".to_string(),
properties: json!({
"path": {
"type": "string",
"description": "Path to the file to edit"
},
"old_string": {
"type": "string",
"description": "Text to find and replace"
},
"new_string": {
"type": "string",
"description": "Text to replace with"
}
}),
required: vec!["path".to_string(), "old_string".to_string(), "new_string".to_string()],
},
},
},
Tool {
tool_type: "function".to_string(),
function: ToolFunction {
name: "bash".to_string(),
description: "Execute a bash command. Use carefully and only when necessary.".to_string(),
parameters: ToolParameters {
param_type: "object".to_string(),
properties: json!({
"command": {
"type": "string",
"description": "The bash command to execute"
}
}),
required: vec!["command".to_string()],
},
},
},
]
}
/// Execute a tool call and return the result
pub async fn execute_tool(
tool_name: &str,
arguments: &Value,
perms: &PermissionManager,
) -> Result<String> {
match tool_name {
"read" => {
let path = arguments["path"]
.as_str()
.ok_or_else(|| eyre!("Missing 'path' argument"))?;
// Check permission
match perms.check(PermTool::Read, Some(path)) {
PermissionDecision::Allow => {
let content = tools_fs::read_file(path)?;
Ok(content)
}
PermissionDecision::Ask => {
Err(eyre!("Permission required: Read operation needs approval"))
}
PermissionDecision::Deny => {
Err(eyre!("Permission denied: Read operation is blocked"))
}
}
}
"glob" => {
let pattern = arguments["pattern"]
.as_str()
.ok_or_else(|| eyre!("Missing 'pattern' argument"))?;
// Check permission
match perms.check(PermTool::Glob, None) {
PermissionDecision::Allow => {
let files = tools_fs::glob_list(pattern)?;
Ok(files.join("\n"))
}
PermissionDecision::Ask => {
Err(eyre!("Permission required: Glob operation needs approval"))
}
PermissionDecision::Deny => {
Err(eyre!("Permission denied: Glob operation is blocked"))
}
}
}
"grep" => {
let root = arguments["root"]
.as_str()
.ok_or_else(|| eyre!("Missing 'root' argument"))?;
let pattern = arguments["pattern"]
.as_str()
.ok_or_else(|| eyre!("Missing 'pattern' argument"))?;
// Check permission
match perms.check(PermTool::Grep, None) {
PermissionDecision::Allow => {
let results = tools_fs::grep(root, pattern)?;
let lines: Vec<String> = results
.into_iter()
.map(|(path, line_num, text)| format!("{}:{}:{}", path, line_num, text))
.collect();
Ok(lines.join("\n"))
}
PermissionDecision::Ask => {
Err(eyre!("Permission required: Grep operation needs approval"))
}
PermissionDecision::Deny => {
Err(eyre!("Permission denied: Grep operation is blocked"))
}
}
}
"write" => {
let path = arguments["path"]
.as_str()
.ok_or_else(|| eyre!("Missing 'path' argument"))?;
let content = arguments["content"]
.as_str()
.ok_or_else(|| eyre!("Missing 'content' argument"))?;
// Check permission
match perms.check(PermTool::Write, Some(path)) {
PermissionDecision::Allow => {
tools_fs::write_file(path, content)?;
Ok(format!("File written successfully: {}", path))
}
PermissionDecision::Ask => {
Err(eyre!("Permission required: Write operation needs approval"))
}
PermissionDecision::Deny => {
Err(eyre!("Permission denied: Write operation is blocked"))
}
}
}
"edit" => {
let path = arguments["path"]
.as_str()
.ok_or_else(|| eyre!("Missing 'path' argument"))?;
let old_string = arguments["old_string"]
.as_str()
.ok_or_else(|| eyre!("Missing 'old_string' argument"))?;
let new_string = arguments["new_string"]
.as_str()
.ok_or_else(|| eyre!("Missing 'new_string' argument"))?;
// Check permission
match perms.check(PermTool::Edit, Some(path)) {
PermissionDecision::Allow => {
tools_fs::edit_file(path, old_string, new_string)?;
Ok(format!("File edited successfully: {}", path))
}
PermissionDecision::Ask => {
Err(eyre!("Permission required: Edit operation needs approval"))
}
PermissionDecision::Deny => {
Err(eyre!("Permission denied: Edit operation is blocked"))
}
}
}
"bash" => {
let command = arguments["command"]
.as_str()
.ok_or_else(|| eyre!("Missing 'command' argument"))?;
// Check permission
match perms.check(PermTool::Bash, Some(command)) {
PermissionDecision::Allow => {
let mut session = tools_bash::BashSession::new().await?;
let output = session.execute(command, None).await?;
let result = if !output.stdout.is_empty() {
output.stdout
} else if !output.stderr.is_empty() {
format!("stderr: {}", output.stderr)
} else {
"Command executed successfully with no output".to_string()
};
Ok(result)
}
PermissionDecision::Ask => {
Err(eyre!("Permission required: Bash operation needs approval"))
}
PermissionDecision::Deny => {
Err(eyre!("Permission denied: Bash operation is blocked"))
}
}
}
_ => Err(eyre!("Unknown tool: {}", tool_name)),
}
}
/// Run the agent loop with tool calling
pub async fn run_agent_loop(
client: &OllamaClient,
user_prompt: &str,
opts: &OllamaOptions,
perms: &PermissionManager,
) -> Result<String> {
let tools = get_tool_definitions();
let mut messages = vec![ChatMessage {
role: "user".to_string(),
content: Some(user_prompt.to_string()),
tool_calls: None,
}];
let max_iterations = 10; // Prevent infinite loops
let mut iteration = 0;
loop {
iteration += 1;
if iteration > max_iterations {
return Err(eyre!("Max iterations reached"));
}
// Call LLM with messages and tools
let mut stream = client.chat_stream(&messages, opts, Some(&tools)).await?;
let mut response_content = String::new();
let mut tool_calls = None;
// Collect the streamed response
while let Some(chunk) = stream.try_next().await? {
if let Some(msg) = chunk.message {
if let Some(content) = msg.content {
response_content.push_str(&content);
}
if let Some(calls) = msg.tool_calls {
tool_calls = Some(calls);
}
}
}
// Drop the stream to release the borrow on messages
drop(stream);
// Check if LLM wants to call tools
if let Some(calls) = tool_calls {
// Add assistant message with tool calls
messages.push(ChatMessage {
role: "assistant".to_string(),
content: if response_content.is_empty() {
None
} else {
Some(response_content.clone())
},
tool_calls: Some(calls.clone()),
});
// Execute each tool call
for call in calls {
let tool_name = &call.function.name;
let arguments = &call.function.arguments;
println!("\n🔧 Tool call: {} with args: {}", tool_name, arguments);
match execute_tool(tool_name, arguments, perms).await {
Ok(result) => {
println!("✅ Tool result: {}", result);
// Add tool result message
messages.push(ChatMessage {
role: "tool".to_string(),
content: Some(result),
tool_calls: None,
});
}
Err(e) => {
println!("❌ Tool error: {}", e);
// Add error message as tool result
messages.push(ChatMessage {
role: "tool".to_string(),
content: Some(format!("Error: {}", e)),
tool_calls: None,
});
}
}
}
// Continue loop to get next response
continue;
}
// No tool calls, we're done
return Ok(response_content);
}
}

View File

@@ -1,4 +1,4 @@
use crate::types::{ChatMessage, ChatResponseChunk};
use crate::types::{ChatMessage, ChatResponseChunk, Tool};
use futures::{Stream, TryStreamExt};
use reqwest::Client;
use serde::Serialize;
@@ -50,15 +50,18 @@ impl OllamaClient {
&self,
messages: &[ChatMessage],
opts: &OllamaOptions,
tools: Option<&[Tool]>,
) -> Result<impl Stream<Item = Result<ChatResponseChunk, OllamaError>>, OllamaError> {
#[derive(Serialize)]
struct Body<'a> {
model: &'a str,
messages: &'a [ChatMessage],
stream: bool,
#[serde(skip_serializing_if = "Option::is_none")]
tools: Option<&'a [Tool]>,
}
let url = format!("{}/api/chat", self.base_url);
let body = Body {model: &opts.model, messages, stream: true};
let body = Body {model: &opts.model, messages, stream: true, tools};
let mut req = self.http.post(url).json(&body);
// Add Authorization header if API key is present

View File

@@ -2,4 +2,4 @@ pub mod client;
pub mod types;
pub use client::{OllamaClient, OllamaOptions};
pub use types::{ChatMessage, ChatResponseChunk};
pub use types::{ChatMessage, ChatResponseChunk, Tool, ToolCall, ToolFunction, ToolParameters, FunctionCall};

View File

@@ -1,9 +1,50 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatMessage {
pub role: String, // "user", | "assistant" | "system"
pub content: String,
pub role: String, // "user" | "assistant" | "system" | "tool"
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<ToolCall>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ToolCall {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
pub call_type: Option<String>, // "function"
pub function: FunctionCall,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct FunctionCall {
pub name: String,
pub arguments: Value, // JSON object with arguments
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Tool {
#[serde(rename = "type")]
pub tool_type: String, // "function"
pub function: ToolFunction,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolFunction {
pub name: String,
pub description: String,
pub parameters: ToolParameters,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolParameters {
#[serde(rename = "type")]
pub param_type: String, // "object"
pub properties: Value,
pub required: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -19,4 +60,6 @@ pub struct ChatResponseChunk {
pub struct ChunkMessage {
pub role: Option<String>,
pub content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<ToolCall>>,
}