First Version

This commit is contained in:
2026-07-10 15:02:09 +01:00
commit 38494a85a9
562 changed files with 196313 additions and 0 deletions
+259
View File
@@ -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)
}