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:
parent
38d92d5883
commit
4f35b4364d
@ -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
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "picobot"
|
name = "picobot"
|
||||||
version = "1.15.0"
|
version = "1.16.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@ -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 匹配时才允许提交。
|
||||||
|
|
||||||
|
|||||||
@ -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 + Vite,Bits UI 提供无样式可访问组件;`cargo build` 增量生成前端到 Cargo `OUT_DIR`,再嵌入单二进制,仓库不保存生成产物
|
- WebUI 使用 Svelte 5 + Vite,Bits UI 提供无样式可访问组件;`cargo build` 增量生成前端到 Cargo `OUT_DIR`,再嵌入单二进制,仓库不保存生成产物
|
||||||
|
|||||||
@ -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,
|
||||||
if definitions.contains_key(&definition.id) {
|
providers,
|
||||||
return Err(AgentCatalogError::Config(format!(
|
models,
|
||||||
"duplicate Agent id '{}'",
|
workspace_dir,
|
||||||
definition.id
|
) {
|
||||||
)));
|
Ok(provider) => provider,
|
||||||
}
|
Err(error) => {
|
||||||
validate_definition_tools(&definition, tools)?;
|
record_error(&mut load_errors, &spec.id, &path, error);
|
||||||
for skill in &definition.skills {
|
continue;
|
||||||
if !loaded_skills.contains(skill) {
|
|
||||||
return Err(AgentCatalogError::UnknownSkill {
|
|
||||||
agent: definition.id.clone(),
|
|
||||||
skill: skill.clone(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
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()
|
if let Err(error) = validate_definition_tools(&definition, tools) {
|
||||||
&& !definition.tools.iter().any(|tool| tool == "get_skill")
|
record_error(&mut load_errors, &definition.id, &path, error);
|
||||||
{
|
continue;
|
||||||
return Err(AgentCatalogError::InvalidTool {
|
}
|
||||||
agent: definition.id.clone(),
|
if let Err(error) = validate_definition_skills(&definition, &loaded_skills) {
|
||||||
tool: "get_skill".to_string(),
|
record_error(&mut load_errors, &definition.id, &path, error);
|
||||||
reason: "skills require get_skill in the definition tool list".to_string(),
|
continue;
|
||||||
});
|
|
||||||
}
|
}
|
||||||
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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};
|
||||||
|
|||||||
@ -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,30 +890,31 @@ 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 {
|
||||||
let fm = &info.frontmatter;
|
continue;
|
||||||
agents.push(json!({
|
};
|
||||||
"id": fm.id,
|
let fm = &info.frontmatter;
|
||||||
"description": fm.description,
|
let load_error = load_errors.get(fm.id.as_str()).map(|error| error.reason.clone());
|
||||||
"enabled": fm.enabled,
|
let disabled = load_error.is_some() || parse_error.is_some();
|
||||||
"llm_profile": fm.llm_profile,
|
agents.push(json!({
|
||||||
"provider": fm.provider,
|
"id": fm.id,
|
||||||
"model": fm.model,
|
"description": fm.description,
|
||||||
"token_limit": fm.token_limit,
|
"enabled": !disabled && fm.enabled,
|
||||||
"max_tool_iterations": fm.max_tool_iterations,
|
"llm_profile": fm.llm_profile,
|
||||||
"tools": fm.tools,
|
"provider": fm.provider,
|
||||||
"delegates": fm.delegates,
|
"model": fm.model,
|
||||||
"skills": fm.skills,
|
"token_limit": fm.token_limit,
|
||||||
"limits": fm.limits,
|
"max_tool_iterations": fm.max_tool_iterations,
|
||||||
"signal": fm.signal,
|
"tools": fm.tools,
|
||||||
"role_prompt": info.role_prompt,
|
"delegates": fm.delegates,
|
||||||
}));
|
"skills": fm.skills,
|
||||||
}
|
"limits": fm.limits,
|
||||||
Err(error) => {
|
"signal": fm.signal,
|
||||||
tracing::warn!(path = %path.display(), error = %error, "Failed to parse Agent definition");
|
"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()));
|
agents.sort_by(|a, b| a["id"].as_str().cmp(&b["id"].as_str()));
|
||||||
Ok(Json(json!({ "agents": agents })))
|
Ok(Json(json!({ "agents": agents })))
|
||||||
|
|||||||
4
webui/package-lock.json
generated
4
webui/package-lock.json
generated
@ -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",
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
<button class="switch" data-state={agent.enabled ? "checked" : "unchecked"} aria-label="启用/禁用" onclick={() => toggleEnabled(agent)}>
|
{#if !agent.load_error && !agent.parse_error}
|
||||||
<span class="switch-thumb" data-state={agent.enabled ? "checked" : "unchecked"}></span>
|
<button class="switch" data-state={agent.enabled ? "checked" : "unchecked"} aria-label="启用/禁用" onclick={() => toggleEnabled(agent)}>
|
||||||
</button>
|
<span class="switch-thumb" data-state={agent.enabled ? "checked" : "unchecked"}></span>
|
||||||
|
</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); }
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user