diff --git a/src/resources/extensions/sf/auto/phases-unit.js b/src/resources/extensions/sf/auto/phases-unit.js index 4999f8bcf..e7818a0c1 100644 --- a/src/resources/extensions/sf/auto/phases-unit.js +++ b/src/resources/extensions/sf/auto/phases-unit.js @@ -986,7 +986,7 @@ export async function runUnitPhase(ic, iterData, loopState, sidecarItem) { unitType, unitId, solverPrompt, - { keepSession: false }, + { keepSession: false, activeToolsAllowlist: ["checkpoint"] }, ); solverPassResult = result; if (result.status !== "cancelled") { diff --git a/src/resources/extensions/sf/auto/run-unit.js b/src/resources/extensions/sf/auto/run-unit.js index 87539dd40..6aca7f81c 100644 --- a/src/resources/extensions/sf/auto/run-unit.js +++ b/src/resources/extensions/sf/auto/run-unit.js @@ -51,6 +51,37 @@ export function buildUnitPromptMessageContent(prompt, promptParts) { { type: "text", text: promptParts.after }, ]; } + +function sameStringList(a, b) { + if (a.length !== b.length) return false; + return a.every((value, index) => value === b[index]); +} + +/** + * Resolve the active tool list for one autonomous unit dispatch. + * + * Purpose: keep phase-specific agents inside their authority boundary. Normal + * units use the unit-type SF allowlists; protocol-only passes can supply an + * explicit allowlist so they cannot edit files, run shell commands, or dispatch + * subagents. + * + * Consumer: runUnit before pi.sendMessage. + */ +export function scopeActiveToolsForRunUnit( + unitType, + currentTools, + options = {}, +) { + const explicitAllowlist = Array.isArray(options.activeToolsAllowlist) + ? options.activeToolsAllowlist + : null; + if (explicitAllowlist) { + const allowed = new Set(explicitAllowlist); + return currentTools.filter((toolName) => allowed.has(toolName)); + } + return scopeActiveToolsForUnitType(unitType, currentTools); +} + /** * Execute a single unit: create a new session, send the prompt, and await * the agent_end promise. Returns a UnitResult describing what happened. @@ -266,8 +297,10 @@ export async function runUnit(ctx, pi, s, unitType, unitId, prompt, options) { typeof pi.setActiveTools === "function" ) { const currentTools = pi.getActiveTools(); - const scopedTools = scopeActiveToolsForUnitType(unitType, currentTools); - if (scopedTools.length !== currentTools.length) { + const scopedTools = scopeActiveToolsForRunUnit(unitType, currentTools, { + activeToolsAllowlist: options?.activeToolsAllowlist, + }); + if (!sameStringList(scopedTools, currentTools)) { savedTools = currentTools; pi.setActiveTools(scopedTools); debugLog("unit-tool-scoping", { @@ -275,6 +308,7 @@ export async function runUnit(ctx, pi, s, unitType, unitId, prompt, options) { before: currentTools.length, after: scopedTools.length, removed: currentTools.length - scopedTools.length, + explicitAllowlist: Array.isArray(options?.activeToolsAllowlist), }); } } diff --git a/src/resources/extensions/sf/subagent/agents.js b/src/resources/extensions/sf/subagent/agents.js index 6321ec3d6..b185f5ec7 100644 --- a/src/resources/extensions/sf/subagent/agents.js +++ b/src/resources/extensions/sf/subagent/agents.js @@ -4,6 +4,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { getAgentDir, parseFrontmatter } from "@singularity-forge/coding-agent"; +import { parse as parseYaml } from "yaml"; const PROJECT_AGENT_DIR_CANDIDATES = [".sf", ".pi"]; export function parseConflictsWith(value) { @@ -20,6 +21,7 @@ function parseAgentTools(value) { .split(",") .map((tool) => tool.trim()) .filter(Boolean); + if (tools.includes("*")) return undefined; return tools.length > 0 ? tools : undefined; } if (Array.isArray(value)) { @@ -27,10 +29,47 @@ function parseAgentTools(value) { .flatMap((tool) => (typeof tool === "string" ? tool.split(",") : [])) .map((tool) => tool.trim()) .filter(Boolean); + if (tools.includes("*")) return undefined; return tools.length > 0 ? tools : undefined; } return undefined; } +function parseAgentModel(value) { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} +function isAgentFileName(name) { + return ( + name.endsWith(".md") || + name.endsWith(".agent.yaml") || + name.endsWith(".agent.yml") + ); +} +function parseMarkdownAgent(content) { + const { frontmatter, body } = parseFrontmatter(content); + return { + name: frontmatter.name, + displayName: frontmatter.displayName, + description: frontmatter.description, + tools: frontmatter.tools, + model: frontmatter.model, + conflictsWith: frontmatter.conflicts_with, + systemPrompt: body, + }; +} +function parseYamlAgent(content) { + const doc = parseYaml(content) ?? {}; + return { + name: doc.name, + displayName: doc.displayName, + description: doc.description, + tools: doc.tools, + model: doc.model, + conflictsWith: doc.conflicts_with ?? doc.conflictsWith, + systemPrompt: doc.prompt, + promptParts: doc.promptParts, + sidekick: doc.sidekick, + }; +} function loadAgentsFromDir(dir, source) { const agents = []; if (!fs.existsSync(dir)) { @@ -43,7 +82,7 @@ function loadAgentsFromDir(dir, source) { return agents; } for (const entry of entries) { - if (!entry.name.endsWith(".md")) continue; + if (!isAgentFileName(entry.name)) continue; if (!entry.isFile() && !entry.isSymbolicLink()) continue; const filePath = path.join(dir, entry.name); let content; @@ -52,22 +91,33 @@ function loadAgentsFromDir(dir, source) { } catch { continue; } - const { frontmatter, body } = parseFrontmatter(content); + let definition; + try { + definition = entry.name.endsWith(".md") + ? parseMarkdownAgent(content) + : parseYamlAgent(content); + } catch { + continue; + } if ( - typeof frontmatter.name !== "string" || - typeof frontmatter.description !== "string" + typeof definition.name !== "string" || + typeof definition.description !== "string" || + typeof definition.systemPrompt !== "string" ) { continue; } - const tools = parseAgentTools(frontmatter.tools); - const conflictsWith = parseConflictsWith(frontmatter.conflicts_with); + const tools = parseAgentTools(definition.tools); + const conflictsWith = parseConflictsWith(definition.conflictsWith); agents.push({ - name: frontmatter.name, - description: frontmatter.description, + name: definition.name, + displayName: definition.displayName, + description: definition.description, tools: tools && tools.length > 0 ? tools : undefined, - model: frontmatter.model, + model: parseAgentModel(definition.model), conflictsWith, - systemPrompt: body, + promptParts: definition.promptParts, + sidekick: definition.sidekick, + systemPrompt: definition.systemPrompt, source, filePath, }); diff --git a/src/resources/extensions/sf/tests/run-unit.test.mjs b/src/resources/extensions/sf/tests/run-unit.test.mjs index 5d95e296c..92675b13b 100644 --- a/src/resources/extensions/sf/tests/run-unit.test.mjs +++ b/src/resources/extensions/sf/tests/run-unit.test.mjs @@ -1,7 +1,10 @@ import assert from "node:assert/strict"; import { test } from "vitest"; -import { buildUnitPromptMessageContent } from "../auto/run-unit.js"; +import { + buildUnitPromptMessageContent, + scopeActiveToolsForRunUnit, +} from "../auto/run-unit.js"; test("buildUnitPromptMessageContent_when_prompt_parts_present_preserves_join_boundary", () => { const content = buildUnitPromptMessageContent("flat", { @@ -28,3 +31,32 @@ test("buildUnitPromptMessageContent_when_prompt_parts_present_preserves_join_bou test("buildUnitPromptMessageContent_when_no_prompt_parts_returns_flat_prompt", () => { assert.equal(buildUnitPromptMessageContent("flat", null), "flat"); }); + +test("scopeActiveToolsForRunUnit_when_explicit_allowlist_set_returns_only_allowed_tools", () => { + const scoped = scopeActiveToolsForRunUnit( + "execute-task", + [ + "read", + "bash", + "edit", + "checkpoint", + "subagent", + "await_subagent", + "complete_task", + ], + { activeToolsAllowlist: ["checkpoint"] }, + ); + + assert.deepEqual(scoped, ["checkpoint"]); +}); + +test("scopeActiveToolsForRunUnit_when_no_explicit_allowlist_keeps_existing_unit_scoping", () => { + const scoped = scopeActiveToolsForRunUnit("research-slice", [ + "read", + "sf_plan_task", + "save_summary", + "report_issue", + ]); + + assert.deepEqual(scoped, ["read", "save_summary", "report_issue"]); +}); diff --git a/src/resources/extensions/sf/tests/subagent-agent-yaml.test.mjs b/src/resources/extensions/sf/tests/subagent-agent-yaml.test.mjs new file mode 100644 index 000000000..cb7953bb9 --- /dev/null +++ b/src/resources/extensions/sf/tests/subagent-agent-yaml.test.mjs @@ -0,0 +1,112 @@ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "vitest"; + +import { discoverAgents } from "../subagent/agents.js"; + +function makeProject() { + const project = mkdtempSync(join(tmpdir(), "sf-agent-yaml-")); + mkdirSync(join(project, ".sf", "agents"), { recursive: true }); + return project; +} + +test("discoverAgents_when_project_has_copilot_style_agent_yaml_loads_agent", () => { + const project = makeProject(); + writeFileSync( + join(project, ".sf", "agents", "explore.agent.yaml"), + [ + "name: explore", + "displayName: Explore Agent", + "description: >", + " Fast codebase exploration in a separate context window.", + "model: claude-haiku-4.5", + "tools:", + " - grep", + " - glob", + " - view", + "promptParts:", + " includeAISafety: true", + " includeEnvironmentContext: false", + "prompt: |", + " You are an exploration agent.", + " Answer quickly and cite exact files.", + "", + ].join("\n"), + ); + + const { agents, projectAgentsDir } = discoverAgents(project, "project"); + const agent = agents.find((entry) => entry.name === "explore"); + + assert.equal(projectAgentsDir, join(project, ".sf", "agents")); + assert.ok(agent); + assert.equal(agent.displayName, "Explore Agent"); + assert.equal( + agent.description, + "Fast codebase exploration in a separate context window.\n", + ); + assert.equal(agent.model, "claude-haiku-4.5"); + assert.deepEqual(agent.tools, ["grep", "glob", "view"]); + assert.deepEqual(agent.promptParts, { + includeAISafety: true, + includeEnvironmentContext: false, + }); + assert.match(agent.systemPrompt, /You are an exploration agent/); +}); + +test("discoverAgents_when_yaml_tools_wildcard_set_uses_default_full_tool_surface", () => { + const project = makeProject(); + writeFileSync( + join(project, ".sf", "agents", "task.agent.yaml"), + [ + "name: task", + "description: Execute commands and report results.", + "tools:", + ' - "*"', + "prompt: |", + " Execute the requested command once.", + "", + ].join("\n"), + ); + + const { agents } = discoverAgents(project, "project"); + const agent = agents.find((entry) => entry.name === "task"); + + assert.ok(agent); + assert.equal(agent.tools, undefined); + assert.match(agent.systemPrompt, /Execute the requested command once/); +}); + +test("discoverAgents_when_yaml_and_markdown_share_name_prefers_later_project_entry", () => { + const project = makeProject(); + writeFileSync( + join(project, ".sf", "agents", "agent-a.md"), + [ + "---", + "name: duplicate", + "description: Markdown definition", + "---", + "", + "Markdown body.", + "", + ].join("\n"), + ); + writeFileSync( + join(project, ".sf", "agents", "agent-z.agent.yaml"), + [ + "name: duplicate", + "description: YAML definition", + "prompt: |", + " YAML body.", + "", + ].join("\n"), + ); + + const { agents } = discoverAgents(project, "project"); + const duplicates = agents.filter((entry) => entry.name === "duplicate"); + + assert.equal(duplicates.length, 1); + assert.equal(duplicates[0].description, "YAML definition"); + assert.match(duplicates[0].systemPrompt, /YAML body/); +});