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:
parent
7ea41b89ae
commit
2f0e5c8054
5 changed files with 242 additions and 14 deletions
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
});
|
||||
|
|
|
|||
112
src/resources/extensions/sf/tests/subagent-agent-yaml.test.mjs
Normal file
112
src/resources/extensions/sf/tests/subagent-agent-yaml.test.mjs
Normal 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/);
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue