feat(subagent,run-unit): YAML agent loader + solver-pass tool scoping

Two coupled product changes from the working tree, validated together:

1. Agent YAML loader (subagent/agents.js + subagent-agent-yaml.test.mjs)
   .sf/agents/*.agent.yaml files now load as first-class agent
   definitions alongside the existing .agent.md frontmatter format.
   Adds `*` wildcard support for the tools field (unrestricted) and a
   parseAgentModel helper for the YAML-only model selector. Mirrors
   the copilot-style YAML format so SF can consume agent definitions
   shared across tools without forcing the markdown wrapping.

2. Solver-pass tool scoping (run-unit.js + phases-unit.js +
   run-unit.test.mjs)
   New scopeActiveToolsForRunUnit honors an explicit
   activeToolsAllowlist so callers can restrict a unit dispatch to a
   tighter tool set than the unit-type's default SF allowlist. The
   autonomous solver pass uses this to constrain the solver to just
   `checkpoint` — solver should reason and persist checkpoints, not
   edit files or dispatch tools. Keeps the solver inside its
   authority boundary.

Tests: 7/7 in the two affected files; full SF suite stays green.

Not in this commit: the sidekick-trigger event emission in
autonomous-solver.js and the external scripts/sidekick-runner.js +
.agents/policies/proactive-sidekick.yaml — that's an experiment
that stays in the working tree pending operator direction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mikael Hugo 2026-05-14 09:40:13 +02:00
parent 7ea41b89ae
commit 2f0e5c8054
5 changed files with 242 additions and 14 deletions

View file

@ -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") {

View file

@ -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),
});
}
}

View file

@ -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,
});

View file

@ -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"]);
});

View file

@ -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/);
});