Skip to content
Open
57 changes: 51 additions & 6 deletions crates/buzz-agent/src/builtin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -198,14 +198,22 @@ async fn load_supporting_file(
.await
.unwrap_or_else(|e| Err(std::io::Error::other(e)))
{
Ok(content) => ToolResult {
provider_id: String::new(),
content: vec![ToolResultContent::Text(format!(
Ok(content) => {
let output = format!(
"# Loaded: {}/{}\n\n{}\n\n---\nFile loaded into context.",
skill_name, rel_path_owned, content
))],
is_error: false,
},
);
let output = if output.len() > MAX_SKILL_BODY_BYTES {
truncate_at_boundary(&output, MAX_SKILL_BODY_BYTES).to_owned()
} else {
output
};
ToolResult {
provider_id: String::new(),
content: vec![ToolResultContent::Text(output)],
is_error: false,
}
}
Err(e) => error_result(&format!(
"load_skill: could not read {skill_name:?}/{rel_path_owned}: {e}"
)),
Expand Down Expand Up @@ -527,4 +535,41 @@ mod tests {
MAX_SKILL_BODY_BYTES
);
}

#[tokio::test]
async fn call_load_skill_truncates_large_supporting_file() {
let tmp = TempDir::new().unwrap();
let skill_dir = tmp.path();
let skill_md = skill_dir.join("SKILL.md");
std::fs::write(&skill_md, "---\nname: big\ndescription: desc\n---\nBody.\n").unwrap();

let refs_dir = skill_dir.join("references");
std::fs::create_dir_all(&refs_dir).unwrap();
let ref_file = refs_dir.join("huge.md");
std::fs::write(&ref_file, "x".repeat(MAX_SKILL_BODY_BYTES * 2)).unwrap();

let skills = vec![make_skill_with_files(
"big",
"desc",
skill_md,
vec![ref_file],
)];
let result = call_load_skill(
&serde_json::json!({"name": "big/references/huge.md"}),
&skills,
)
.await;
assert!(!result.is_error);
let text = text_content(&result);
assert!(
text.len() <= MAX_SKILL_BODY_BYTES,
"output length {} exceeds MAX_SKILL_BODY_BYTES {}",
text.len(),
MAX_SKILL_BODY_BYTES
);
assert!(
text.starts_with("# Loaded: big/references/huge.md"),
"missing supporting-file header: {text}"
);
}
}
35 changes: 32 additions & 3 deletions crates/buzz-agent/src/hints.rs
Original file line number Diff line number Diff line change
Expand Up @@ -157,12 +157,24 @@ fn scan_skill_dir(dir: &Path, seen: &mut HashSet<String>, skills: &mut Vec<Skill
/// are treated as separate skills and are not descended into.
fn collect_supporting_files(skill_dir: &Path) -> Vec<PathBuf> {
let mut result = Vec::new();
collect_supporting_files_impl(skill_dir, &mut result);
let mut visited_dirs = HashSet::new();
collect_supporting_files_impl(skill_dir, &mut result, &mut visited_dirs);
result.sort();
result
}

fn collect_supporting_files_impl(current: &Path, out: &mut Vec<PathBuf>) {
fn collect_supporting_files_impl(
current: &Path,
out: &mut Vec<PathBuf>,
visited_dirs: &mut HashSet<PathBuf>,
) {
let Ok(canonical_current) = current.canonicalize() else {
return;
};
if !visited_dirs.insert(canonical_current) {
return;
}

let Ok(entries) = std::fs::read_dir(current) else {
return;
};
Expand All @@ -182,7 +194,7 @@ fn collect_supporting_files_impl(current: &Path, out: &mut Vec<PathBuf>) {
if path.join("SKILL.md").is_file() {
continue;
}
collect_supporting_files_impl(&path, out);
collect_supporting_files_impl(&path, out, visited_dirs);
} else if ft.is_file() && path.file_name().and_then(|n| n.to_str()) != Some("SKILL.md") {
out.push(path);
}
Expand Down Expand Up @@ -669,6 +681,23 @@ mod tests {
);
}

#[cfg(unix)]
#[test]
fn collect_supporting_files_skips_symlink_cycles() {
let tmp = TempDir::new().unwrap();
let skill_dir = tmp.path();
std::fs::write(skill_dir.join("SKILL.md"), "---\nname: x\n---\n").unwrap();

let refs = skill_dir.join("references");
std::fs::create_dir_all(&refs).unwrap();
let guide = refs.join("guide.md");
std::fs::write(&guide, "guide").unwrap();
std::os::unix::fs::symlink(&refs, refs.join("loop")).unwrap();

let files = collect_supporting_files(skill_dir);
assert_eq!(files, vec![guide]);
}

#[test]
fn discover_skills_populates_supporting_files() {
let tmp = TempDir::new().unwrap();
Expand Down
161 changes: 146 additions & 15 deletions desktop/src-tauri/src/managed_agents/personas.rs

Large diffs are not rendered by default.

25 changes: 22 additions & 3 deletions desktop/src-tauri/src/managed_agents/personas/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,34 @@ fn merge_personas_adds_missing_built_ins() {
assert!(changed);
assert_eq!(records.len(), BUILT_IN_PERSONAS.len());
assert!(records.iter().all(|record| record.is_builtin));
assert!(records.iter().all(|record| record.is_active));
assert!(records
.iter()
.any(|record| record.id == "builtin:fizz" && record.runtime.as_deref() == Some("goose")));
assert!(records
.iter()
.any(|record| record.id == "builtin:product-strategist" && !record.is_active));
let display_names: Vec<&str> = records
.iter()
.map(|record| record.display_name.as_str())
.collect();
assert_eq!(display_names, vec!["Fizz"]);
assert_eq!(
display_names,
vec![
"Fizz",
"Product Strategist",
"Implementation Partner",
"QA Reviewer",
"Work Coordinator",
"Support Guide",
"Experiment Designer"
]
);
let active_ids: Vec<&str> = records
.iter()
.filter(|record| record.is_active)
.map(|record| record.id.as_str())
.collect();
assert_eq!(active_ids, vec!["builtin:fizz"]);
}

#[test]
Expand Down Expand Up @@ -204,7 +223,7 @@ fn ensure_persona_is_active_rejects_inactive_personas() {

assert_eq!(
err,
"Fizz is not in My Agents. Choose it from Persona Catalog first."
"Fizz is not in My Agents. Choose it from Agent Catalog first."
);
}

Expand Down
26 changes: 24 additions & 2 deletions desktop/src/features/agents/lib/catalog.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import test from "node:test";
import {
getCatalogPersonas,
getCatalogSelectionState,
getLibraryPersonas,
getPersonaLabelsById,
getPersonaLibraryState,
isCatalogPersonaSelected,
Expand Down Expand Up @@ -80,7 +81,7 @@ test("getCatalogPersonas keeps chooser order stable when selection changes", ()
);
});

test("isCatalogPersonaSelected only treats active built-ins as selected", () => {
test("isCatalogPersonaSelected treats active catalog personas as selected", () => {
assert.equal(
isCatalogPersonaSelected(
createPersona("builtin:fizz", "Fizz", {
Expand All @@ -101,7 +102,7 @@ test("isCatalogPersonaSelected only treats active built-ins as selected", () =>
);
assert.equal(
isCatalogPersonaSelected(createPersona("custom:builder", "Builder")),
false,
true,
);
});

Expand Down Expand Up @@ -135,3 +136,24 @@ test("getPersonaLibraryState keeps the working library and full catalog in one p
);
assert.equal(state.personaLabelsById["builtin:fizz"], "Fizz");
});

test("getLibraryPersonas keeps active custom personas even when catalog entries are similar", () => {
const avatarUrl = "https://example.test/coordinator.png";
const personas = [
createPersona("builtin:work-coordinator", "Work Coordinator", {
avatarUrl,
isBuiltIn: true,
isActive: false,
}),
createPersona("custom:work-coordinator", "Work Coordinator", {
avatarUrl,
isActive: true,
}),
createPersona("custom:builder", "Builder", { isActive: true }),
];

assert.deepEqual(
getLibraryPersonas(personas).map((persona) => persona.id),
["custom:work-coordinator", "custom:builder"],
);
});
33 changes: 27 additions & 6 deletions desktop/src/features/agents/lib/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,20 +20,37 @@ export function getActivePersonas(personas: readonly AgentPersona[]) {
return personas.filter(isPersonaActive);
}

export function getCatalogPersonas(personas: readonly AgentPersona[]) {
export function getLibraryPersonas(personas: readonly AgentPersona[]) {
return getActivePersonas(personas);
}

export function isPersonaVisibleInCatalog(
persona: AgentPersona,
sharedCatalogPersonaIds: ReadonlySet<string> = new Set(),
) {
return persona.isBuiltIn || sharedCatalogPersonaIds.has(persona.id);
}

export function getCatalogPersonas(
personas: readonly AgentPersona[],
sharedCatalogPersonaIds: ReadonlySet<string> = new Set(),
) {
return personas
.filter((persona) => persona.isBuiltIn)
.filter((persona) =>
isPersonaVisibleInCatalog(persona, sharedCatalogPersonaIds),
)
.sort((left, right) => left.displayName.localeCompare(right.displayName));
}

export function isCatalogPersonaSelected(persona: AgentPersona) {
return persona.isBuiltIn && persona.isActive;
return persona.isActive;
}

export function getCatalogSelectionState(
personas: readonly AgentPersona[],
sharedCatalogPersonaIds: ReadonlySet<string> = new Set(),
): CatalogSelectionState {
const catalogPersonas = getCatalogPersonas(personas);
const catalogPersonas = getCatalogPersonas(personas, sharedCatalogPersonaIds);

return {
catalogPersonas,
Expand All @@ -52,9 +69,13 @@ export function getPersonaLabelsById(personas: readonly AgentPersona[]) {

export function getPersonaLibraryState(
personas: readonly AgentPersona[],
sharedCatalogPersonaIds: ReadonlySet<string> = new Set(),
): PersonaLibraryState {
const libraryPersonas = getActivePersonas(personas);
const { catalogPersonas } = getCatalogSelectionState(personas);
const libraryPersonas = getLibraryPersonas(personas);
const { catalogPersonas } = getCatalogSelectionState(
personas,
sharedCatalogPersonaIds,
);

return {
catalogPersonas,
Expand Down
37 changes: 37 additions & 0 deletions desktop/src/features/agents/ui/AgentsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { PersonaCatalogDialog } from "./PersonaCatalogDialog";
import { PersonaDialog } from "./PersonaDialog";
import { PersonaDeleteDialog } from "./PersonaDeleteDialog";
import { PersonaImportUpdateDialog } from "./PersonaImportUpdateDialog";
import { PersonaShareDialog } from "./PersonaShareDialog";
import { RelayDirectorySection } from "./RelayDirectorySection";
import { SecretRevealDialog } from "./SecretRevealDialog";
import { TeamDeleteDialog } from "./TeamDeleteDialog";
Expand Down Expand Up @@ -118,6 +119,13 @@ export function AgentsView() {
isPersonasPending={personas.isPending}
onCreatePersona={personas.openCreate}
onChooseCatalog={personas.openCatalog}
onDuplicatePersona={personas.openDuplicate}
onEditPersona={personas.openEdit}
onSharePersona={personas.openShare}
onDeactivatePersona={(persona) => {
void personas.handleSetActive(persona, false, "library");
}}
onDeletePersona={personas.openDelete}
onImportPersonaFile={(fileBytes, fileName) => {
void personas.handleImportFile(fileBytes, fileName);
}}
Expand Down Expand Up @@ -250,6 +258,35 @@ export function AgentsView() {
persona={personas.personaToDelete}
/>
) : null}
{personas.personaToShare ? (
<PersonaShareDialog
isCatalogVisible={
personas.personaToShare.isBuiltIn ||
personas.sharedCatalogPersonaIdSet.has(personas.personaToShare.id)
}
isPending={personas.isPending}
onCatalogVisibilityChange={(visible) => {
if (personas.personaToShare) {
personas.setPersonaCatalogVisibility(
personas.personaToShare,
visible,
);
}
}}
onExport={() => {
if (personas.personaToShare) {
personas.handleExport(personas.personaToShare);
}
}}
onOpenChange={(open) => {
if (!open) {
personas.setPersonaToShare(null);
}
}}
open={personas.personaToShare !== null}
persona={personas.personaToShare}
/>
) : null}
{personas.isCatalogDialogOpen ? (
<PersonaCatalogDialog
error={
Expand Down
Loading
Loading