Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions crates/buzz-agent/src/config.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use std::collections::HashSet;
use std::time::Duration;

pub const PROTOCOL_VERSION: u32 = 2;
Expand Down Expand Up @@ -840,6 +841,13 @@ pub struct Config {
/// existing `auto` semantics.
pub prefer_mesh_for_auto: bool,
pub hints_enabled: bool,
/// Per-agent skill allowlist. When non-empty, only these skill names are
/// offered via the built-in `load_skill` tool (and advertised in the
/// "Available Skills" hint section). An empty set — the default — disables
/// all skills, so every agent starts with zero skills and skills are only
/// available once the operator explicitly enables them for that agent.
/// Set via `BUZZ_AGENT_SKILLS` (comma- or space-separated skill names).
pub skills_allowlist: HashSet<String>,
/// Thinking/reasoning effort level. `None` = use provider default (no
/// thinking config sent). Set via `BUZZ_AGENT_THINKING_EFFORT`.
pub thinking_effort: Option<ThinkingEffort>,
Expand Down Expand Up @@ -963,6 +971,7 @@ impl Config {
require_reply: parse_env("BUZZ_AGENT_REQUIRE_REPLY", 0u8)? != 0,
hook_servers: parse_hook_servers_env("MCP_HOOK_SERVERS"),
hints_enabled: parse_env("BUZZ_AGENT_NO_HINTS", 0u8)? == 0,
skills_allowlist: parse_skills_allowlist(env("BUZZ_AGENT_SKILLS").as_deref()),
thinking_effort: parse_thinking_effort(env("BUZZ_AGENT_THINKING_EFFORT").as_deref())?,
thinking_summary: parse_thinking_summary(
env("BUZZ_AGENT_THINKING_SUMMARY").as_deref(),
Expand Down Expand Up @@ -1009,6 +1018,7 @@ impl Config {
require_reply: false,
hook_servers: HookServers::None,
hints_enabled: false,
skills_allowlist: HashSet::new(),
thinking_effort: None,
thinking_summary: ThinkingSummary::Auto,
prompt_caching: false,
Expand Down Expand Up @@ -1107,6 +1117,22 @@ fn env_or(k: &str, d: &str) -> String {
env(k).unwrap_or_else(|| d.into())
}

/// Parse the per-agent skill allowlist from `BUZZ_AGENT_SKILLS`. Splits on
/// commas and whitespace and drops empties; names match the skill `name`
/// frontmatter exactly (discovery is case-sensitive). Unset/empty → an empty
/// set, which disables all skills for that agent (the default: agents start
/// with zero skills).
fn parse_skills_allowlist(v: Option<&str>) -> HashSet<String> {
v.map(|raw| {
raw.split(|c: char| c == ',' || c.is_whitespace())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect()
})
.unwrap_or_default()
}

fn req(k: &str) -> Result<String, String> {
env(k).ok_or_else(|| format!("config: {k} required"))
}
Expand Down
110 changes: 95 additions & 15 deletions crates/buzz-agent/src/hints.rs
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,30 @@ fn collect_supporting_files_impl(
}
}

fn discover_skills_impl(cwd: &Path, home: Option<&Path>) -> Vec<SkillEntry> {
/// Filter discovered skills down to the per-agent allowlist.
///
/// `Some(allow)` means only skills whose `name` is in `allow` are offered;
/// an empty set therefore disables all skills (the default, so agents start
/// with zero skills). `None` keeps every discovered skill (used by tests and
/// callers that perform no per-agent gating).
fn apply_allowlist(
skills: Vec<SkillEntry>,
allowed: Option<&HashSet<String>>,
) -> Vec<SkillEntry> {
match allowed {
Some(allow) => skills
.into_iter()
.filter(|s| allow.contains(&s.name))
.collect(),
None => skills,
}
}

fn discover_skills_impl(
cwd: &Path,
home: Option<&Path>,
allowed: Option<&HashSet<String>>,
) -> Vec<SkillEntry> {
let mut seen = HashSet::new();
let mut skills = Vec::new();

Expand All @@ -213,16 +236,23 @@ fn discover_skills_impl(cwd: &Path, home: Option<&Path>) -> Vec<SkillEntry> {
scan_skill_dir(&home.join(".agents/skills"), &mut seen, &mut skills);
}

skills
apply_allowlist(skills, allowed)
}

pub fn build_hints_section(cwd: &Path) -> (String, Vec<SkillEntry>) {
build_hints_section_impl(cwd, home_dir().as_deref())
pub fn build_hints_section(
cwd: &Path,
allowed: Option<&HashSet<String>>,
) -> (String, Vec<SkillEntry>) {
build_hints_section_impl(cwd, home_dir().as_deref(), allowed)
}

fn build_hints_section_impl(cwd: &Path, home: Option<&Path>) -> (String, Vec<SkillEntry>) {
fn build_hints_section_impl(
cwd: &Path,
home: Option<&Path>,
allowed: Option<&HashSet<String>>,
) -> (String, Vec<SkillEntry>) {
let hints_text = load_hint_files_impl(cwd, home);
let skills = discover_skills_impl(cwd, home);
let skills = discover_skills_impl(cwd, home, allowed);

if hints_text.is_empty() && skills.is_empty() {
return (String::new(), skills);
Expand Down Expand Up @@ -368,7 +398,7 @@ mod tests {
)
.unwrap();

let skills = discover_skills_impl(cwd, None);
let skills = discover_skills_impl(cwd, None, None);
assert_eq!(skills.len(), 2);
let names: Vec<&str> = skills.iter().map(|s| s.name.as_str()).collect();
assert!(names.contains(&"my-skill"), "missing my-skill");
Expand Down Expand Up @@ -402,7 +432,7 @@ mod tests {
)
.unwrap();

let skills = discover_skills_impl(cwd, None);
let skills = discover_skills_impl(cwd, None, None);
assert_eq!(skills.len(), 1, "duplicate name should be deduplicated");
assert_eq!(
skills[0].description, "from agents",
Expand All @@ -429,14 +459,64 @@ mod tests {
)
.unwrap();

let skills = discover_skills_impl(cwd, None);
let skills = discover_skills_impl(cwd, None, None);
assert!(skills.is_empty(), "entry without name should be skipped");
}

fn setup_two_skills(cwd: &Path) {
let a = cwd.join(".agents/skills/alpha");
std::fs::create_dir_all(&a).unwrap();
std::fs::write(
a.join("SKILL.md"),
"---\nname: alpha\ndescription: Alpha\n---\nAlpha body.\n",
)
.unwrap();

let b = cwd.join(".agents/skills/beta");
std::fs::create_dir_all(&b).unwrap();
std::fs::write(
b.join("SKILL.md"),
"---\nname: beta\ndescription: Beta\n---\nBeta body.\n",
)
.unwrap();
}

#[test]
fn discover_skills_empty_allowlist_gives_zero_skills() {
let tmp = TempDir::new().unwrap();
setup_two_skills(tmp.path());

// The default state: an empty allowlist means NO skills are offered.
let allowed = HashSet::new();
let skills = discover_skills_impl(tmp.path(), None, Some(&allowed));
assert!(skills.is_empty(), "empty allowlist should yield zero skills");
}

#[test]
fn discover_skills_allowlist_keeps_only_members() {
let tmp = TempDir::new().unwrap();
setup_two_skills(tmp.path());

let mut allowed = HashSet::new();
allowed.insert("beta".to_string());
let skills = discover_skills_impl(tmp.path(), None, Some(&allowed));
assert_eq!(skills.len(), 1, "only allowlisted skill should be kept");
assert_eq!(skills[0].name, "beta");
}

#[test]
fn discover_skills_none_keeps_all() {
let tmp = TempDir::new().unwrap();
setup_two_skills(tmp.path());

let skills = discover_skills_impl(tmp.path(), None, None);
assert_eq!(skills.len(), 2, "None allowlist keeps every discovered skill");
}

#[test]
fn build_hints_section_empty() {
let tmp = TempDir::new().unwrap();
let (result, skills) = build_hints_section_impl(tmp.path(), None);
let (result, skills) = build_hints_section_impl(tmp.path(), None, None);
assert_eq!(result, "");
assert!(skills.is_empty());
}
Expand All @@ -456,7 +536,7 @@ mod tests {
)
.unwrap();

let (result, skills) = build_hints_section_impl(cwd, None);
let (result, skills) = build_hints_section_impl(cwd, None, None);

assert!(
result.contains("# Additional Instructions"),
Expand Down Expand Up @@ -567,7 +647,7 @@ mod tests {
"---\nname: global-skill\ndescription: A global skill\n---\nGlobal body.\n",
)
.unwrap();
let skills = discover_skills_impl(cwd.path(), Some(home.path()));
let skills = discover_skills_impl(cwd.path(), Some(home.path()), None);
assert_eq!(skills.len(), 1);
assert_eq!(skills[0].name, "global-skill");
}
Expand All @@ -593,7 +673,7 @@ mod tests {
)
.unwrap();

let skills = discover_skills_impl(cwd.path(), Some(home.path()));
let skills = discover_skills_impl(cwd.path(), Some(home.path()), None);
assert_eq!(skills.len(), 1, "duplicate name should be deduplicated");
assert_eq!(
skills[0].description, "from project",
Expand All @@ -611,7 +691,7 @@ mod tests {
"---\nname: local\ndescription: Local skill\n---\nBody.\n",
)
.unwrap();
let skills = discover_skills_impl(cwd.path(), None);
let skills = discover_skills_impl(cwd.path(), None, None);
assert_eq!(skills.len(), 1);
assert_eq!(skills[0].name, "local");
}
Expand Down Expand Up @@ -713,7 +793,7 @@ mod tests {
std::fs::create_dir_all(&refs).unwrap();
std::fs::write(refs.join("guide.md"), "guide content").unwrap();

let skills = discover_skills_impl(cwd, None);
let skills = discover_skills_impl(cwd, None, None);
assert_eq!(skills.len(), 1);
assert_eq!(skills[0].name, "with-refs");
assert_eq!(skills[0].supporting_files.len(), 1);
Expand Down
5 changes: 4 additions & 1 deletion crates/buzz-agent/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -363,7 +363,10 @@ async fn session_new(app: &Arc<App>, id: Value, params: Value, wire_tx: &WireSen
}
}
let (hints_text, skills) = if app.cfg.hints_enabled {
hints::build_hints_section(std::path::Path::new(&p.cwd))
hints::build_hints_section(
std::path::Path::new(&p.cwd),
Some(&app.cfg.skills_allowlist),
)
} else {
(String::new(), Vec::new())
};
Expand Down
1 change: 1 addition & 0 deletions crates/buzz-agent/src/llm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2735,6 +2735,7 @@ mod tests {
openai_api: OpenAiApi::Chat,
prefer_mesh_for_auto: false,
hints_enabled: true,
skills_allowlist: std::collections::HashSet::new(),
thinking_effort: None,
thinking_summary: ThinkingSummary::Auto,
prompt_caching: true,
Expand Down
18 changes: 14 additions & 4 deletions crates/buzz-agent/tests/hints_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,8 @@ async fn skills_loaded_from_agents_skills_dir() {
.unwrap();

let llm = spawn_capturing_llm(vec![openai_text("done")]).await;
let mut h = Harness::spawn_with_env(&llm.url, &[]).await;
let mut h =
Harness::spawn_with_env(&llm.url, &[("BUZZ_AGENT_SKILLS", "test-skill")]).await;
let sid = init_session(&mut h, cwd.to_str().unwrap()).await;

let p = h
Expand Down Expand Up @@ -412,7 +413,14 @@ async fn global_skills_loaded_and_project_wins() {

let llm = spawn_capturing_llm(vec![openai_text("done")]).await;
let mut h =
Harness::spawn_with_env(&llm.url, &[("HOME", home_tmp.path().to_str().unwrap())]).await;
Harness::spawn_with_env(
&llm.url,
&[
("HOME", home_tmp.path().to_str().unwrap()),
("BUZZ_AGENT_SKILLS", "global-only,shared-name"),
],
)
.await;
let sid = init_session(&mut h, cwd_tmp.path().to_str().unwrap()).await;

let p = h
Expand Down Expand Up @@ -485,7 +493,8 @@ async fn symlinked_skill_dir_is_discovered() {
std::os::unix::fs::symlink(&real_skill_dir, skills_dir.join("symlinked-skill")).unwrap();

let llm = spawn_capturing_llm(vec![openai_text("done")]).await;
let mut h = Harness::spawn_with_env(&llm.url, &[]).await;
let mut h =
Harness::spawn_with_env(&llm.url, &[("BUZZ_AGENT_SKILLS", "symlinked-skill")]).await;
let sid = init_session(&mut h, cwd.to_str().unwrap()).await;

let p = h
Expand Down Expand Up @@ -547,7 +556,8 @@ async fn load_skill_tool_returns_body() {
let end_turn = openai_text("done");

let llm = spawn_capturing_llm(vec![load_skill_call, end_turn]).await;
let mut h = Harness::spawn_with_env(&llm.url, &[]).await;
let mut h =
Harness::spawn_with_env(&llm.url, &[("BUZZ_AGENT_SKILLS", "my-skill")]).await;
let sid = init_session(&mut h, cwd.to_str().unwrap()).await;

let p = h
Expand Down
2 changes: 2 additions & 0 deletions desktop/src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ mod qr_download;
mod relay_members;
mod relay_reconnect;
mod social;
mod skills;
mod team_snapshot;
mod teams;
mod updater;
Expand Down Expand Up @@ -109,6 +110,7 @@ pub use qr_download::*;
pub use relay_members::*;
pub use relay_reconnect::*;
pub use social::*;
pub use skills::*;
pub use team_snapshot::*;
pub use teams::*;
pub use updater::*;
Expand Down
Loading