First Version
This commit is contained in:
@@ -0,0 +1,259 @@
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{debug, trace, warn};
|
||||
|
||||
use crate::config::LlmStrength;
|
||||
|
||||
const AGENTS_DIR: &str = "agents";
|
||||
|
||||
/// The role an agent plays, declared by the required `type` field in `meta.json`.
|
||||
///
|
||||
/// - `Chat`: a conversational entry-point the user talks to directly (e.g. `main`,
|
||||
/// `project-coordinator`). Not dispatchable as a sub-agent, not a valid task root.
|
||||
/// - `Task`: a task executor. Dispatchable by a parent agent **and** a valid root of a
|
||||
/// scheduled/async task (e.g. `software-engineer`, `researcher`, `generalist`).
|
||||
/// - `System`: a hidden background agent wired into the runtime by id (e.g. `tic`).
|
||||
/// Never listed, never user-chattable, never dispatchable from the tool surface.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AgentType {
|
||||
Chat,
|
||||
Task,
|
||||
System,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RawMeta {
|
||||
name: String,
|
||||
description: String,
|
||||
#[serde(default)]
|
||||
friendly_description: Option<String>,
|
||||
#[serde(default)]
|
||||
instructions: Option<String>,
|
||||
#[serde(default)]
|
||||
inject_memory: Vec<String>,
|
||||
#[serde(default)]
|
||||
client: Option<String>,
|
||||
#[serde(default)]
|
||||
scope: Option<String>,
|
||||
#[serde(default)]
|
||||
strength: Option<LlmStrength>,
|
||||
/// Required: declares the agent's role. A `meta.json` without `type` fails to load.
|
||||
#[serde(rename = "type")]
|
||||
agent_type: AgentType,
|
||||
#[serde(default = "default_true")]
|
||||
inject_skills: bool,
|
||||
#[serde(default)]
|
||||
icon: Option<String>,
|
||||
}
|
||||
|
||||
/// Serde default for boolean fields that should be `true` when the key is absent.
|
||||
fn default_true() -> bool { true }
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AgentMeta {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// Routing description for the **orchestrator LLM**: "when should I delegate to this
|
||||
/// agent, and what does it return?". Injected into `<!-- AGENTS_LIST -->` and returned
|
||||
/// by `list_items` (type=agents). Required.
|
||||
pub description: String,
|
||||
/// Human-facing blurb shown to the **user** on the frontend Agents page. When absent
|
||||
/// the frontend falls back to `description`. Applies to every agent type.
|
||||
#[serde(default)]
|
||||
pub friendly_description: Option<String>,
|
||||
/// Note for the **calling LLM** on *how* to invoke this agent for the best result
|
||||
/// (expected inputs, format, gotchas). Kept short. Only meaningful for `task` agents —
|
||||
/// it is surfaced solely via `list_items` (type=agents), which already lists task
|
||||
/// agents only, so no extra gating is needed.
|
||||
#[serde(default)]
|
||||
pub instructions: Option<String>,
|
||||
#[serde(default)]
|
||||
pub inject_memory: Vec<String>,
|
||||
/// Preferred LLM client name (must exist in the DB, configured via the web app).
|
||||
/// If unset, the sub-agent inherits the caller's client.
|
||||
#[serde(default)]
|
||||
pub client: Option<String>,
|
||||
/// Task domain this agent operates in (e.g. "coding", "reasoning").
|
||||
/// Used by AUTO client selection to find a matching LLM.
|
||||
#[serde(default)]
|
||||
pub scope: Option<String>,
|
||||
/// Minimum LLM capability required to run this agent reliably.
|
||||
/// AUTO selection skips clients weaker than this threshold.
|
||||
#[serde(default)]
|
||||
pub strength: Option<LlmStrength>,
|
||||
/// The agent's role (`chat` / `task` / `system`). Only `task` agents are listed in
|
||||
/// `list_items` (type=agents) / the AGENTS_LIST injection and are dispatchable or
|
||||
/// runnable as a task root; `chat` and `system` are excluded from those paths.
|
||||
#[serde(rename = "type")]
|
||||
pub agent_type: AgentType,
|
||||
/// When true (the default, including when the key is absent), the skills index
|
||||
/// (`skills/index.md`) is injected into this agent's system prompt so it can
|
||||
/// discover and use installed skills. Set false for background agents that don't
|
||||
/// need them (e.g. TIC) to save tokens.
|
||||
#[serde(default = "default_true")]
|
||||
pub inject_skills: bool,
|
||||
/// Path to the agent's icon image file (relative to the agent's directory).
|
||||
/// Defaults to None if no icon is configured.
|
||||
#[serde(default)]
|
||||
pub icon: Option<String>,
|
||||
}
|
||||
|
||||
/// Scan `agents/` and return metadata for every agent that has both
|
||||
/// `meta.json` and `AGENT.md`. Skips the `common/` directory.
|
||||
pub fn discover() -> Result<Vec<AgentMeta>> {
|
||||
let mut agents = Vec::new();
|
||||
|
||||
let dir = std::fs::read_dir(AGENTS_DIR)
|
||||
.with_context(|| format!("Failed to read agents directory '{AGENTS_DIR}'"))?;
|
||||
|
||||
for entry in dir {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if !path.is_dir() { continue; }
|
||||
|
||||
let id = match path.file_name().and_then(|n| n.to_str()) {
|
||||
Some(n) if !n.is_empty() && n != "common" => n.to_string(),
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
let meta_path = path.join("meta.json");
|
||||
let system_path = path.join("AGENT.md");
|
||||
if !meta_path.exists() || !system_path.exists() {
|
||||
warn!(agent_id = %id, "skipping agent: missing meta.json or AGENT.md");
|
||||
continue;
|
||||
}
|
||||
|
||||
let raw_str = match std::fs::read_to_string(&meta_path) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
warn!(agent_id = %id, error = %e, "skipping agent: cannot read meta.json");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// A single malformed meta.json (e.g. missing the required `type` field) must not
|
||||
// blank the whole roster — warn and skip it, keep discovering the rest.
|
||||
let raw: RawMeta = match serde_json::from_str(&raw_str) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
warn!(agent_id = %id, error = %e, "skipping agent: invalid meta.json");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let meta = AgentMeta {
|
||||
id,
|
||||
name: raw.name,
|
||||
description: raw.description,
|
||||
friendly_description: raw.friendly_description,
|
||||
instructions: raw.instructions,
|
||||
inject_memory: raw.inject_memory,
|
||||
client: raw.client,
|
||||
scope: raw.scope,
|
||||
strength: raw.strength,
|
||||
agent_type: raw.agent_type,
|
||||
inject_skills: raw.inject_skills,
|
||||
icon: raw.icon,
|
||||
};
|
||||
trace!(agent_id = %meta.id, client = ?meta.client, scope = ?meta.scope, strength = ?meta.strength, "agent meta loaded");
|
||||
debug!(agent_id = %meta.id, name = %meta.name, "agent discovered");
|
||||
agents.push(meta);
|
||||
}
|
||||
|
||||
agents.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
Ok(agents)
|
||||
}
|
||||
|
||||
/// Load metadata for a single agent (reads its `meta.json`).
|
||||
pub fn load_meta(agent_id: &str) -> Result<AgentMeta> {
|
||||
let path = format!("{AGENTS_DIR}/{agent_id}/meta.json");
|
||||
let raw_str = std::fs::read_to_string(&path)
|
||||
.with_context(|| format!("Agent '{agent_id}': meta.json not found at '{path}'"))?;
|
||||
|
||||
let raw: RawMeta = serde_json::from_str(&raw_str)
|
||||
.with_context(|| format!("Agent '{agent_id}': failed to parse meta.json"))?;
|
||||
|
||||
Ok(AgentMeta {
|
||||
id: agent_id.to_string(),
|
||||
name: raw.name,
|
||||
description: raw.description,
|
||||
friendly_description: raw.friendly_description,
|
||||
instructions: raw.instructions,
|
||||
inject_memory: raw.inject_memory,
|
||||
client: raw.client,
|
||||
scope: raw.scope,
|
||||
strength: raw.strength,
|
||||
agent_type: raw.agent_type,
|
||||
inject_skills: raw.inject_skills,
|
||||
icon: raw.icon,
|
||||
})
|
||||
}
|
||||
|
||||
/// Load metadata for `agent_id` and assert it is a runnable **task** agent.
|
||||
/// Errors if the agent does not exist or is a `chat` / `system` agent — i.e. the
|
||||
/// single gate for "can this agent be dispatched or run as a task root?".
|
||||
pub fn load_task_meta(agent_id: &str) -> Result<AgentMeta> {
|
||||
let meta = load_meta(agent_id)?;
|
||||
if meta.agent_type != AgentType::Task {
|
||||
anyhow::bail!(
|
||||
"agent `{agent_id}` is a {:?} agent and cannot be dispatched or run as a task — only `task` agents can",
|
||||
meta.agent_type
|
||||
);
|
||||
}
|
||||
Ok(meta)
|
||||
}
|
||||
|
||||
/// Load and resolve the system prompt for `agent_id` from disk.
|
||||
/// Called at request time so edits to `.md` files take effect without restart.
|
||||
pub fn load_prompt(agent_id: &str) -> Result<String> {
|
||||
let path = format!("{AGENTS_DIR}/{agent_id}/AGENT.md");
|
||||
let content = std::fs::read_to_string(&path)
|
||||
.with_context(|| format!("Agent '{agent_id}': AGENT.md not found at '{path}'"))?;
|
||||
resolve_includes(&content)
|
||||
}
|
||||
|
||||
fn resolve_includes(content: &str) -> Result<String> {
|
||||
let mut out = String::with_capacity(content.len());
|
||||
for line in content.lines() {
|
||||
let trimmed = line.trim();
|
||||
if let Some(path_raw) = trimmed
|
||||
.strip_prefix("<!-- INCLUDE:")
|
||||
.and_then(|s| s.strip_suffix("-->"))
|
||||
{
|
||||
let path = format!("{AGENTS_DIR}/{}", path_raw.trim());
|
||||
let included = std::fs::read_to_string(&path)
|
||||
.with_context(|| format!("INCLUDE: failed to read '{path}'"))?;
|
||||
out.push_str(&format!("<included_file path=\"{path}\">\n"));
|
||||
out.push_str(&resolve_includes(&included)?);
|
||||
out.push_str("</included_file>\n");
|
||||
} else if trimmed == "<!-- AGENTS_LIST -->" {
|
||||
out.push_str(&render_agents_list()?);
|
||||
} else if trimmed == "<!-- MCP_LIST -->" {
|
||||
// Replaced at request time in build_openai_messages with dynamic
|
||||
// active/hidden sections. Leave a sentinel so the injection point
|
||||
// is preserved and positioned correctly in the prompt.
|
||||
out.push_str("__MCP_LIST__\n");
|
||||
} else if let Some(key) = trimmed
|
||||
.strip_prefix("<!-- ")
|
||||
.and_then(|s| s.strip_suffix(" -->"))
|
||||
.filter(|k| k.chars().all(|c| c.is_ascii_uppercase() || c == '_'))
|
||||
{
|
||||
// Generic runtime substitution: <!-- KEY --> → __KEY__ sentinel.
|
||||
// Replaced at request time via SendMessageOptions::system_substitutions.
|
||||
out.push_str(&format!("__{key}__\n"));
|
||||
} else {
|
||||
out.push_str(line);
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn render_agents_list() -> Result<String> {
|
||||
let agents = discover()?;
|
||||
let mut out = String::new();
|
||||
for agent in agents.iter().filter(|a| a.agent_type == AgentType::Task) {
|
||||
out.push_str(&format!("- **{}** — {}\n", agent.id, agent.description));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
Reference in New Issue
Block a user