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
This commit is contained in:
xiaoxixi 2026-08-14 17:50:55 +08:00
parent 38d92d5883
commit 4f35b4364d
12 changed files with 439 additions and 72 deletions

View File

@ -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 - **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 - **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 - **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 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 - **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 - **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

View File

@ -1,6 +1,6 @@
[package] [package]
name = "picobot" name = "picobot"
version = "1.15.0" version = "1.16.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]

View File

@ -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。 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 匹配时才允许提交。 每个 session 最多有一个 active plan但 plan 内多个 item 可以分别绑定不同子 Agent 并行执行。主 Agent 通过 `todo` 创建和维护计划,通过 `delegate.plan_item_id` 委托;子 Agent 的工具集由其定义文件决定,能否继续委托由其 `delegates` 白名单决定。领取 item 使用条件更新防止重复执行,迟到结果只有在 plan 仍 active 且 execution ID 匹配时才允许提交。

View File

@ -50,7 +50,7 @@ Scheduler → SessionManager.handle_cron_message → AgentLoop → send_message
- 每个活动 Turn 独占一个 TurnSink平台 message ID 和 reaction 清理状态只存在于 sink 内 - 每个活动 Turn 独占一个 TurnSink平台 message ID 和 reaction 清理状态只存在于 sink 内
- Tools 接收原始参数,通常返回字符串结果;有状态适配器额外接收 session/turn `ToolExecutionContext` - Tools 接收原始参数,通常返回字符串结果;有状态适配器额外接收 session/turn `ToolExecutionContext`
- MCP 工具在 Gateway 初始化时连接服务器、发现工具,并包装成普通 Tool 注册到 ToolRegistry - 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 不能修改计划 - 复杂任务可使用 `todo` 创建 session 级计划;多个子项可通过 `delegate.plan_item_id` 并行委托,子 Agent 不能修改计划
- WebUI 聊天复用 `/ws``cli_chat`;同源管理 API 只读取受限的日志、任务、记忆,并对白名单配置文件做原子写入 - WebUI 聊天复用 `/ws``cli_chat`;同源管理 API 只读取受限的日志、任务、记忆,并对白名单配置文件做原子写入
- WebUI 使用 Svelte 5 + ViteBits UI 提供无样式可访问组件;`cargo build` 增量生成前端到 Cargo `OUT_DIR`,再嵌入单二进制,仓库不保存生成产物 - WebUI 使用 Svelte 5 + ViteBits UI 提供无样式可访问组件;`cargo build` 增量生成前端到 Cargo `OUT_DIR`,再嵌入单二进制,仓库不保存生成产物

View File

@ -42,9 +42,24 @@ pub enum AgentCatalogError {
UnknownSkill { agent: String, skill: String }, 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)] #[derive(Debug)]
pub struct AgentCatalog { pub struct AgentCatalog {
definitions: BTreeMap<String, Arc<AgentDefinition>>, definitions: BTreeMap<String, Arc<AgentDefinition>>,
/// 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<CatalogEntryError>,
runtime_generation: u64, runtime_generation: u64,
max_tree_depth: u16, max_tree_depth: u16,
max_runs_per_tree: usize, max_runs_per_tree: usize,
@ -54,6 +69,7 @@ impl AgentCatalog {
pub fn legacy() -> Self { pub fn legacy() -> Self {
Self { Self {
definitions: BTreeMap::new(), definitions: BTreeMap::new(),
load_errors: Vec::new(),
runtime_generation: 0, runtime_generation: 0,
max_tree_depth: 4, max_tree_depth: 4,
max_runs_per_tree: 16, max_runs_per_tree: 16,
@ -102,70 +118,108 @@ impl AgentCatalog {
.map(|(name, _)| name) .map(|(name, _)| name)
.collect(); .collect();
let mut definitions = BTreeMap::new(); let mut definitions = BTreeMap::new();
let mut load_errors: Vec<CatalogEntryError> = Vec::new();
for path in paths { 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 // Disabled definitions stay on disk for the management UI but
// never enter the active catalog. // never enter the active catalog.
if !spec.enabled { if !spec.enabled {
continue; continue;
} }
let provider = let provider = match resolve_provider(
resolve_provider(&spec, provider_profiles, providers, models, workspace_dir)?; &spec,
let definition = Arc::new(parse_definition(&path, Arc::new(provider))?); 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) { if definitions.contains_key(&definition.id) {
return Err(AgentCatalogError::Config(format!( record_error(
"duplicate Agent id '{}'", &mut load_errors,
definition.id &definition.id,
))); &path,
format!("duplicate Agent id '{}'", definition.id),
);
continue;
} }
validate_definition_tools(&definition, tools)?; if let Err(error) = validate_definition_tools(&definition, tools) {
for skill in &definition.skills { record_error(&mut load_errors, &definition.id, &path, error);
if !loaded_skills.contains(skill) { continue;
return Err(AgentCatalogError::UnknownSkill {
agent: definition.id.clone(),
skill: skill.clone(),
});
} }
} if let Err(error) = validate_definition_skills(&definition, &loaded_skills) {
if !definition.skills.is_empty() record_error(&mut load_errors, &definition.id, &path, error);
&& !definition.tools.iter().any(|tool| tool == "get_skill") continue;
{
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(),
});
} }
definitions.insert(definition.id.clone(), definition); 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<String> = Vec::new();
for definition in definitions.values() { for definition in definitions.values() {
let Some(delegates) = definition.delegates.as_deref() else { let Some(delegates) = definition.delegates.as_deref() else {
continue; continue;
}; };
// A `*` entry means "any other Agent" and skips target validation.
if delegates.iter().any(|target| target == "*") { if delegates.iter().any(|target| target == "*") {
continue; continue;
} }
for target in delegates { if let Some(target) = delegates
if !definitions.contains_key(target) { .iter()
return Err(AgentCatalogError::UnknownDelegate { .find(|target| !definitions.contains_key(*target))
agent: definition.id.clone(), {
target: target.clone(), 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 { Ok(Self {
definitions, definitions,
load_errors,
runtime_generation, runtime_generation,
max_tree_depth: config.max_tree_depth, max_tree_depth: config.max_tree_depth,
max_runs_per_tree: config.max_runs_per_tree, max_runs_per_tree: config.max_runs_per_tree,
}) })
} }
pub fn load_errors(&self) -> &[CatalogEntryError] {
&self.load_errors
}
pub fn runtime_generation(&self) -> u64 { pub fn runtime_generation(&self) -> u64 {
self.runtime_generation self.runtime_generation
} }
@ -419,6 +473,57 @@ fn validate_definition_tools(
Ok(()) Ok(())
} }
fn validate_definition_skills(
definition: &AgentDefinition,
loaded_skills: &HashSet<String>,
) -> 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<CatalogEntryError>,
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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -685,4 +790,151 @@ mod tests {
.is_err() .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());
}
} }

View File

@ -333,6 +333,82 @@ pub fn parse_definition_info(path: &Path) -> Result<AgentDefinitionInfo, AgentDe
}) })
} }
/// Best-effort view of a definition file for the management UI. A fully
/// valid file returns `(Some(info), None)`; a broken file returns a partial
/// `info` (id from the file stem, the role body, and whichever frontmatter
/// fields still parse) together with the parse error, so broken definitions
/// remain listed and editable instead of being silently hidden.
pub fn parse_definition_lenient(
path: &Path,
) -> (Option<AgentDefinitionInfo>, Option<String>) {
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<String>,
description: Option<String>,
llm_profile: Option<String>,
provider: Option<String>,
model: Option<String>,
token_limit: Option<usize>,
max_tool_iterations: Option<usize>,
tools: Option<Vec<String>>,
delegates: Option<Vec<String>>,
skills: Option<Vec<String>>,
}
if let Ok(parsed) = serde_yaml::from_str::<Lenient>(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. /// Serialize a definition back to the Markdown file format.
pub fn serialize_definition(info: &AgentDefinitionInfo) -> String { pub fn serialize_definition(info: &AgentDefinitionInfo) -> String {
let yaml = serde_yaml::to_string(&info.frontmatter).unwrap_or_default(); let yaml = serde_yaml::to_string(&info.frontmatter).unwrap_or_default();

View File

@ -15,7 +15,7 @@ pub mod system_prompt;
pub mod turn_event; pub mod turn_event;
pub use agent_loop::{AgentError, AgentLoop, AgentProcessResult}; 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 context_compressor::{ContextCompressor, estimate_tokens};
pub use coordinator::{AgentCoordinator, CoordinatorError}; pub use coordinator::{AgentCoordinator, CoordinatorError};
pub use definition::{AgentDefinition, AgentLimits}; pub use definition::{AgentDefinition, AgentLimits};

View File

@ -867,8 +867,17 @@ pub async fn get_tools(State(state): State<Arc<GatewayState>>) -> Result<Json<Va
} }
/// List Agent definition files (enabled and disabled) from the resolved /// List Agent definition files (enabled and disabled) from the resolved
/// definitions directory. /// definitions directory. Broken definitions are listed too (with their parse
/// error) and every entry is annotated with any load error from the active
/// catalog generation, so a definition that failed validation can be fixed and
/// re-enabled from the UI instead of silently disappearing.
pub async fn list_agents(State(state): State<Arc<GatewayState>>) -> Result<Json<Value>, ApiError> { pub async fn list_agents(State(state): State<Arc<GatewayState>>) -> Result<Json<Value>, 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 mut agents = Vec::new();
let entries = match std::fs::read_dir(&state.agents_dir) { let entries = match std::fs::read_dir(&state.agents_dir) {
Ok(entries) => entries, Ok(entries) => entries,
@ -881,13 +890,17 @@ pub async fn list_agents(State(state): State<Arc<GatewayState>>) -> Result<Json<
if path.extension().and_then(|e| e.to_str()) != Some("md") { if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue; continue;
} }
match crate::agent::definition::parse_definition_info(&path) { let (info, parse_error) = crate::agent::definition::parse_definition_lenient(&path);
Ok(info) => { let Some(info) = info else {
continue;
};
let fm = &info.frontmatter; 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!({ agents.push(json!({
"id": fm.id, "id": fm.id,
"description": fm.description, "description": fm.description,
"enabled": fm.enabled, "enabled": !disabled && fm.enabled,
"llm_profile": fm.llm_profile, "llm_profile": fm.llm_profile,
"provider": fm.provider, "provider": fm.provider,
"model": fm.model, "model": fm.model,
@ -899,13 +912,10 @@ pub async fn list_agents(State(state): State<Arc<GatewayState>>) -> Result<Json<
"limits": fm.limits, "limits": fm.limits,
"signal": fm.signal, "signal": fm.signal,
"role_prompt": info.role_prompt, "role_prompt": info.role_prompt,
"load_error": load_error,
"parse_error": parse_error,
})); }));
} }
Err(error) => {
tracing::warn!(path = %path.display(), error = %error, "Failed to parse Agent definition");
}
}
}
agents.sort_by(|a, b| a["id"].as_str().cmp(&b["id"].as_str())); agents.sort_by(|a, b| a["id"].as_str().cmp(&b["id"].as_str()));
Ok(Json(json!({ "agents": agents }))) Ok(Json(json!({ "agents": agents })))
} }

View File

@ -1,12 +1,12 @@
{ {
"name": "picobot-webui", "name": "picobot-webui",
"version": "1.15.0", "version": "1.16.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "picobot-webui", "name": "picobot-webui",
"version": "1.15.0", "version": "1.16.0",
"dependencies": { "dependencies": {
"bits-ui": "^2.0.0", "bits-ui": "^2.0.0",
"dompurify": "^3.4.12", "dompurify": "^3.4.12",

View File

@ -1,7 +1,7 @@
{ {
"name": "picobot-webui", "name": "picobot-webui",
"private": true, "private": true,
"version": "1.15.0", "version": "1.16.0",
"type": "module", "type": "module",
"engines": { "engines": {
"node": ">=20" "node": ">=20"

View File

@ -45,6 +45,8 @@
<rect x="2.75" y="3.25" width="14.5" height="13.5" rx="2" /><path d="M12.25 3.25v13.5" /> <rect x="2.75" y="3.25" width="14.5" height="13.5" rx="2" /><path d="M12.25 3.25v13.5" />
{:else if name === "back"} {:else if name === "back"}
<path d="M12.5 4.5 6.25 10l6.25 5.5M7 10h6.5" /> <path d="M12.5 4.5 6.25 10l6.25 5.5M7 10h6.5" />
{:else if name === "warning"}
<path d="M10 2.75 2.5 16h15L10 2.75Z" /><path d="M10 8v3.5" /><path d="M10 13.75h.01" />
{/if} {/if}
</svg> </svg>

View File

@ -9,6 +9,7 @@
let loading = $state(true); let loading = $state(true);
let error = $state(""); let error = $state("");
let editing = $state(null); let editing = $state(null);
let editingError = $state("");
let saving = $state(false); let saving = $state(false);
let reloading = $state(false); let reloading = $state(false);
let reloadStatus = $state(null); let reloadStatus = $state(null);
@ -73,7 +74,13 @@
} }
async function pollReloadStatus() { 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() { function startPolling() {
@ -94,6 +101,7 @@
function startNew() { function startNew() {
editing = blank(); editing = blank();
editingError = "";
} }
function editAgent(agent) { function editAgent(agent) {
@ -110,6 +118,7 @@
delegateMode = "list"; delegateMode = "list";
list = [...delegates]; list = [...delegates];
} }
// Fixing a definition should re-enable it by default.
editing = { editing = {
id: agent.id, id: agent.id,
description: agent.description || "", description: agent.description || "",
@ -122,12 +131,14 @@
delegateMode, delegateMode,
delegates: list, delegates: list,
role_prompt: agent.role_prompt || "", 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() { function cancelEdit() {
editing = null; editing = null;
editingError = "";
} }
function delegateLabel(agent) { function delegateLabel(agent) {
@ -260,7 +271,7 @@
<div class="card-row"> <div class="card-row">
<div> <div>
<h3>{agent.id} <StatusBadge status={agent.enabled ? "enabled" : "disabled"} /></h3> <h3>{agent.id} <StatusBadge status={agent.enabled ? "enabled" : "disabled"} /></h3>
<p>{agent.description}</p> <p>{agent.description || "(无描述)"}</p>
<div class="meta"> <div class="meta">
<span>provider: {agent.provider || agent.llm_profile || "—"}</span> <span>provider: {agent.provider || agent.llm_profile || "—"}</span>
<span>model: {agent.model || "—"}</span> <span>model: {agent.model || "—"}</span>
@ -273,12 +284,20 @@
{#each agent.tools as tool (tool)}<span class="tag" title={toolDesc(tool)}>{tool}</span>{/each} {#each agent.tools as tool (tool)}<span class="tag" title={toolDesc(tool)}>{tool}</span>{/each}
</div> </div>
{/if} {/if}
{#if agent.load_error || agent.parse_error}
<div class="agent-error">
<Icon name="warning" size={14} />
<span>定义错误,已停用:{agent.load_error || agent.parse_error}</span>
</div>
{/if}
</div> </div>
<div class="card-actions"> <div class="card-actions">
<button class="secondary" onclick={() => editAgent(agent)}><Icon name="more" size={15} />编辑</button> <button class="secondary" onclick={() => editAgent(agent)}><Icon name="more" size={15} />编辑</button>
{#if !agent.load_error && !agent.parse_error}
<button class="switch" data-state={agent.enabled ? "checked" : "unchecked"} aria-label="启用/禁用" onclick={() => toggleEnabled(agent)}> <button class="switch" data-state={agent.enabled ? "checked" : "unchecked"} aria-label="启用/禁用" onclick={() => toggleEnabled(agent)}>
<span class="switch-thumb" data-state={agent.enabled ? "checked" : "unchecked"}></span> <span class="switch-thumb" data-state={agent.enabled ? "checked" : "unchecked"}></span>
</button> </button>
{/if}
<button class="icon-button danger" aria-label="删除" onclick={() => remove(agent)}><Icon name="dismiss" size={16} /></button> <button class="icon-button danger" aria-label="删除" onclick={() => remove(agent)}><Icon name="dismiss" size={16} /></button>
</div> </div>
</div> </div>
@ -294,6 +313,12 @@
<div><strong>{editing.id ? `编辑 ${editing.id}` : "新增子代理"}</strong><small>保存后点击「热重载」使改动生效</small></div> <div><strong>{editing.id ? `编辑 ${editing.id}` : "新增子代理"}</strong><small>保存后点击「热重载」使改动生效</small></div>
<button class="icon-button" aria-label="关闭" onclick={cancelEdit}><Icon name="dismiss" size={18} /></button> <button class="icon-button" aria-label="关闭" onclick={cancelEdit}><Icon name="dismiss" size={18} /></button>
</div> </div>
{#if editingError}
<div class="agent-error editor-error">
<Icon name="warning" size={16} />
<span>此定义当前有误,已停用:{editingError}。请修正后保存,再点击「热重载」使其生效。</span>
</div>
{/if}
<div class="agent-form"> <div class="agent-form">
<div class="form-row"> <div class="form-row">
<label>ID <label>ID
@ -373,6 +398,8 @@
.tag-row.selectable .tag.picked { border-color: var(--accent-border); color: var(--accent); background: var(--accent-soft); } .tag-row.selectable .tag.picked { border-color: var(--accent-border); color: var(--accent); background: var(--accent-soft); }
.card-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; } .card-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.card-actions button { white-space: nowrap; flex-shrink: 0; } .card-actions button { white-space: nowrap; flex-shrink: 0; }
.agent-error { display: flex; align-items: flex-start; gap: 6px; margin-top: 10px; padding: 8px 10px; border: 1px solid var(--danger-border); border-radius: 6px; color: var(--danger); background: color-mix(in srgb, var(--danger) 8%, transparent); font-size: 12px; line-height: 1.5; }
.editor-error { margin: 14px 18px 0; }
.toolbar-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; } .toolbar-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.icon-button.danger:hover { color: var(--danger); border-color: var(--danger-border); } .icon-button.danger:hover { color: var(--danger); border-color: var(--danger-border); }
.modal-scrim { position: fixed; inset: 0; z-index: 40; border: 0; padding: 0; cursor: default; background: rgba(0,0,0,.45); } .modal-scrim { position: fixed; inset: 0; z-index: 40; border: 0; padding: 0; cursor: default; background: rgba(0,0,0,.45); }