From 4f35b4364df2325ac3a816cb52c1a98ee914d11e Mon Sep 17 00:00:00 2001 From: xiaoxixi Date: Fri, 14 Aug 2026 17:50:55 +0800 Subject: [PATCH] feat: deactivate broken sub-agent definitions instead of blocking startup - AgentCatalog now records per-definition load errors (bad YAML, unknown provider/profile/model/tool/skill, or explicit delegate edge to an absent target) and excludes only the failing definition, never failing gateway startup or reload; config- and directory-trust-level errors stay fatal - Explicit delegation to an absent target cascades to deactivate the delegating agent; `*` and implicit general-purpose delegation do not - GET /api/agents lists broken definitions with parse/load errors so they can be fixed and re-enabled from the WebUI instead of disappearing - WebUI shows an error banner on broken definitions, hides the enable switch, pre-fills the editor via lenient parsing, and re-enables on save - Bump version to 1.16.0 --- AGENTS.md | 2 +- Cargo.toml | 2 +- docs/ARCHITECTURE.md | 2 +- .../about-picobot/references/architecture.md | 2 +- src/agent/catalog.rs | 318 ++++++++++++++++-- src/agent/definition.rs | 76 +++++ src/agent/mod.rs | 2 +- src/gateway/http.rs | 60 ++-- webui/package-lock.json | 4 +- webui/package.json | 2 +- webui/src/lib/Icon.svelte | 2 + .../lib/components/SubAgentDefinitions.svelte | 39 ++- 12 files changed, 439 insertions(+), 72 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fa23188..9297252 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -93,7 +93,7 @@ Scheduler → SessionManager scheduled execution → AgentLoop → Scheduler del - **WorkManager** owns the single active plan per session, item state transitions, plan versions, and plan-change events; plans are optional and absent from ordinary chat context - **Scheduler** supports legacy direct-delivery jobs and managed `task`/`monitor` jobs; managed agents cannot call `send_message`, and `on_alert` suppresses only healthy informational results - **AgentLoop** is stateless across turns; it receives prepared history, drains same-Turn steering only at safe model boundaries, calls LLM providers, executes tools, and returns one result -- **AgentCatalog** is immutable per runtime generation; candidate preparation strictly validates trusted Markdown definitions, Provider profiles, tool/Skill allowlists, and delegation edges before activation. Sub-Agent orchestration is an intrinsic, always-on mechanism (no feature switch). Named Agents support foreground (single/batch) and Root-initiated single background execution with durable run/inbox delivery; a built-in general-purpose definition is released to `~/.picobot/agents/` on first run. Background batches and nested background remain restricted +- **AgentCatalog** is immutable per runtime generation; candidate preparation strictly validates trusted Markdown definitions, Provider profiles, tool/Skill allowlists, and delegation edges before activation. A definition that fails per-file validation (bad YAML, unknown provider/profile/model/tool/skill, or an explicit delegate edge to an absent target) is disabled for that generation only and reported via `load_errors` (exposed by `GET /api/agents`), never blocking startup or reload; config- and directory-trust-level failures remain fatal. Sub-Agent orchestration is an intrinsic, always-on mechanism (no feature switch). Named Agents support foreground (single/batch) and Root-initiated single background execution with durable run/inbox delivery; a built-in general-purpose definition is released to `~/.picobot/agents/` on first run. Background batches and nested background remain restricted - **WebUI management APIs** only expose allowlisted config/profile/log/storage operations; keep response limits, secret redaction, atomic config writes, and profile path allowlists intact - **WebUI styling** uses the local Fluent 2 semantic tokens in `webui/src/styles.css`; page and component styles should consume the aliases instead of introducing independent hard-coded palettes, and must preserve selectable light/dark and brand-color themes, keyboard focus, responsive layout, and reduced-motion behavior. Browser-only appearance settings belong in `lib/theme.js`/`localStorage`, and `public/theme-init.js` must restore them before Svelte mounts - **Gateway config reload** validates and prepares a complete next runtime generation before retiring the old one; close runtime admission before draining inbound lanes, Sessions, Scheduler jobs, and background sub-agents; MCP connects only during activation; host/port, workspace, and effective storage path remain restart-only, and runtime reload must never mutate process environment variables diff --git a/Cargo.toml b/Cargo.toml index 2469764..1e2b50e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "picobot" -version = "1.15.0" +version = "1.16.0" edition = "2024" [dependencies] diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7d19868..5fde03c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -206,7 +206,7 @@ Session ID 格式为: SessionManager 负责组装会话上下文:系统提示、Skills、召回的 Knowledge、压缩后的 Timeline、可选的 active plan 摘要和当前消息历史。`session::turn_input` 在 Session 锁外并行读取 Knowledge、active plan 并压缩历史,然后通过同一个 assembly 路径生成首次请求和 context-overflow 重试输入;重试不得复制系统提示或 runtime context 拼接逻辑,也不得丢失已经从 mailbox 取出的 steering。普通闲聊 session 没有 plan 摘要;计划状态由 `WorkManager` 从 SQLite 读取,因此不以自然语言摘要作为权威来源。`AgentLoop` 接收完整输入执行一次模型/工具循环,本身不拥有会话状态,但通过本 Turn 的 mailbox 在安全边界接收追加用户输入。执行工具时额外传递只包含 session/turn 身份的 `ToolExecutionContext`;无状态工具使用默认实现忽略它,有状态外部适配器用它路由资源,但可按明确的单用户配置跨 dialog 共享,且不能自行反向查询 SessionManager。 -当前 Turn 的工具进度只从 `AgentLoop` 的结构化 `TurnEvent` 进入 `TurnController`,不能另建字符串 notification 通道重复投递。具名子 Agent 基础已接入:候选运行代从受信任配置目录严格加载不可变 `AgentCatalog`,Definition 固定 Provider/Model(内联或 `llm_profile`)、工具/Skill allowlist、委托边和执行限制,工具集完全由定义文件决定;`delegate` 使用 `foreground/background` 两个 canonical 生命周期词,批量并发与生命周期正交。具名 foreground 支持批量并发、显式 `AgentExecutionContext`、祖先环路和委托边校验;Root 对具名 Agent 的 background(单任务或批量,批量并发、每个 run 独立 completion 事件)走 durable run/inbox + continuation 投递,空闲时完成即返回。内置 general-purpose 定义随二进制释放,WebUI「子 Agent」页可增删改与启停定义。旧匿名 general 兼容路径已移除。自动标题属于非关键派生工作:Turn 持久化完成后由 `TaskSupervisor` 调度,Session worker 不等待模型生成;同一 Session 同时最多有一个标题任务,提交时仍校验标题保持默认值,避免覆盖用户改名。 +当前 Turn 的工具进度只从 `AgentLoop` 的结构化 `TurnEvent` 进入 `TurnController`,不能另建字符串 notification 通道重复投递。具名子 Agent 基础已接入:候选运行代从受信任配置目录严格加载不可变 `AgentCatalog`,Definition 固定 Provider/Model(内联或 `llm_profile`)、工具/Skill allowlist、委托边和执行限制,工具集完全由定义文件决定;单个定义校验失败(坏 YAML、未知 provider/profile/model/tool/skill、或显式委托到缺失目标)仅停用该定义并记入 `load_errors`(`GET /api/agents` 返回),不会阻塞启动或热重载,配置与目录信任级错误仍然致命;`delegate` 使用 `foreground/background` 两个 canonical 生命周期词,批量并发与生命周期正交。具名 foreground 支持批量并发、显式 `AgentExecutionContext`、祖先环路和委托边校验;Root 对具名 Agent 的 background(单任务或批量,批量并发、每个 run 独立 completion 事件)走 durable run/inbox + continuation 投递,空闲时完成即返回。内置 general-purpose 定义随二进制释放,WebUI「子 Agent」页可增删改与启停定义。旧匿名 general 兼容路径已移除。自动标题属于非关键派生工作:Turn 持久化完成后由 `TaskSupervisor` 调度,Session worker 不等待模型生成;同一 Session 同时最多有一个标题任务,提交时仍校验标题保持默认值,避免覆盖用户改名。 每个 session 最多有一个 active plan,但 plan 内多个 item 可以分别绑定不同子 Agent 并行执行。主 Agent 通过 `todo` 创建和维护计划,通过 `delegate.plan_item_id` 委托;子 Agent 的工具集由其定义文件决定,能否继续委托由其 `delegates` 白名单决定。领取 item 使用条件更新防止重复执行,迟到结果只有在 plan 仍 active 且 execution ID 匹配时才允许提交。 diff --git a/resources/skills/about-picobot/references/architecture.md b/resources/skills/about-picobot/references/architecture.md index 6ec2b97..1028a88 100644 --- a/resources/skills/about-picobot/references/architecture.md +++ b/resources/skills/about-picobot/references/architecture.md @@ -50,7 +50,7 @@ Scheduler → SessionManager.handle_cron_message → AgentLoop → send_message - 每个活动 Turn 独占一个 TurnSink;平台 message ID 和 reaction 清理状态只存在于 sink 内 - Tools 接收原始参数,通常返回字符串结果;有状态适配器额外接收 session/turn `ToolExecutionContext` - MCP 工具在 Gateway 初始化时连接服务器、发现工具,并包装成普通 Tool 注册到 ToolRegistry -- 具名子 Agent 从运行代不可变 `AgentCatalog` 加载,Definition 固定 Provider/Model、工具/Skill allowlist、委托边与限制;工具集完全由定义文件的 `tools` 列表决定(管理员显式授权),`delegate`/`emit_signal`/`get_skill`/`agent_task` 为运行时注入不可静态声明。支持单个/批量 foreground 和显式父子授权;Root 对具名 Agent 的 background(单任务或批量)走 durable run/inbox + continuation 投递,每个 run 独立完成、空闲时完成即返回。内置 general-purpose 定义随二进制释放到 `~/.picobot/agents/`,WebUI「子 Agent」页可增删改与启停定义 +- 具名子 Agent 从运行代不可变 `AgentCatalog` 加载,Definition 固定 Provider/Model、工具/Skill allowlist、委托边与限制;工具集完全由定义文件的 `tools` 列表决定(管理员显式授权),`delegate`/`emit_signal`/`get_skill`/`agent_task` 为运行时注入不可静态声明。单个定义校验失败(坏 YAML、未知 provider/profile/model/tool/skill、或显式委托到缺失目标)仅停用该定义并记入 `load_errors`(`GET /api/agents` 返回),不会阻塞启动或热重载;配置与目录信任级错误仍然致命。支持单个/批量 foreground 和显式父子授权;Root 对具名 Agent 的 background(单任务或批量)走 durable run/inbox + continuation 投递,每个 run 独立完成、空闲时完成即返回。内置 general-purpose 定义随二进制释放到 `~/.picobot/agents/`,WebUI「子 Agent」页可增删改与启停定义 - 复杂任务可使用 `todo` 创建 session 级计划;多个子项可通过 `delegate.plan_item_id` 并行委托,子 Agent 不能修改计划 - WebUI 聊天复用 `/ws` 与 `cli_chat`;同源管理 API 只读取受限的日志、任务、记忆,并对白名单配置文件做原子写入 - WebUI 使用 Svelte 5 + Vite,Bits UI 提供无样式可访问组件;`cargo build` 增量生成前端到 Cargo `OUT_DIR`,再嵌入单二进制,仓库不保存生成产物 diff --git a/src/agent/catalog.rs b/src/agent/catalog.rs index accddc0..2dd152e 100644 --- a/src/agent/catalog.rs +++ b/src/agent/catalog.rs @@ -42,9 +42,24 @@ pub enum AgentCatalogError { UnknownSkill { agent: String, skill: String }, } +/// A single Agent definition that failed to load. The definition stays on +/// disk and is reported to the management UI, but it does not enter the active +/// catalog (it is effectively disabled), so a broken file never blocks gateway +/// startup or reload. +#[derive(Debug, Clone)] +pub struct CatalogEntryError { + pub id: String, + pub reason: String, + pub path: PathBuf, +} + #[derive(Debug)] pub struct AgentCatalog { definitions: BTreeMap>, + /// Definitions that failed validation during this generation's load. + /// They are excluded from `definitions` but remain listed for the + /// management UI so users can fix and re-enable them. + load_errors: Vec, runtime_generation: u64, max_tree_depth: u16, max_runs_per_tree: usize, @@ -54,6 +69,7 @@ impl AgentCatalog { pub fn legacy() -> Self { Self { definitions: BTreeMap::new(), + load_errors: Vec::new(), runtime_generation: 0, max_tree_depth: 4, max_runs_per_tree: 16, @@ -102,70 +118,108 @@ impl AgentCatalog { .map(|(name, _)| name) .collect(); let mut definitions = BTreeMap::new(); + let mut load_errors: Vec = Vec::new(); for path in paths { - let spec = read_provider_spec(&path)?; + let spec = match read_provider_spec(&path) { + Ok(spec) => spec, + Err(error) => { + record_error(&mut load_errors, &file_stem_id(&path), &path, error); + continue; + } + }; // Disabled definitions stay on disk for the management UI but // never enter the active catalog. if !spec.enabled { continue; } - let provider = - resolve_provider(&spec, provider_profiles, providers, models, workspace_dir)?; - let definition = Arc::new(parse_definition(&path, Arc::new(provider))?); - if definitions.contains_key(&definition.id) { - return Err(AgentCatalogError::Config(format!( - "duplicate Agent id '{}'", - definition.id - ))); - } - validate_definition_tools(&definition, tools)?; - for skill in &definition.skills { - if !loaded_skills.contains(skill) { - return Err(AgentCatalogError::UnknownSkill { - agent: definition.id.clone(), - skill: skill.clone(), - }); + let provider = match resolve_provider( + &spec, + provider_profiles, + providers, + models, + workspace_dir, + ) { + Ok(provider) => provider, + Err(error) => { + record_error(&mut load_errors, &spec.id, &path, error); + continue; } + }; + let definition = match parse_definition(&path, Arc::new(provider)) { + Ok(definition) => Arc::new(definition), + Err(error) => { + record_error(&mut load_errors, &spec.id, &path, error); + continue; + } + }; + if definitions.contains_key(&definition.id) { + record_error( + &mut load_errors, + &definition.id, + &path, + format!("duplicate Agent id '{}'", definition.id), + ); + continue; } - if !definition.skills.is_empty() - && !definition.tools.iter().any(|tool| tool == "get_skill") - { - return Err(AgentCatalogError::InvalidTool { - agent: definition.id.clone(), - tool: "get_skill".to_string(), - reason: "skills require get_skill in the definition tool list".to_string(), - }); + if let Err(error) = validate_definition_tools(&definition, tools) { + record_error(&mut load_errors, &definition.id, &path, error); + continue; + } + if let Err(error) = validate_definition_skills(&definition, &loaded_skills) { + record_error(&mut load_errors, &definition.id, &path, error); + continue; } definitions.insert(definition.id.clone(), definition); } + // Delegation edges are validated only after every candidate has been + // loaded. An explicit `delegates` entry that points at an Agent absent + // from the active catalog (missing file, frontmatter-disabled, or + // failed validation) disables the delegating Agent as well, so no + // invalid edge is ever activated. A `*` entry and the implicit + // default (`general-purpose`) are not cascaded. + let mut cascaded: Vec = Vec::new(); for definition in definitions.values() { let Some(delegates) = definition.delegates.as_deref() else { continue; }; - // A `*` entry means "any other Agent" and skips target validation. if delegates.iter().any(|target| target == "*") { continue; } - for target in delegates { - if !definitions.contains_key(target) { - return Err(AgentCatalogError::UnknownDelegate { - agent: definition.id.clone(), - target: target.clone(), - }); - } + if let Some(target) = delegates + .iter() + .find(|target| !definitions.contains_key(*target)) + { + record_error( + &mut load_errors, + &definition.id, + &definition.source_path, + format!( + "delegates to '{target}' which is not in the active catalog (missing, \ + disabled, or failed validation)" + ), + ); + cascaded.push(definition.id.clone()); } } + for id in cascaded { + definitions.remove(&id); + } Ok(Self { definitions, + load_errors, runtime_generation, max_tree_depth: config.max_tree_depth, max_runs_per_tree: config.max_runs_per_tree, }) } + pub fn load_errors(&self) -> &[CatalogEntryError] { + &self.load_errors + } + pub fn runtime_generation(&self) -> u64 { self.runtime_generation } @@ -419,6 +473,57 @@ fn validate_definition_tools( Ok(()) } +fn validate_definition_skills( + definition: &AgentDefinition, + loaded_skills: &HashSet, +) -> Result<(), AgentCatalogError> { + for skill in &definition.skills { + if !loaded_skills.contains(skill) { + return Err(AgentCatalogError::UnknownSkill { + agent: definition.id.clone(), + skill: skill.clone(), + }); + } + } + if !definition.skills.is_empty() + && !definition.tools.iter().any(|tool| tool == "get_skill") + { + return Err(AgentCatalogError::InvalidTool { + agent: definition.id.clone(), + tool: "get_skill".to_string(), + reason: "skills require get_skill in the definition tool list".to_string(), + }); + } + Ok(()) +} + +fn file_stem_id(path: &Path) -> String { + path.file_stem() + .and_then(|value| value.to_str()) + .unwrap_or("(unknown)") + .to_string() +} + +fn record_error( + load_errors: &mut Vec, + id: &str, + path: &Path, + reason: impl ToString, +) { + let reason = reason.to_string(); + tracing::warn!( + agent = id, + path = %path.display(), + error = %reason, + "Agent definition disabled due to load error" + ); + load_errors.push(CatalogEntryError { + id: id.to_string(), + reason, + path: path.to_path_buf(), + }); +} + #[cfg(test)] mod tests { use super::*; @@ -685,4 +790,151 @@ mod tests { .is_err() ); } + + #[test] + fn broken_definition_is_skipped_and_recorded() { + let root = tempfile::tempdir().unwrap(); + std::fs::create_dir(root.path().join("agents")).unwrap(); + write_agent(root.path(), "researcher", &[], None); + std::fs::write( + root.path().join("agents/broken.md"), + "this is not a valid definition file\n", + ) + .unwrap(); + let tools = ToolRegistry::new(); + let loader = SkillsLoader::new_for_testing( + root.path().join("skills"), + root.path().join("external-skills"), + ); + let profiles = HashMap::from([("research".to_string(), provider())]); + + let catalog = AgentCatalog::load( + &config(), + root.path(), + &profiles, + &HashMap::new(), + &HashMap::new(), + root.path(), + &tools, + &loader, + 1, + ) + .unwrap(); + + assert!(catalog.get("researcher").is_some()); + assert!(catalog.get("broken").is_none()); + let errors = catalog.load_errors(); + assert_eq!(errors.len(), 1); + assert_eq!(errors[0].id, "broken"); + } + + #[test] + fn unknown_profile_disables_agent_but_loads() { + let root = tempfile::tempdir().unwrap(); + std::fs::create_dir(root.path().join("agents")).unwrap(); + std::fs::write( + root.path().join("agents/researcher.md"), + "---\nid: researcher\ndescription: research\nllm_profile: missing\n---\n# Role\n\nDo work.\n", + ) + .unwrap(); + let tools = ToolRegistry::new(); + let loader = SkillsLoader::new_for_testing( + root.path().join("skills"), + root.path().join("external-skills"), + ); + let profiles = HashMap::from([("research".to_string(), provider())]); + + let catalog = AgentCatalog::load( + &config(), + root.path(), + &profiles, + &HashMap::new(), + &HashMap::new(), + root.path(), + &tools, + &loader, + 1, + ) + .unwrap(); + + assert!(catalog.get("researcher").is_none()); + let errors = catalog.load_errors(); + assert_eq!(errors.len(), 1); + assert_eq!(errors[0].id, "researcher"); + } + + #[test] + fn delegate_to_disabled_target_cascades() { + let root = tempfile::tempdir().unwrap(); + std::fs::create_dir(root.path().join("agents")).unwrap(); + // reviewer is explicitly disabled; researcher delegates to it, so it + // must be cascaded out too. coder uses `*` and stays loaded. + std::fs::write( + root.path().join("agents/reviewer.md"), + "---\nid: reviewer\ndescription: review\nllm_profile: research\nenabled: false\n---\n# Role\n\nReview.\n", + ) + .unwrap(); + write_agent(root.path(), "researcher", &[], Some(&["reviewer"])); + write_agent(root.path(), "coder", &[], Some(&["*"])); + let tools = ToolRegistry::new(); + let loader = SkillsLoader::new_for_testing( + root.path().join("skills"), + root.path().join("external-skills"), + ); + let profiles = HashMap::from([("research".to_string(), provider())]); + + let catalog = AgentCatalog::load( + &config(), + root.path(), + &profiles, + &HashMap::new(), + &HashMap::new(), + root.path(), + &tools, + &loader, + 1, + ) + .unwrap(); + + assert!(catalog.get("reviewer").is_none()); + assert!(catalog.get("researcher").is_none(), "must cascade"); + assert!(catalog.get("coder").is_some(), "`*` must not cascade"); + assert!(catalog + .load_errors() + .iter() + .any(|error| error.id == "researcher" && error.reason.contains("reviewer"))); + } + + #[test] + fn implicit_default_delegation_tolerates_missing_general_purpose() { + let root = tempfile::tempdir().unwrap(); + std::fs::create_dir(root.path().join("agents")).unwrap(); + // No `delegates` field means "default to general-purpose"; when + // general-purpose is absent, the Agent still loads but cannot + // delegate further. + write_agent(root.path(), "writer", &[], None); + let tools = ToolRegistry::new(); + let loader = SkillsLoader::new_for_testing( + root.path().join("skills"), + root.path().join("external-skills"), + ); + let profiles = HashMap::from([("research".to_string(), provider())]); + + let catalog = AgentCatalog::load( + &config(), + root.path(), + &profiles, + &HashMap::new(), + &HashMap::new(), + root.path(), + &tools, + &loader, + 1, + ) + .unwrap(); + + assert!(catalog.get("writer").is_some()); + assert!(catalog.load_errors().is_empty()); + assert!(catalog.delegate_targets("writer").is_empty()); + } } diff --git a/src/agent/definition.rs b/src/agent/definition.rs index 1698441..bd7c0bd 100644 --- a/src/agent/definition.rs +++ b/src/agent/definition.rs @@ -333,6 +333,82 @@ pub fn parse_definition_info(path: &Path) -> Result (Option, Option) { + match parse_definition_info(path) { + Ok(info) => (Some(info), None), + Err(error) => { + let stem = path + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or("agent") + .to_string(); + let mut role_prompt = String::new(); + let mut frontmatter = AgentFrontmatter { + id: stem.clone(), + description: String::new(), + llm_profile: None, + provider: None, + model: None, + token_limit: None, + max_tool_iterations: None, + tools: Vec::new(), + enabled: false, + delegates: None, + skills: Vec::new(), + limits: AgentLimits::default(), + signal: None, + }; + if let Ok(content) = std::fs::read_to_string(path) { + let normalized = content.replace("\r\n", "\n"); + if let Some(rest) = normalized.strip_prefix("---\n") + && let Some((yaml, body)) = rest.split_once("\n---\n") + { + role_prompt = body.trim().to_string(); + #[derive(serde::Deserialize)] + struct Lenient { + id: Option, + description: Option, + llm_profile: Option, + provider: Option, + model: Option, + token_limit: Option, + max_tool_iterations: Option, + tools: Option>, + delegates: Option>, + skills: Option>, + } + if let Ok(parsed) = serde_yaml::from_str::(yaml) { + frontmatter.id = parsed.id.unwrap_or(stem); + frontmatter.description = parsed.description.unwrap_or_default(); + frontmatter.llm_profile = parsed.llm_profile; + frontmatter.provider = parsed.provider; + frontmatter.model = parsed.model; + frontmatter.token_limit = parsed.token_limit; + frontmatter.max_tool_iterations = parsed.max_tool_iterations; + frontmatter.tools = parsed.tools.unwrap_or_default(); + frontmatter.delegates = parsed.delegates; + frontmatter.skills = parsed.skills.unwrap_or_default(); + } + } + } + ( + Some(AgentDefinitionInfo { + frontmatter, + role_prompt, + }), + Some(error.to_string()), + ) + } + } +} + /// Serialize a definition back to the Markdown file format. pub fn serialize_definition(info: &AgentDefinitionInfo) -> String { let yaml = serde_yaml::to_string(&info.frontmatter).unwrap_or_default(); diff --git a/src/agent/mod.rs b/src/agent/mod.rs index 3b56d27..1172cea 100644 --- a/src/agent/mod.rs +++ b/src/agent/mod.rs @@ -15,7 +15,7 @@ pub mod system_prompt; pub mod turn_event; pub use agent_loop::{AgentError, AgentLoop, AgentProcessResult}; -pub use catalog::{AgentCatalog, AgentCatalogError}; +pub use catalog::{AgentCatalog, AgentCatalogError, CatalogEntryError}; pub use context_compressor::{ContextCompressor, estimate_tokens}; pub use coordinator::{AgentCoordinator, CoordinatorError}; pub use definition::{AgentDefinition, AgentLimits}; diff --git a/src/gateway/http.rs b/src/gateway/http.rs index efda05e..5220f6d 100644 --- a/src/gateway/http.rs +++ b/src/gateway/http.rs @@ -867,8 +867,17 @@ pub async fn get_tools(State(state): State>) -> Result>) -> Result, ApiError> { + let load_errors: std::collections::HashMap<&str, &crate::agent::CatalogEntryError> = state + .agent_catalog + .load_errors() + .iter() + .map(|error| (error.id.as_str(), error)) + .collect(); let mut agents = Vec::new(); let entries = match std::fs::read_dir(&state.agents_dir) { Ok(entries) => entries, @@ -881,30 +890,31 @@ pub async fn list_agents(State(state): State>) -> Result { - let fm = &info.frontmatter; - agents.push(json!({ - "id": fm.id, - "description": fm.description, - "enabled": fm.enabled, - "llm_profile": fm.llm_profile, - "provider": fm.provider, - "model": fm.model, - "token_limit": fm.token_limit, - "max_tool_iterations": fm.max_tool_iterations, - "tools": fm.tools, - "delegates": fm.delegates, - "skills": fm.skills, - "limits": fm.limits, - "signal": fm.signal, - "role_prompt": info.role_prompt, - })); - } - Err(error) => { - tracing::warn!(path = %path.display(), error = %error, "Failed to parse Agent definition"); - } - } + let (info, parse_error) = crate::agent::definition::parse_definition_lenient(&path); + let Some(info) = info else { + continue; + }; + let fm = &info.frontmatter; + let load_error = load_errors.get(fm.id.as_str()).map(|error| error.reason.clone()); + let disabled = load_error.is_some() || parse_error.is_some(); + agents.push(json!({ + "id": fm.id, + "description": fm.description, + "enabled": !disabled && fm.enabled, + "llm_profile": fm.llm_profile, + "provider": fm.provider, + "model": fm.model, + "token_limit": fm.token_limit, + "max_tool_iterations": fm.max_tool_iterations, + "tools": fm.tools, + "delegates": fm.delegates, + "skills": fm.skills, + "limits": fm.limits, + "signal": fm.signal, + "role_prompt": info.role_prompt, + "load_error": load_error, + "parse_error": parse_error, + })); } agents.sort_by(|a, b| a["id"].as_str().cmp(&b["id"].as_str())); Ok(Json(json!({ "agents": agents }))) diff --git a/webui/package-lock.json b/webui/package-lock.json index 73a7453..736257d 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -1,12 +1,12 @@ { "name": "picobot-webui", - "version": "1.15.0", + "version": "1.16.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "picobot-webui", - "version": "1.15.0", + "version": "1.16.0", "dependencies": { "bits-ui": "^2.0.0", "dompurify": "^3.4.12", diff --git a/webui/package.json b/webui/package.json index e79b2b5..710baca 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,7 +1,7 @@ { "name": "picobot-webui", "private": true, - "version": "1.15.0", + "version": "1.16.0", "type": "module", "engines": { "node": ">=20" diff --git a/webui/src/lib/Icon.svelte b/webui/src/lib/Icon.svelte index 0e4fb57..1b5b446 100644 --- a/webui/src/lib/Icon.svelte +++ b/webui/src/lib/Icon.svelte @@ -45,6 +45,8 @@ {:else if name === "back"} + {:else if name === "warning"} + {/if} diff --git a/webui/src/lib/components/SubAgentDefinitions.svelte b/webui/src/lib/components/SubAgentDefinitions.svelte index f015725..a637756 100644 --- a/webui/src/lib/components/SubAgentDefinitions.svelte +++ b/webui/src/lib/components/SubAgentDefinitions.svelte @@ -9,6 +9,7 @@ let loading = $state(true); let error = $state(""); let editing = $state(null); + let editingError = $state(""); let saving = $state(false); let reloading = $state(false); let reloadStatus = $state(null); @@ -73,7 +74,13 @@ } async function pollReloadStatus() { - try { reloadStatus = await api("/api/config/reload/status"); } catch {} + try { + const prev = reloadStatus?.phase; + reloadStatus = await api("/api/config/reload/status"); + if (reloadStatus.phase === "active" && prev && prev !== "active") { + await load(); + } + } catch {} } function startPolling() { @@ -94,6 +101,7 @@ function startNew() { editing = blank(); + editingError = ""; } function editAgent(agent) { @@ -110,6 +118,7 @@ delegateMode = "list"; list = [...delegates]; } + // Fixing a definition should re-enable it by default. editing = { id: agent.id, description: agent.description || "", @@ -122,12 +131,14 @@ delegateMode, delegates: list, role_prompt: agent.role_prompt || "", - enabled: agent.enabled !== false, + enabled: agent.load_error || agent.parse_error ? true : agent.enabled !== false, }; + editingError = agent.load_error || agent.parse_error || ""; } function cancelEdit() { editing = null; + editingError = ""; } function delegateLabel(agent) { @@ -260,7 +271,7 @@

{agent.id}

-

{agent.description}

+

{agent.description || "(无描述)"}

provider: {agent.provider || agent.llm_profile || "—"} model: {agent.model || "—"} @@ -273,12 +284,20 @@ {#each agent.tools as tool (tool)}{tool}{/each}
{/if} + {#if agent.load_error || agent.parse_error} +
+ + 定义错误,已停用:{agent.load_error || agent.parse_error} +
+ {/if}
- + {#if !agent.load_error && !agent.parse_error} + + {/if}
@@ -294,6 +313,12 @@
{editing.id ? `编辑 ${editing.id}` : "新增子代理"}保存后点击「热重载」使改动生效
+ {#if editingError} +
+ + 此定义当前有误,已停用:{editingError}。请修正后保存,再点击「热重载」使其生效。 +
+ {/if}