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

@@ -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);
}
}