feat(agent): load configurable profiles from .owlen/agents
This commit is contained in:
@@ -1,7 +1,6 @@
|
|||||||
# Agents Upgrade Plan
|
# Agents Upgrade Plan
|
||||||
|
|
||||||
- feat: implement resumable command queue, automatic thought summaries, and queued execution controls to match Codex CLI session management and Claude Code’s scripted workflows — **shipped** (`:queue` commands, automatic thought toasts, resumable submissions)
|
- feat: add first-class prompt, agent, and sub-agent configuration via `.owlen/agents` plus reusable prompt libraries, mirroring Codex custom prompts and Claude’s configurable agents — **shipped** (`AgentRegistry` loaders, `:agent list/use/reload`, prompt/ sub-agent TOML + file-based libraries)
|
||||||
- feat: add first-class prompt, agent, and sub-agent configuration via `.owlen/agents` plus reusable prompt libraries, mirroring Codex custom prompts and Claude’s configurable agents
|
|
||||||
- feat: deliver official VS Code extension and browser workspace so Owlen runs alongside Codex’s IDE plugin and Claude Code’s app-based surfaces
|
- feat: deliver official VS Code extension and browser workspace so Owlen runs alongside Codex’s IDE plugin and Claude Code’s app-based surfaces
|
||||||
- feat: support multimodal inputs (images, rich artifacts) and preview panes so non-text context matches Codex CLI image handling and Claude Code’s artifact outputs
|
- feat: support multimodal inputs (images, rich artifacts) and preview panes so non-text context matches Codex CLI image handling and Claude Code’s artifact outputs
|
||||||
- feat: integrate repository automation (GitHub PR review, commit templating, Claude SDK-style automation APIs) to reach parity with Codex CLI’s GitHub integration and Claude Code’s CLI/SDK automation
|
- feat: integrate repository automation (GitHub PR review, commit templating, Claude SDK-style automation APIs) to reach parity with Codex CLI’s GitHub integration and Claude Code’s CLI/SDK automation
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ async fn test_agent_single_tool_scenario() {
|
|||||||
model: "llama3.2".to_string(),
|
model: "llama3.2".to_string(),
|
||||||
temperature: Some(0.7),
|
temperature: Some(0.7),
|
||||||
max_tokens: None,
|
max_tokens: None,
|
||||||
|
..AgentConfig::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
let executor = AgentExecutor::new(provider, mcp_client, config);
|
let executor = AgentExecutor::new(provider, mcp_client, config);
|
||||||
@@ -119,6 +120,7 @@ async fn test_agent_multi_step_workflow() {
|
|||||||
model: "llama3.2".to_string(),
|
model: "llama3.2".to_string(),
|
||||||
temperature: Some(0.5), // Lower temperature for more consistent behavior
|
temperature: Some(0.5), // Lower temperature for more consistent behavior
|
||||||
max_tokens: None,
|
max_tokens: None,
|
||||||
|
..AgentConfig::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
let executor = AgentExecutor::new(provider, mcp_client, config);
|
let executor = AgentExecutor::new(provider, mcp_client, config);
|
||||||
@@ -150,6 +152,7 @@ async fn test_agent_iteration_limit() {
|
|||||||
model: "llama3.2".to_string(),
|
model: "llama3.2".to_string(),
|
||||||
temperature: Some(0.7),
|
temperature: Some(0.7),
|
||||||
max_tokens: None,
|
max_tokens: None,
|
||||||
|
..AgentConfig::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
let executor = AgentExecutor::new(provider, mcp_client, config);
|
let executor = AgentExecutor::new(provider, mcp_client, config);
|
||||||
@@ -191,6 +194,7 @@ async fn test_agent_tool_budget_enforcement() {
|
|||||||
model: "llama3.2".to_string(),
|
model: "llama3.2".to_string(),
|
||||||
temperature: Some(0.7),
|
temperature: Some(0.7),
|
||||||
max_tokens: None,
|
max_tokens: None,
|
||||||
|
..AgentConfig::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
let executor = AgentExecutor::new(provider, mcp_client, config);
|
let executor = AgentExecutor::new(provider, mcp_client, config);
|
||||||
@@ -248,6 +252,8 @@ fn test_agent_config_defaults() {
|
|||||||
assert_eq!(config.max_iterations, 15);
|
assert_eq!(config.max_iterations, 15);
|
||||||
assert_eq!(config.model, "llama3.2:latest");
|
assert_eq!(config.model, "llama3.2:latest");
|
||||||
assert_eq!(config.temperature, Some(0.7));
|
assert_eq!(config.temperature, Some(0.7));
|
||||||
|
assert_eq!(config.system_prompt, None);
|
||||||
|
assert!(config.sub_agents.is_empty());
|
||||||
// max_tool_calls field removed - agent now tracks iterations instead
|
// max_tool_calls field removed - agent now tracks iterations instead
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,6 +264,8 @@ fn test_agent_config_custom() {
|
|||||||
model: "custom-model".to_string(),
|
model: "custom-model".to_string(),
|
||||||
temperature: Some(0.5),
|
temperature: Some(0.5),
|
||||||
max_tokens: Some(2000),
|
max_tokens: Some(2000),
|
||||||
|
system_prompt: Some("Custom prompt".to_string()),
|
||||||
|
sub_agents: Vec::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
assert_eq!(config.max_iterations, 15);
|
assert_eq!(config.max_iterations, 15);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
use crate::Provider;
|
use crate::Provider;
|
||||||
use crate::mcp::{McpClient, McpToolCall, McpToolDescriptor, McpToolResponse};
|
use crate::mcp::{McpClient, McpToolCall, McpToolDescriptor, McpToolResponse};
|
||||||
use crate::types::{ChatParameters, ChatRequest, Message};
|
use crate::types::{ChatParameters, ChatRequest, Message};
|
||||||
use crate::{Error, Result};
|
use crate::{Error, Result, SubAgentSpec};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -28,6 +28,38 @@ pub enum LlmResponse {
|
|||||||
Reasoning { thought: String },
|
Reasoning { thought: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn assemble_prompt_with_tools_and_subagents(
|
||||||
|
base_prompt: &str,
|
||||||
|
tools: &[McpToolDescriptor],
|
||||||
|
sub_agents: &[SubAgentSpec],
|
||||||
|
) -> String {
|
||||||
|
let mut prompt = base_prompt.trim().to_string();
|
||||||
|
prompt.push_str("\n\nYou have access to the following tools:\n");
|
||||||
|
for tool in tools {
|
||||||
|
prompt.push_str(&format!("- {}: {}\n", tool.name, tool.description));
|
||||||
|
}
|
||||||
|
append_subagent_guidance(&mut prompt, sub_agents);
|
||||||
|
prompt
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append_subagent_guidance(prompt: &mut String, sub_agents: &[SubAgentSpec]) {
|
||||||
|
if sub_agents.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt.push_str("\nYou may delegate focused tasks to the following specialised sub-agents:\n");
|
||||||
|
for sub in sub_agents {
|
||||||
|
prompt.push_str(&format!(
|
||||||
|
"- {}: {}\n{}\n",
|
||||||
|
sub.name.as_deref().unwrap_or(sub.id.as_str()),
|
||||||
|
sub.description
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("No description provided."),
|
||||||
|
sub.prompt.trim()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Parse error when LLM response doesn't match expected format
|
/// Parse error when LLM response doesn't match expected format
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum ParseError {
|
pub enum ParseError {
|
||||||
@@ -63,6 +95,10 @@ pub struct AgentConfig {
|
|||||||
pub temperature: Option<f32>,
|
pub temperature: Option<f32>,
|
||||||
/// Max tokens per LLM call
|
/// Max tokens per LLM call
|
||||||
pub max_tokens: Option<u32>,
|
pub max_tokens: Option<u32>,
|
||||||
|
/// Optional override for the system prompt presented to the LLM.
|
||||||
|
pub system_prompt: Option<String>,
|
||||||
|
/// Optional sub-agent prompts exposed to the executor.
|
||||||
|
pub sub_agents: Vec<SubAgentSpec>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AgentConfig {
|
impl Default for AgentConfig {
|
||||||
@@ -72,6 +108,8 @@ impl Default for AgentConfig {
|
|||||||
model: "llama3.2:latest".to_string(),
|
model: "llama3.2:latest".to_string(),
|
||||||
temperature: Some(0.7),
|
temperature: Some(0.7),
|
||||||
max_tokens: Some(4096),
|
max_tokens: Some(4096),
|
||||||
|
system_prompt: None,
|
||||||
|
sub_agents: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -187,6 +225,14 @@ impl AgentExecutor {
|
|||||||
|
|
||||||
/// Build the system prompt with ReAct format and tool descriptions
|
/// Build the system prompt with ReAct format and tool descriptions
|
||||||
fn build_system_prompt(&self, tools: &[McpToolDescriptor]) -> String {
|
fn build_system_prompt(&self, tools: &[McpToolDescriptor]) -> String {
|
||||||
|
if let Some(custom) = &self.config.system_prompt {
|
||||||
|
return assemble_prompt_with_tools_and_subagents(
|
||||||
|
custom,
|
||||||
|
tools,
|
||||||
|
&self.config.sub_agents,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let mut prompt = String::from(
|
let mut prompt = String::from(
|
||||||
"You are an AI assistant that uses the ReAct (Reasoning and Acting) pattern to solve tasks.\n\n\
|
"You are an AI assistant that uses the ReAct (Reasoning and Acting) pattern to solve tasks.\n\n\
|
||||||
You have access to the following tools:\n\n",
|
You have access to the following tools:\n\n",
|
||||||
@@ -213,6 +259,8 @@ impl AgentExecutor {
|
|||||||
- Use FINAL_ANSWER only when you have sufficient information\n",
|
- Use FINAL_ANSWER only when you have sufficient information\n",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
append_subagent_guidance(&mut prompt, &self.config.sub_agents);
|
||||||
|
|
||||||
prompt
|
prompt
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,7 +281,6 @@ impl AgentExecutor {
|
|||||||
let response = self.llm_client.send_prompt(request).await?;
|
let response = self.llm_client.send_prompt(request).await?;
|
||||||
Ok(response.message.content)
|
Ok(response.message.content)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse LLM response into structured format
|
/// Parse LLM response into structured format
|
||||||
pub fn parse_response(&self, text: &str) -> Result<LlmResponse> {
|
pub fn parse_response(&self, text: &str) -> Result<LlmResponse> {
|
||||||
let lines: Vec<&str> = text.lines().collect();
|
let lines: Vec<&str> = text.lines().collect();
|
||||||
|
|||||||
462
crates/owlen-core/src/agent_registry.rs
Normal file
462
crates/owlen-core/src/agent_registry.rs
Normal file
@@ -0,0 +1,462 @@
|
|||||||
|
use crate::{Error, Result};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
/// Maximum allowed size (bytes) for an agent prompt file.
|
||||||
|
const MAX_PROMPT_SIZE_BYTES: usize = 128 * 1024;
|
||||||
|
|
||||||
|
/// Definition of a sub-agent that can be referenced by the primary agent prompt.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SubAgentSpec {
|
||||||
|
pub id: String,
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub prompt: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fully resolved agent profile loaded from configuration files.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AgentProfile {
|
||||||
|
pub id: String,
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub system_prompt: String,
|
||||||
|
pub model: Option<String>,
|
||||||
|
pub temperature: Option<f32>,
|
||||||
|
pub max_iterations: Option<usize>,
|
||||||
|
pub max_tokens: Option<u32>,
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
pub sub_agents: Vec<SubAgentSpec>,
|
||||||
|
pub source_path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AgentProfile {
|
||||||
|
pub fn display_name(&self) -> &str {
|
||||||
|
self.name.as_deref().unwrap_or(self.id.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Registry responsible for discovering and loading user-defined agent profiles.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct AgentRegistry {
|
||||||
|
profiles: Vec<AgentProfile>,
|
||||||
|
index: HashMap<String, usize>,
|
||||||
|
search_paths: Vec<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AgentRegistry {
|
||||||
|
/// Build a registry by discovering configuration in standard locations.
|
||||||
|
pub fn discover(project_hint: Option<&Path>) -> Result<Self> {
|
||||||
|
let mut search_paths = Vec::new();
|
||||||
|
|
||||||
|
if let Some(config_dir) = dirs::config_dir() {
|
||||||
|
search_paths.push(config_dir.join("owlen").join("agents"));
|
||||||
|
}
|
||||||
|
|
||||||
|
search_paths.extend(discover_project_agent_paths(project_hint));
|
||||||
|
|
||||||
|
if let Ok(env) = std::env::var("OWLEN_AGENTS_PATH") {
|
||||||
|
for path in env.split(std::path::MAIN_SEPARATOR) {
|
||||||
|
if !path.trim().is_empty() {
|
||||||
|
search_paths.push(PathBuf::from(path));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Self::load_from_paths(search_paths)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the registry from explicit paths.
|
||||||
|
pub fn load_from_paths(paths: Vec<PathBuf>) -> Result<Self> {
|
||||||
|
let mut registry = Self {
|
||||||
|
profiles: Vec::new(),
|
||||||
|
index: HashMap::new(),
|
||||||
|
search_paths: paths.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
for path in paths {
|
||||||
|
registry.load_directory(&path)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(registry)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the list of discovered agent profiles.
|
||||||
|
pub fn profiles(&self) -> &[AgentProfile] {
|
||||||
|
&self.profiles
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return a profile by identifier.
|
||||||
|
pub fn get(&self, id: &str) -> Option<&AgentProfile> {
|
||||||
|
self.index.get(id).and_then(|idx| self.profiles.get(*idx))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reload all search paths, replacing existing profiles.
|
||||||
|
pub fn reload(&mut self) -> Result<()> {
|
||||||
|
let paths = self.search_paths.clone();
|
||||||
|
self.profiles.clear();
|
||||||
|
self.index.clear();
|
||||||
|
|
||||||
|
for path in paths {
|
||||||
|
self.load_directory(&path)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_directory(&mut self, dir: &Path) -> Result<()> {
|
||||||
|
if !dir.exists() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut files = Vec::new();
|
||||||
|
collect_agent_files(dir, &mut files)?;
|
||||||
|
files.sort();
|
||||||
|
|
||||||
|
for file in files {
|
||||||
|
match load_agent_file(&file) {
|
||||||
|
Ok(mut profiles) => {
|
||||||
|
for profile in profiles.drain(..) {
|
||||||
|
let id = profile.id.clone();
|
||||||
|
if let Some(existing) = self.index.get(&id).copied() {
|
||||||
|
// Later search paths override earlier ones.
|
||||||
|
self.profiles[existing] = profile;
|
||||||
|
} else {
|
||||||
|
let idx = self.profiles.len();
|
||||||
|
self.profiles.push(profile);
|
||||||
|
self.index.insert(id, idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
return Err(Error::Config(format!(
|
||||||
|
"Failed to load agent definition {}: {err}",
|
||||||
|
file.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_agent_files(dir: &Path, files: &mut Vec<PathBuf>) -> Result<()> {
|
||||||
|
if !dir.exists() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
for entry in fs::read_dir(dir).map_err(Error::Io)? {
|
||||||
|
let entry = entry.map_err(Error::Io)?;
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_dir() {
|
||||||
|
collect_agent_files(&path, files)?;
|
||||||
|
} else if path
|
||||||
|
.extension()
|
||||||
|
.and_then(|ext| ext.to_str())
|
||||||
|
.map(|ext| ext.eq_ignore_ascii_case("toml"))
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
files.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn discover_project_agent_paths(project_hint: Option<&Path>) -> Vec<PathBuf> {
|
||||||
|
let mut results = Vec::new();
|
||||||
|
|
||||||
|
let mut current = project_hint
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.or_else(|| std::env::current_dir().ok());
|
||||||
|
|
||||||
|
while let Some(path) = current {
|
||||||
|
let candidate = path.join(".owlen").join("agents");
|
||||||
|
if candidate.exists() {
|
||||||
|
results.push(candidate);
|
||||||
|
}
|
||||||
|
|
||||||
|
current = path.parent().map(PathBuf::from);
|
||||||
|
}
|
||||||
|
|
||||||
|
results
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_agent_file(path: &Path) -> Result<Vec<AgentProfile>> {
|
||||||
|
let raw = fs::read_to_string(path).map_err(Error::Io)?;
|
||||||
|
if raw.trim().is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let document: AgentDocument = toml::from_str(&raw)
|
||||||
|
.map_err(|err| Error::Config(format!("Unable to parse {}: {err}", path.display())))?;
|
||||||
|
|
||||||
|
let mut profiles = Vec::new();
|
||||||
|
|
||||||
|
if document.agents.is_empty() {
|
||||||
|
let single: SingleAgentFile = toml::from_str(&raw).map_err(|err| {
|
||||||
|
Error::Config(format!(
|
||||||
|
"Agent definition {} must contain either [[agents]] tables or top-level id/prompt fields: {err}",
|
||||||
|
path.display()
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
profiles.push(resolve_agent_entry(path, &single.entry)?);
|
||||||
|
return Ok(profiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
for entry in document.agents {
|
||||||
|
profiles.push(resolve_agent_entry(path, &entry)?);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(profiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_agent_entry(path: &Path, entry: &AgentEntry) -> Result<AgentProfile> {
|
||||||
|
let base_dir = path
|
||||||
|
.parent()
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|| PathBuf::from("."));
|
||||||
|
|
||||||
|
let system_prompt = entry
|
||||||
|
.prompt
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| {
|
||||||
|
Error::Config(format!(
|
||||||
|
"Agent '{}' in {} is missing a `prompt` value",
|
||||||
|
entry.id,
|
||||||
|
path.display()
|
||||||
|
))
|
||||||
|
})?
|
||||||
|
.resolve(&base_dir)?;
|
||||||
|
|
||||||
|
let mut sub_agents = Vec::new();
|
||||||
|
for (id, sub) in &entry.sub_agents {
|
||||||
|
let prompt = sub.prompt.resolve(&base_dir)?;
|
||||||
|
sub_agents.push(SubAgentSpec {
|
||||||
|
id: id.clone(),
|
||||||
|
name: sub.name.clone(),
|
||||||
|
description: sub.description.clone(),
|
||||||
|
prompt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(AgentProfile {
|
||||||
|
id: entry.id.clone(),
|
||||||
|
name: entry.name.clone(),
|
||||||
|
description: entry.description.clone(),
|
||||||
|
system_prompt,
|
||||||
|
model: entry.parameters.as_ref().and_then(|p| p.model.clone()),
|
||||||
|
temperature: entry.parameters.as_ref().and_then(|p| p.temperature),
|
||||||
|
max_iterations: entry.parameters.as_ref().and_then(|p| p.max_iterations),
|
||||||
|
max_tokens: entry.parameters.as_ref().and_then(|p| p.max_tokens),
|
||||||
|
tags: entry.tags.clone().unwrap_or_default(),
|
||||||
|
sub_agents,
|
||||||
|
source_path: path.to_path_buf(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct AgentDocument {
|
||||||
|
#[serde(default = "default_schema_version")]
|
||||||
|
_version: String,
|
||||||
|
#[serde(default)]
|
||||||
|
agents: Vec<AgentEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct SingleAgentFile {
|
||||||
|
#[serde(default = "default_schema_version")]
|
||||||
|
_version: String,
|
||||||
|
#[serde(flatten)]
|
||||||
|
entry: AgentEntry,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_schema_version() -> String {
|
||||||
|
"1".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct AgentEntry {
|
||||||
|
id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
name: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
description: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
tags: Option<Vec<String>>,
|
||||||
|
#[serde(default)]
|
||||||
|
prompt: Option<PromptSpec>,
|
||||||
|
#[serde(default)]
|
||||||
|
parameters: Option<AgentParameters>,
|
||||||
|
#[serde(default)]
|
||||||
|
sub_agents: HashMap<String, SubAgentEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct AgentParameters {
|
||||||
|
#[serde(default)]
|
||||||
|
model: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
temperature: Option<f32>,
|
||||||
|
#[serde(default)]
|
||||||
|
max_iterations: Option<usize>,
|
||||||
|
#[serde(default)]
|
||||||
|
max_tokens: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct SubAgentEntry {
|
||||||
|
#[serde(default)]
|
||||||
|
name: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
description: Option<String>,
|
||||||
|
prompt: PromptSpec,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
enum PromptSpec {
|
||||||
|
Inline(String),
|
||||||
|
Source { file: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PromptSpec {
|
||||||
|
fn resolve(&self, base_dir: &Path) -> Result<String> {
|
||||||
|
match self {
|
||||||
|
PromptSpec::Inline(value) => Ok(value.trim().to_string()),
|
||||||
|
PromptSpec::Source { file } => {
|
||||||
|
let path = if Path::new(file).is_absolute() {
|
||||||
|
PathBuf::from(file)
|
||||||
|
} else {
|
||||||
|
base_dir.join(file)
|
||||||
|
};
|
||||||
|
|
||||||
|
let data = fs::read(&path).map_err(Error::Io)?;
|
||||||
|
if data.len() > MAX_PROMPT_SIZE_BYTES {
|
||||||
|
return Err(Error::Config(format!(
|
||||||
|
"Prompt file {} exceeds the maximum supported size ({MAX_PROMPT_SIZE_BYTES} bytes)",
|
||||||
|
path.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let text = String::from_utf8(data).map_err(|_| {
|
||||||
|
Error::Config(format!("Prompt file {} is not valid UTF-8", path.display()))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(text.trim().to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::io::Write;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_simple_agent() {
|
||||||
|
let dir = tempdir().expect("temp dir");
|
||||||
|
let agent_dir = dir.path().join("agents");
|
||||||
|
fs::create_dir_all(&agent_dir).unwrap();
|
||||||
|
|
||||||
|
let mut file = fs::File::create(agent_dir.join("support.toml")).unwrap();
|
||||||
|
writeln!(
|
||||||
|
file,
|
||||||
|
r#"
|
||||||
|
version = "1"
|
||||||
|
|
||||||
|
[[agents]]
|
||||||
|
id = "support"
|
||||||
|
name = "Support Specialist"
|
||||||
|
description = "Handles user support tickets."
|
||||||
|
prompt = "You are a helpful support assistant."
|
||||||
|
|
||||||
|
[agents.parameters]
|
||||||
|
model = "gpt-4"
|
||||||
|
max_iterations = 8
|
||||||
|
temperature = 0.2
|
||||||
|
|
||||||
|
[agents.sub_agents.first_line]
|
||||||
|
name = "First-line support"
|
||||||
|
description = "Handles simple issues"
|
||||||
|
prompt = "Escalate complex issues."
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let registry = AgentRegistry::load_from_paths(vec![agent_dir]).unwrap();
|
||||||
|
assert_eq!(registry.profiles.len(), 1);
|
||||||
|
|
||||||
|
let profile = registry.get("support").unwrap();
|
||||||
|
assert_eq!(profile.display_name(), "Support Specialist");
|
||||||
|
assert_eq!(
|
||||||
|
profile.system_prompt,
|
||||||
|
"You are a helpful support assistant."
|
||||||
|
);
|
||||||
|
assert_eq!(profile.model.as_deref(), Some("gpt-4"));
|
||||||
|
assert_eq!(profile.max_iterations, Some(8));
|
||||||
|
assert_eq!(profile.sub_agents.len(), 1);
|
||||||
|
assert_eq!(profile.sub_agents[0].id, "first_line");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prompt_from_file_resolves_relative_path() {
|
||||||
|
let dir = tempdir().expect("temp dir");
|
||||||
|
let agent_dir = dir.path().join(".owlen").join("agents");
|
||||||
|
let prompt_dir = agent_dir.join("prompts");
|
||||||
|
fs::create_dir_all(&prompt_dir).unwrap();
|
||||||
|
|
||||||
|
fs::write(
|
||||||
|
prompt_dir.join("researcher.md"),
|
||||||
|
"Research the latest documentation updates.",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
fs::write(
|
||||||
|
agent_dir.join("doc.toml"),
|
||||||
|
r#"
|
||||||
|
version = "1"
|
||||||
|
|
||||||
|
[[agents]]
|
||||||
|
id = "docs"
|
||||||
|
prompt = { file = "prompts/researcher.md" }
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let registry = AgentRegistry::load_from_paths(vec![agent_dir]).unwrap();
|
||||||
|
let profile = registry.get("docs").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
profile.system_prompt,
|
||||||
|
"Research the latest documentation updates."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_agent_from_flat_document() {
|
||||||
|
let dir = tempdir().expect("temp dir");
|
||||||
|
let agent_dir = dir.path().join("agents");
|
||||||
|
fs::create_dir_all(&agent_dir).unwrap();
|
||||||
|
|
||||||
|
fs::write(
|
||||||
|
agent_dir.join("flat.toml"),
|
||||||
|
r#"
|
||||||
|
version = "1"
|
||||||
|
id = "flat"
|
||||||
|
name = "Flat Agent"
|
||||||
|
prompt = "Operate using flat configuration."
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let registry = AgentRegistry::load_from_paths(vec![agent_dir]).unwrap();
|
||||||
|
let profile = registry.get("flat").expect("profile present");
|
||||||
|
assert_eq!(profile.display_name(), "Flat Agent");
|
||||||
|
assert_eq!(profile.system_prompt, "Operate using flat configuration.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
//! LLM providers, routers, and MCP (Model Context Protocol) adapters.
|
//! LLM providers, routers, and MCP (Model Context Protocol) adapters.
|
||||||
|
|
||||||
pub mod agent;
|
pub mod agent;
|
||||||
|
pub mod agent_registry;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod consent;
|
pub mod consent;
|
||||||
pub mod conversation;
|
pub mod conversation;
|
||||||
@@ -35,6 +36,7 @@ pub mod validation;
|
|||||||
pub mod wrap_cursor;
|
pub mod wrap_cursor;
|
||||||
|
|
||||||
pub use agent::*;
|
pub use agent::*;
|
||||||
|
pub use agent_registry::*;
|
||||||
pub use config::*;
|
pub use config::*;
|
||||||
pub use consent::*;
|
pub use consent::*;
|
||||||
pub use conversation::*;
|
pub use conversation::*;
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ use owlen_core::config::{
|
|||||||
OLLAMA_CLOUD_BASE_URL, OLLAMA_CLOUD_ENDPOINT_KEY, OLLAMA_MODE_KEY,
|
OLLAMA_CLOUD_BASE_URL, OLLAMA_CLOUD_ENDPOINT_KEY, OLLAMA_MODE_KEY,
|
||||||
};
|
};
|
||||||
use owlen_core::credentials::{ApiCredentials, OLLAMA_CLOUD_CREDENTIAL_ID};
|
use owlen_core::credentials::{ApiCredentials, OLLAMA_CLOUD_CREDENTIAL_ID};
|
||||||
|
use owlen_core::{AgentProfile, AgentRegistry};
|
||||||
// Agent executor moved to separate binary `owlen-agent`. The TUI no longer directly
|
// Agent executor moved to separate binary `owlen-agent`. The TUI no longer directly
|
||||||
// imports `AgentExecutor` to avoid a circular dependency on `owlen-cli`.
|
// imports `AgentExecutor` to avoid a circular dependency on `owlen-cli`.
|
||||||
use std::collections::hash_map::DefaultHasher;
|
use std::collections::hash_map::DefaultHasher;
|
||||||
@@ -819,6 +820,10 @@ pub struct ChatApp {
|
|||||||
agent_mode: bool,
|
agent_mode: bool,
|
||||||
/// Agent running flag
|
/// Agent running flag
|
||||||
agent_running: bool,
|
agent_running: bool,
|
||||||
|
/// Loaded agent profiles from configuration
|
||||||
|
agent_registry: AgentRegistry,
|
||||||
|
/// Currently selected agent profile identifier
|
||||||
|
active_agent_id: Option<String>,
|
||||||
/// Operating mode (Chat or Code)
|
/// Operating mode (Chat or Code)
|
||||||
operating_mode: owlen_core::mode::Mode,
|
operating_mode: owlen_core::mode::Mode,
|
||||||
/// Flag indicating new messages arrived while scrolled away from tail
|
/// Flag indicating new messages arrived while scrolled away from tail
|
||||||
@@ -1091,11 +1096,19 @@ impl ChatApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let workspace_root = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
let workspace_root = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
||||||
let file_tree = FileTreeState::new(workspace_root);
|
let file_tree = FileTreeState::new(workspace_root.clone());
|
||||||
let file_icons = FileIconResolver::from_mode(icon_mode);
|
let file_icons = FileIconResolver::from_mode(icon_mode);
|
||||||
|
|
||||||
install_global_logger();
|
install_global_logger();
|
||||||
|
|
||||||
|
let agent_registry = AgentRegistry::discover(Some(&workspace_root)).unwrap_or_else(|err| {
|
||||||
|
eprintln!(
|
||||||
|
"Warning: failed to load agent configurations from {}: {err}",
|
||||||
|
workspace_root.display()
|
||||||
|
);
|
||||||
|
AgentRegistry::default()
|
||||||
|
});
|
||||||
|
|
||||||
let mut app = Self {
|
let mut app = Self {
|
||||||
controller,
|
controller,
|
||||||
mode: if show_onboarding {
|
mode: if show_onboarding {
|
||||||
@@ -1202,6 +1215,8 @@ impl ChatApp {
|
|||||||
_execution_budget: 50,
|
_execution_budget: 50,
|
||||||
agent_mode: false,
|
agent_mode: false,
|
||||||
agent_running: false,
|
agent_running: false,
|
||||||
|
agent_registry,
|
||||||
|
active_agent_id: None,
|
||||||
operating_mode: owlen_core::mode::Mode::default(),
|
operating_mode: owlen_core::mode::Mode::default(),
|
||||||
new_message_alert: false,
|
new_message_alert: false,
|
||||||
show_cursor_outside_insert,
|
show_cursor_outside_insert,
|
||||||
@@ -2978,6 +2993,90 @@ impl ChatApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn active_agent_profile(&self) -> Option<&AgentProfile> {
|
||||||
|
self.active_agent_id
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|id| self.agent_registry.get(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_active_agent(&mut self) -> Result<()> {
|
||||||
|
if self.active_agent_profile().is_some() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(profile) = self.agent_registry.profiles().first() {
|
||||||
|
let id = profile.id.clone();
|
||||||
|
let display_name = profile.display_name().to_string();
|
||||||
|
self.active_agent_id = Some(id);
|
||||||
|
self.error = None;
|
||||||
|
self.set_system_status(format!("🤖 Ready · {}", display_name));
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
let message = "No agent profiles found. Create .owlen/agents/*.toml or ~/.config/owlen/agents/*.toml";
|
||||||
|
self.error = Some(message.to_string());
|
||||||
|
self.status = message.to_string();
|
||||||
|
Err(anyhow!(message))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_active_agent_from_query(&mut self, query: &str) -> Result<()> {
|
||||||
|
let trimmed = query.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Err(anyhow!("Usage: :agent use <id>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let lookup = trimmed.to_ascii_lowercase();
|
||||||
|
let profile = self
|
||||||
|
.agent_registry
|
||||||
|
.profiles()
|
||||||
|
.iter()
|
||||||
|
.find(|profile| {
|
||||||
|
profile.id.eq_ignore_ascii_case(trimmed)
|
||||||
|
|| profile.display_name().to_ascii_lowercase() == lookup
|
||||||
|
})
|
||||||
|
.ok_or_else(|| {
|
||||||
|
anyhow!(format!(
|
||||||
|
"Unknown agent '{trimmed}'. Use :agent list to view available agents."
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let id = profile.id.clone();
|
||||||
|
let display_name = profile.display_name().to_string();
|
||||||
|
|
||||||
|
self.active_agent_id = Some(id);
|
||||||
|
self.status = format!("Active agent: {}", display_name);
|
||||||
|
self.error = None;
|
||||||
|
self.set_system_status(format!("🤖 Ready · {}", display_name));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn describe_agents(&self) -> String {
|
||||||
|
if self.agent_registry.profiles().is_empty() {
|
||||||
|
return "No agent profiles found. Add .toml files under ~/.config/owlen/agents or ./.owlen/agents.".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
self.agent_registry
|
||||||
|
.profiles()
|
||||||
|
.iter()
|
||||||
|
.map(|profile| {
|
||||||
|
let is_active = self
|
||||||
|
.active_agent_id
|
||||||
|
.as_deref()
|
||||||
|
.map(|id| id.eq_ignore_ascii_case(&profile.id))
|
||||||
|
.unwrap_or(false);
|
||||||
|
let marker = if is_active { '*' } else { ' ' };
|
||||||
|
let label = profile.name.as_deref().unwrap_or("(unnamed)");
|
||||||
|
let description = profile.description.as_deref().unwrap_or("");
|
||||||
|
if description.is_empty() {
|
||||||
|
format!("{marker} {} — {}", profile.id, label)
|
||||||
|
} else {
|
||||||
|
format!("{marker} {} — {} — {description}", profile.id, label)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
fn prune_toasts(&mut self) {
|
fn prune_toasts(&mut self) {
|
||||||
self.toasts.retain_active();
|
self.toasts.retain_active();
|
||||||
}
|
}
|
||||||
@@ -8592,7 +8691,7 @@ impl ChatApp {
|
|||||||
// "run-agent" command removed to break circular dependency on owlen-cli.
|
// "run-agent" command removed to break circular dependency on owlen-cli.
|
||||||
"agent" => {
|
"agent" => {
|
||||||
if let Some(subcommand) = args.first() {
|
if let Some(subcommand) = args.first() {
|
||||||
match subcommand.to_lowercase().as_str() {
|
match subcommand.to_ascii_lowercase().as_str() {
|
||||||
"status" => {
|
"status" => {
|
||||||
let armed =
|
let armed =
|
||||||
if self.agent_mode { "armed" } else { "idle" };
|
if self.agent_mode { "armed" } else { "idle" };
|
||||||
@@ -8601,21 +8700,105 @@ impl ChatApp {
|
|||||||
} else {
|
} else {
|
||||||
"stopped"
|
"stopped"
|
||||||
};
|
};
|
||||||
self.status =
|
let agent_label = self
|
||||||
format!("Agent status: {armed} · {running}");
|
.active_agent_profile()
|
||||||
|
.map(|profile| {
|
||||||
|
profile.display_name().to_string()
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "(none)".to_string());
|
||||||
|
self.status = format!(
|
||||||
|
"Agent status: {armed} · {running} · active: {agent_label}"
|
||||||
|
);
|
||||||
self.error = None;
|
self.error = None;
|
||||||
}
|
}
|
||||||
|
"list" => {
|
||||||
|
let listing = self.describe_agents();
|
||||||
|
self.status = listing
|
||||||
|
.lines()
|
||||||
|
.next()
|
||||||
|
.unwrap_or("No agent profiles found.")
|
||||||
|
.to_string();
|
||||||
|
self.error = None;
|
||||||
|
self.push_toast_with_hint(
|
||||||
|
ToastLevel::Info,
|
||||||
|
listing,
|
||||||
|
":agent use <id>",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
"use" => {
|
||||||
|
if args.len() < 2 {
|
||||||
|
self.error =
|
||||||
|
Some("Usage: :agent use <id>".to_string());
|
||||||
|
} else {
|
||||||
|
let target = args[1..].join(" ");
|
||||||
|
if let Err(err) =
|
||||||
|
self.set_active_agent_from_query(&target)
|
||||||
|
{
|
||||||
|
self.error = Some(err.to_string());
|
||||||
|
self.status =
|
||||||
|
"Failed to select agent".to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"reload" => match self.agent_registry.reload() {
|
||||||
|
Ok(()) => {
|
||||||
|
if self
|
||||||
|
.active_agent_id
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|id| self.agent_registry.get(id))
|
||||||
|
.is_none()
|
||||||
|
{
|
||||||
|
self.active_agent_id = None;
|
||||||
|
self.set_system_status(
|
||||||
|
"🤖 Idle".to_string(),
|
||||||
|
);
|
||||||
|
} else if let Some(profile) =
|
||||||
|
self.active_agent_profile()
|
||||||
|
{
|
||||||
|
self.set_system_status(format!(
|
||||||
|
"🤖 Ready · {}",
|
||||||
|
profile.display_name()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let count =
|
||||||
|
self.agent_registry.profiles().len();
|
||||||
|
self.status = format!(
|
||||||
|
"Reloaded agent profiles ({count})"
|
||||||
|
);
|
||||||
|
self.error = None;
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
let message =
|
||||||
|
format!("Failed to reload agents: {err}");
|
||||||
|
self.error = Some(message.clone());
|
||||||
|
self.status = "Agent reload failed".to_string();
|
||||||
|
self.push_toast(ToastLevel::Error, message);
|
||||||
|
}
|
||||||
|
},
|
||||||
"start" | "arm" => {
|
"start" | "arm" => {
|
||||||
if self.agent_running {
|
if self.agent_running {
|
||||||
self.status =
|
self.status =
|
||||||
"Agent is already running".to_string();
|
"Agent is already running".to_string();
|
||||||
} else {
|
} else if let Err(err) = self.ensure_active_agent()
|
||||||
|
{
|
||||||
|
self.error = Some(err.to_string());
|
||||||
|
} else if let Some(display_name) = self
|
||||||
|
.active_agent_profile()
|
||||||
|
.map(|p| p.display_name().to_string())
|
||||||
|
{
|
||||||
self.agent_mode = true;
|
self.agent_mode = true;
|
||||||
self.status = "Agent armed. Next message will be processed by the agent.".to_string();
|
self.status = format!(
|
||||||
|
"Agent '{}' armed. Next message will run it.",
|
||||||
|
display_name
|
||||||
|
);
|
||||||
self.error = None;
|
self.error = None;
|
||||||
|
self.set_system_status(format!(
|
||||||
|
"🤖 Ready · {}",
|
||||||
|
display_name
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"stop" => {
|
"stop" | "disarm" => {
|
||||||
if self.agent_running {
|
if self.agent_running {
|
||||||
self.agent_running = false;
|
self.agent_running = false;
|
||||||
self.agent_mode = false;
|
self.agent_mode = false;
|
||||||
@@ -8623,11 +8806,13 @@ impl ChatApp {
|
|||||||
self.status =
|
self.status =
|
||||||
"Agent execution stopped".to_string();
|
"Agent execution stopped".to_string();
|
||||||
self.error = None;
|
self.error = None;
|
||||||
|
self.set_system_status("🤖 Idle".to_string());
|
||||||
} else if self.agent_mode {
|
} else if self.agent_mode {
|
||||||
self.agent_mode = false;
|
self.agent_mode = false;
|
||||||
self.agent_actions = None;
|
self.agent_actions = None;
|
||||||
self.status = "Agent disarmed".to_string();
|
self.status = "Agent disarmed".to_string();
|
||||||
self.error = None;
|
self.error = None;
|
||||||
|
self.set_system_status("🤖 Idle".to_string());
|
||||||
} else {
|
} else {
|
||||||
self.status =
|
self.status =
|
||||||
"No agent is currently running".to_string();
|
"No agent is currently running".to_string();
|
||||||
@@ -8640,11 +8825,26 @@ impl ChatApp {
|
|||||||
}
|
}
|
||||||
} else if self.agent_running {
|
} else if self.agent_running {
|
||||||
self.status = "Agent is already running".to_string();
|
self.status = "Agent is already running".to_string();
|
||||||
} else {
|
} else if let Err(err) = self.ensure_active_agent() {
|
||||||
|
self.error = Some(err.to_string());
|
||||||
|
} else if let Some(display_name) = self
|
||||||
|
.active_agent_profile()
|
||||||
|
.map(|p| p.display_name().to_string())
|
||||||
|
{
|
||||||
self.agent_mode = true;
|
self.agent_mode = true;
|
||||||
self.status = "Agent mode enabled. Next message will be processed by agent.".to_string();
|
self.status = format!(
|
||||||
|
"Agent '{}' armed. Next message will be processed by the agent.",
|
||||||
|
display_name
|
||||||
|
);
|
||||||
self.error = None;
|
self.error = None;
|
||||||
|
self.set_system_status(format!(
|
||||||
|
"🤖 Ready · {}",
|
||||||
|
display_name
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
self.set_input_mode(InputMode::Normal);
|
||||||
|
self.command_palette.clear();
|
||||||
|
return Ok(AppState::Running);
|
||||||
}
|
}
|
||||||
"stop-agent" => {
|
"stop-agent" => {
|
||||||
if self.agent_running {
|
if self.agent_running {
|
||||||
@@ -12261,10 +12461,6 @@ impl ChatApp {
|
|||||||
use owlen_core::mcp::remote_client::RemoteMcpClient;
|
use owlen_core::mcp::remote_client::RemoteMcpClient;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
self.agent_running = true;
|
|
||||||
self.status = "Agent is running...".to_string();
|
|
||||||
self.start_loading_animation();
|
|
||||||
|
|
||||||
// Get the last user message
|
// Get the last user message
|
||||||
let user_message = self
|
let user_message = self
|
||||||
.controller
|
.controller
|
||||||
@@ -12276,14 +12472,50 @@ impl ChatApp {
|
|||||||
.map(|m| m.content.clone())
|
.map(|m| m.content.clone())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
// Create agent config
|
let profile = match self.active_agent_profile().cloned() {
|
||||||
let config = AgentConfig {
|
Some(profile) => profile,
|
||||||
max_iterations: 10,
|
None => {
|
||||||
model: self.controller.selected_model().to_string(),
|
if self.agent_registry.profiles().is_empty() {
|
||||||
temperature: Some(0.7),
|
self.error = Some(
|
||||||
max_tokens: None,
|
"No agent profiles configured. Add files under .owlen/agents or ~/.config/owlen/agents.".to_string(),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
self.error = Some(
|
||||||
|
"No active agent selected. Use :agent use <id> to choose one.".to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
self.agent_running = false;
|
||||||
|
self.agent_mode = false;
|
||||||
|
self.stop_loading_animation();
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let selected_model = self.controller.selected_model().to_string();
|
||||||
|
let mut config = AgentConfig {
|
||||||
|
model: profile.model.clone().unwrap_or(selected_model),
|
||||||
|
system_prompt: Some(profile.system_prompt.clone()),
|
||||||
|
sub_agents: profile.sub_agents.clone(),
|
||||||
|
..AgentConfig::default()
|
||||||
|
};
|
||||||
|
if let Some(iterations) = profile.max_iterations {
|
||||||
|
config.max_iterations = iterations;
|
||||||
|
}
|
||||||
|
if let Some(temp) = profile.temperature {
|
||||||
|
config.temperature = Some(temp);
|
||||||
|
}
|
||||||
|
if let Some(max_tokens) = profile.max_tokens {
|
||||||
|
config.max_tokens = Some(max_tokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
let agent_label = profile.display_name().to_string();
|
||||||
|
|
||||||
|
self.agent_running = true;
|
||||||
|
self.status = format!("Agent '{}' is running...", agent_label);
|
||||||
|
self.error = None;
|
||||||
|
self.set_system_status(format!("🤖 Working · {}", agent_label));
|
||||||
|
self.start_loading_animation();
|
||||||
|
|
||||||
// Get the provider
|
// Get the provider
|
||||||
let provider = self.controller.provider().clone();
|
let provider = self.controller.provider().clone();
|
||||||
|
|
||||||
@@ -12312,7 +12544,11 @@ impl ChatApp {
|
|||||||
self.agent_running = false;
|
self.agent_running = false;
|
||||||
self.agent_mode = false;
|
self.agent_mode = false;
|
||||||
self.agent_actions = None;
|
self.agent_actions = None;
|
||||||
self.status = format!("Agent completed in {} iterations", result.iterations);
|
self.status = format!(
|
||||||
|
"Agent '{}' completed in {} iterations",
|
||||||
|
agent_label, result.iterations
|
||||||
|
);
|
||||||
|
self.set_system_status(format!("🤖 Complete · {}", agent_label));
|
||||||
self.stop_loading_animation();
|
self.stop_loading_animation();
|
||||||
if let Some(active) = self.active_command.as_mut() {
|
if let Some(active) = self.active_command.as_mut() {
|
||||||
active.record_response(message_id);
|
active.record_response(message_id);
|
||||||
@@ -12322,12 +12558,13 @@ impl ChatApp {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let message = format!("Agent failed: {}", e);
|
let message = format!("Agent '{}' failed: {}", agent_label, e);
|
||||||
self.error = Some(message.clone());
|
self.error = Some(message.clone());
|
||||||
self.agent_running = false;
|
self.agent_running = false;
|
||||||
self.agent_mode = false;
|
self.agent_mode = false;
|
||||||
self.agent_actions = None;
|
self.agent_actions = None;
|
||||||
self.stop_loading_animation();
|
self.stop_loading_animation();
|
||||||
|
self.set_system_status(format!("🤖 Failed · {}", agent_label));
|
||||||
self.mark_active_command_failed(Some(message));
|
self.mark_active_command_failed(Some(message));
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user