Skip to content
Open
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
10 changes: 10 additions & 0 deletions src/agents/agent-management.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
type AgentSource,
type ChainConfig,
type ChainStepConfig,
DEFAULT_SKILL_INJECTION,
defaultInheritProjectContext,
defaultInheritSkills,
defaultSystemPromptMode,
Expand Down Expand Up @@ -286,6 +287,11 @@ function applyAgentConfig(target: AgentConfig, cfg: Record<string, unknown>): st
if (typeof cfg.inheritSkills !== "boolean") return "config.inheritSkills must be a boolean when provided.";
target.inheritSkills = cfg.inheritSkills;
}
if (hasKey(cfg, "skillInjection")) {
if (cfg.skillInjection === "full" || cfg.skillInjection === "light") {
target.skillInjection = cfg.skillInjection;
} else return "config.skillInjection must be 'full' or 'light' when provided.";
}
if (hasKey(cfg, "defaultContext")) {
if (cfg.defaultContext === false || cfg.defaultContext === "") target.defaultContext = undefined;
else if (cfg.defaultContext === "fresh" || cfg.defaultContext === "fork") target.defaultContext = cfg.defaultContext;
Expand Down Expand Up @@ -378,6 +384,9 @@ function formatAgentDetail(agent: AgentConfig): string {
lines.push(`System prompt mode: ${agent.systemPromptMode}`);
lines.push(`Inherit project context: ${agent.inheritProjectContext ? "true" : "false"}`);
lines.push(`Inherit skills: ${agent.inheritSkills ? "true" : "false"}`);
if (agent.skillInjection && agent.skillInjection !== "full") {
lines.push(`Skill injection: ${agent.skillInjection}`);
}
if (agent.defaultContext) lines.push(`Default context: ${agent.defaultContext}`);
if (agent.source === "builtin") lines.push(`Disabled: ${agent.disabled ? "true" : "false"}`);
if (agent.extensions !== undefined) lines.push(`Extensions: ${agent.extensions.length ? agent.extensions.join(", ") : "(none)"}`);
Expand Down Expand Up @@ -536,6 +545,7 @@ export function handleCreate(params: ManagementParams, ctx: ManagementContext):
systemPromptMode: defaultSystemPromptMode(name),
inheritProjectContext: defaultInheritProjectContext(name),
inheritSkills: defaultInheritSkills(),
skillInjection: DEFAULT_SKILL_INJECTION,
};
const applyError = applyAgentConfig(agent, cfg);
if (applyError) return result(applyError, true);
Expand Down
4 changes: 4 additions & 0 deletions src/agents/agent-serializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const KNOWN_FIELDS = new Set([
"systemPromptMode",
"inheritProjectContext",
"inheritSkills",
"skillInjection",
"defaultContext",
"skill",
"skills",
Expand Down Expand Up @@ -50,6 +51,9 @@ export function serializeAgent(config: AgentConfig): string {
lines.push(`systemPromptMode: ${config.systemPromptMode}`);
lines.push(`inheritProjectContext: ${config.inheritProjectContext ? "true" : "false"}`);
lines.push(`inheritSkills: ${config.inheritSkills ? "true" : "false"}`);
if (config.skillInjection && config.skillInjection !== "full") {
lines.push(`skillInjection: ${config.skillInjection}`);
}
if (config.defaultContext) lines.push(`defaultContext: ${config.defaultContext}`);

const skillsValue = joinComma(config.skills);
Expand Down
26 changes: 25 additions & 1 deletion src/agents/agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ export type AgentScope = "user" | "project" | "both";
export type AgentSource = "builtin" | "user" | "project";
type SystemPromptMode = "append" | "replace";
export type AgentDefaultContext = "fresh" | "fork";
export type SkillInjectionMode = "full" | "light";

export const DEFAULT_SKILL_INJECTION: SkillInjectionMode = "full";

export function defaultSystemPromptMode(name: string): SystemPromptMode {
return name === "delegate" ? "append" : "replace";
Expand All @@ -40,6 +43,7 @@ export interface BuiltinAgentOverrideBase {
systemPromptMode: SystemPromptMode;
inheritProjectContext: boolean;
inheritSkills: boolean;
skillInjection: SkillInjectionMode;
defaultContext?: AgentDefaultContext;
disabled?: boolean;
systemPrompt: string;
Expand All @@ -56,6 +60,7 @@ interface BuiltinAgentOverrideConfig {
systemPromptMode?: SystemPromptMode;
inheritProjectContext?: boolean;
inheritSkills?: boolean;
skillInjection?: SkillInjectionMode;
defaultContext?: AgentDefaultContext | false;
disabled?: boolean;
systemPrompt?: string;
Expand Down Expand Up @@ -83,6 +88,7 @@ export interface AgentConfig {
systemPromptMode: SystemPromptMode;
inheritProjectContext: boolean;
inheritSkills: boolean;
skillInjection: SkillInjectionMode;
defaultContext?: AgentDefaultContext;
systemPrompt: string;
source: AgentSource;
Expand Down Expand Up @@ -197,6 +203,7 @@ function cloneOverrideBase(agent: AgentConfig): BuiltinAgentOverrideBase {
systemPromptMode: agent.systemPromptMode,
inheritProjectContext: agent.inheritProjectContext,
inheritSkills: agent.inheritSkills,
skillInjection: agent.skillInjection,
defaultContext: agent.defaultContext,
disabled: agent.disabled,
systemPrompt: agent.systemPrompt,
Expand All @@ -217,6 +224,7 @@ function cloneOverrideValue(override: BuiltinAgentOverrideConfig): BuiltinAgentO
...(override.systemPromptMode !== undefined ? { systemPromptMode: override.systemPromptMode } : {}),
...(override.inheritProjectContext !== undefined ? { inheritProjectContext: override.inheritProjectContext } : {}),
...(override.inheritSkills !== undefined ? { inheritSkills: override.inheritSkills } : {}),
...(override.skillInjection !== undefined ? { skillInjection: override.skillInjection } : {}),
...(override.defaultContext !== undefined ? { defaultContext: override.defaultContext } : {}),
...(override.disabled !== undefined ? { disabled: override.disabled } : {}),
...(override.systemPrompt !== undefined ? { systemPrompt: override.systemPrompt } : {}),
Expand Down Expand Up @@ -343,6 +351,14 @@ function parseBuiltinOverrideEntry(
}
}

if ("skillInjection" in input) {
if (input.skillInjection === "full" || input.skillInjection === "light") {
override.skillInjection = input.skillInjection;
} else {
throw new Error(`Builtin override '${name}' in '${filePath}' has invalid 'skillInjection'; expected 'full' or 'light'.`);
}
}

if ("defaultContext" in input) {
if (input.defaultContext === "fresh" || input.defaultContext === "fork" || input.defaultContext === false) {
override.defaultContext = input.defaultContext;
Expand Down Expand Up @@ -430,6 +446,7 @@ function applyBuiltinOverride(
if (override.systemPromptMode !== undefined) next.systemPromptMode = override.systemPromptMode;
if (override.inheritProjectContext !== undefined) next.inheritProjectContext = override.inheritProjectContext;
if (override.inheritSkills !== undefined) next.inheritSkills = override.inheritSkills;
if (override.skillInjection !== undefined) next.skillInjection = override.skillInjection;
if (override.defaultContext !== undefined) next.defaultContext = override.defaultContext === false ? undefined : override.defaultContext;
if (override.disabled !== undefined) next.disabled = override.disabled;
if (override.systemPrompt !== undefined) next.systemPrompt = override.systemPrompt;
Expand Down Expand Up @@ -479,7 +496,7 @@ function applyBuiltinOverrides(

export function buildBuiltinOverrideConfig(
base: BuiltinAgentOverrideBase,
draft: Pick<AgentConfig, "model" | "fallbackModels" | "thinking" | "systemPromptMode" | "inheritProjectContext" | "inheritSkills" | "defaultContext" | "disabled" | "systemPrompt" | "skills" | "tools" | "mcpDirectTools" | "completionGuard">,
draft: Pick<AgentConfig, "model" | "fallbackModels" | "thinking" | "systemPromptMode" | "inheritProjectContext" | "inheritSkills" | "skillInjection" | "defaultContext" | "disabled" | "systemPrompt" | "skills" | "tools" | "mcpDirectTools" | "completionGuard">,
): BuiltinAgentOverrideConfig | undefined {
const override: BuiltinAgentOverrideConfig = {};

Expand All @@ -489,6 +506,7 @@ export function buildBuiltinOverrideConfig(
if (draft.systemPromptMode !== base.systemPromptMode) override.systemPromptMode = draft.systemPromptMode;
if (draft.inheritProjectContext !== base.inheritProjectContext) override.inheritProjectContext = draft.inheritProjectContext;
if (draft.inheritSkills !== base.inheritSkills) override.inheritSkills = draft.inheritSkills;
if (draft.skillInjection !== base.skillInjection) override.skillInjection = draft.skillInjection;
if (draft.defaultContext !== base.defaultContext) override.defaultContext = draft.defaultContext ?? false;
if (draft.disabled !== base.disabled) override.disabled = draft.disabled ?? false;
if (draft.systemPrompt !== base.systemPrompt) override.systemPrompt = draft.systemPrompt;
Expand Down Expand Up @@ -645,6 +663,11 @@ function loadAgentsFromDir(dir: string, source: AgentSource): AgentConfig[] {
: frontmatter.inheritSkills === "false"
? false
: defaultInheritSkills();
const skillInjection: SkillInjectionMode = frontmatter.skillInjection === "light"
? "light"
: frontmatter.skillInjection === "full"
? "full"
: DEFAULT_SKILL_INJECTION;
const defaultContext = frontmatter.defaultContext === "fork"
? "fork" as const
: frontmatter.defaultContext === "fresh"
Expand Down Expand Up @@ -684,6 +707,7 @@ function loadAgentsFromDir(dir: string, source: AgentSource): AgentConfig[] {
systemPromptMode,
inheritProjectContext,
inheritSkills,
skillInjection,
defaultContext,
systemPrompt: body,
source,
Expand Down
89 changes: 86 additions & 3 deletions src/agents/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ interface ResolvedSkill {
name: string;
path: string;
content: string;
description?: string;
source: SkillSource;
}

Expand Down Expand Up @@ -410,6 +411,39 @@ function collectFilesystemSkills(cwd: string, agentDir: string, skillPaths: Skil
});
};

// Recurse into a directory looking for SKILL.md. Mirrors pi-coding-agent's
// loadSkillsFromDirInternal: if the directory contains SKILL.md, treat the
// directory as a skill root and stop (do not recurse further). Otherwise
// recurse into subdirectories. node_modules and dotfiles are skipped.
const recurseForSkillFile = (dir: string, source: SkillSource | undefined) => {
const skillFile = path.join(dir, "SKILL.md");
if (fs.existsSync(skillFile)) {
pushEntry(path.basename(dir), skillFile, source);
return;
}
let children: fs.Dirent[];
try {
children = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const child of children) {
if (child.name.startsWith(".")) continue;
if (child.name === "node_modules") continue;
if (!(child.isDirectory() || child.isSymbolicLink())) continue;
const childPath = path.join(dir, child.name);
if (child.isSymbolicLink()) {
try {
const stats = fs.statSync(childPath);
if (!stats.isDirectory()) continue;
} catch {
continue;
}
}
recurseForSkillFile(childPath, source);
}
};

for (const skillPath of skillPaths) {
if (!fs.existsSync(skillPath.path)) continue;

Expand Down Expand Up @@ -446,12 +480,21 @@ function collectFilesystemSkills(cwd: string, agentDir: string, skillPaths: Skil

for (const child of childEntries) {
if (child.name.startsWith(".")) continue;
if (child.name === "node_modules") continue;
const childPath = path.join(skillPath.path, child.name);
if (child.isDirectory() || child.isSymbolicLink()) {
const nestedSkillPath = path.join(childPath, "SKILL.md");
if (fs.existsSync(nestedSkillPath)) {
pushEntry(child.name, nestedSkillPath, skillPath.source);
if (child.isSymbolicLink()) {
try {
const stats = fs.statSync(childPath);
if (!stats.isDirectory()) continue;
} catch {
continue;
}
}
// Recursive descent: mirrors pi-coding-agent so layouts like
// `<root>/<group>/<skill-name>/SKILL.md` are discovered, not just
// `<root>/<skill-name>/SKILL.md`.
recurseForSkillFile(childPath, skillPath.source);
continue;
}
if (child.isFile() && child.name.toLowerCase().endsWith(".md")) {
Expand Down Expand Up @@ -508,10 +551,12 @@ function readSkill(

const raw = fs.readFileSync(skillPath, "utf-8");
const content = stripSkillFrontmatter(raw);
const description = maybeReadSkillDescription(skillPath);
const skill: ResolvedSkill = {
name: skillName,
path: skillPath,
content,
description,
source,
};

Expand Down Expand Up @@ -584,6 +629,44 @@ export function buildSkillInjection(skills: ResolvedSkill[]): string {
.join("\n\n");
}

/**
* Light-weight injection: emit only the skill name, description, and absolute
* path. The agent uses the read tool to load the full SKILL.md when the task
* matches. Intended for agents that declare many skills in `skills:` but want
* to keep their startup system prompt small. Mirrors the shape produced by
* `pi-coding-agent`'s `formatSkillsForPrompt`, so a model that knows how to
* use one knows how to use the other.
*/
export function buildLightSkillInjection(skills: ResolvedSkill[]): string {
if (skills.length === 0) return "";

const lines = [
"The following skills are available for this agent. Use the read tool to load a skill's file when the task matches its description.",
"",
"<available_skills>",
];
for (const skill of skills) {
lines.push(" <skill>");
lines.push(` <name>${escapeXmlAttr(skill.name)}</name>`);
if (skill.description) {
lines.push(` <description>${escapeXmlAttr(skill.description)}</description>`);
}
lines.push(` <location>${escapeXmlAttr(skill.path)}</location>`);
lines.push(" </skill>");
}
lines.push("</available_skills>");
return lines.join("\n");
}

function escapeXmlAttr(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}

export function normalizeSkillInput(
input: string | string[] | boolean | undefined,
): string[] | false | undefined {
Expand Down
2 changes: 1 addition & 1 deletion src/extension/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -406,7 +406,7 @@ Example: { chain: [{agent:"agent-a", task:"Analyze {task}"}, {agent:"agent-b", t
MANAGEMENT (use action field, omit agent/task/chain/tasks):
• { action: "list" } - discover executable agents/chains
• { action: "get", agent: "name" } - full detail; packaged agents use dotted runtime names like "package.agent"
• { action: "create", config: { name: "custom-agent", package: "code-analysis", systemPrompt, systemPromptMode, inheritProjectContext, inheritSkills, defaultContext, ... } }
• { action: "create", config: { name: "custom-agent", package: "code-analysis", systemPrompt, systemPromptMode, inheritProjectContext, inheritSkills, skillInjection, defaultContext, ... } }
• { action: "update", agent: "code-analysis.custom-agent", config: { package: "analysis", ... } } - merge
• { action: "delete", agent: "code-analysis.custom-agent" }
• Use chainName for chain operations; packaged chains also use dotted runtime names
Expand Down
2 changes: 1 addition & 1 deletion src/extension/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@ export const SubagentParams = Type.Object({
{ type: "object", additionalProperties: true },
{ type: "string" },
],
description: "Agent or chain config for create/update. Agent: name, package (optional namespace; runtime name becomes package.name), description, scope ('user'|'project', default 'user'), systemPrompt, systemPromptMode, inheritProjectContext, inheritSkills, defaultContext ('fresh'|'fork'), model, tools (comma-separated), extensions (comma-separated), skills (comma-separated), thinking, output, reads, progress, maxSubagentDepth. Chain: name, package, description, scope, steps (array of {agent, task?, output?, outputMode?, reads?, model?, skill?, progress?}). Presence of 'steps' creates a chain instead of an agent. String values must be valid JSON."
description: "Agent or chain config for create/update. Agent: name, package (optional namespace; runtime name becomes package.name), description, scope ('user'|'project', default 'user'), systemPrompt, systemPromptMode, inheritProjectContext, inheritSkills, skillInjection ('full'|'light', default 'full'; 'light' injects skill name+description+location instead of full SKILL.md content), defaultContext ('fresh'|'fork'), model, tools (comma-separated), extensions (comma-separated), skills (comma-separated), thinking, output, reads, progress, maxSubagentDepth. Chain: name, package, description, scope, steps (array of {agent, task?, output?, outputMode?, reads?, model?, skill?, progress?}). Presence of 'steps' creates a chain instead of an agent. String values must be valid JSON."
})),
tasks: Type.Optional(Type.Array(TaskItem, { description: "PARALLEL mode: [{agent, task, count?, output?, outputMode?, reads?, progress?}, ...]" })),
concurrency: Type.Optional(Type.Integer({ minimum: 1, description: "Top-level PARALLEL mode only: max concurrent tasks. Defaults to config.parallel.concurrency or 4." })),
Expand Down
10 changes: 7 additions & 3 deletions src/runs/background/async-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { injectSingleOutputInstruction, normalizeSingleOutputOverride, resolveSi
import { buildChainInstructions, isDynamicParallelStep, isParallelStep, resolveStepBehavior, suppressProgressForReadOnlyTask, writeInitialProgressFile, type ChainStep, type ResolvedStepBehavior, type SequentialStep, type StepOverrides } from "../../shared/settings.ts";
import type { RunnerStep } from "../shared/parallel-utils.ts";
import { resolvePiPackageRoot } from "../shared/pi-spawn.ts";
import { buildSkillInjection, normalizeSkillInput, resolveSkillsWithFallback } from "../../agents/skills.ts";
import { buildSkillInjection, buildLightSkillInjection, normalizeSkillInput, resolveSkillsWithFallback } from "../../agents/skills.ts";
import { resolveChildCwd } from "../../shared/utils.ts";
import { buildModelCandidates, resolveModelCandidate, type AvailableModelInfo } from "../shared/model-fallback.ts";
import { resolveEffectiveThinking } from "../../shared/model-info.ts";
Expand Down Expand Up @@ -326,7 +326,9 @@ export function executeAsyncChain(

let systemPrompt = a.systemPrompt?.trim() ?? "";
if (resolvedSkills.length > 0) {
const injection = buildSkillInjection(resolvedSkills);
const injection = a.skillInjection === "light"
? buildLightSkillInjection(resolvedSkills)
: buildSkillInjection(resolvedSkills);
systemPrompt = systemPrompt ? `${systemPrompt}\n\n${injection}` : injection;
}

Expand Down Expand Up @@ -639,7 +641,9 @@ export function executeAsyncSingle(
if (missingSkills.includes("pi-subagents")) return formatAsyncStartError("single", UNAVAILABLE_SUBAGENT_SKILL_ERROR);
let systemPrompt = agentConfig.systemPrompt?.trim() ?? "";
if (resolvedSkills.length > 0) {
const injection = buildSkillInjection(resolvedSkills);
const injection = agentConfig.skillInjection === "light"
? buildLightSkillInjection(resolvedSkills)
: buildSkillInjection(resolvedSkills);
systemPrompt = systemPrompt ? `${systemPrompt}\n\n${injection}` : injection;
}

Expand Down
6 changes: 4 additions & 2 deletions src/runs/foreground/execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ import {
extractToolArgsPreview,
extractTextFromContent,
} from "../../shared/utils.ts";
import { buildSkillInjection, resolveSkillsWithFallback } from "../../agents/skills.ts";
import { buildSkillInjection, buildLightSkillInjection, resolveSkillsWithFallback } from "../../agents/skills.ts";
import { evaluateCompletionMutationGuard } from "../shared/completion-guard.ts";
import { getPiSpawnCommand } from "../shared/pi-spawn.ts";
import { createJsonlWriter } from "../../shared/jsonl-writer.ts";
Expand Down Expand Up @@ -831,7 +831,9 @@ export async function runSync(
}
let systemPrompt = agent.systemPrompt?.trim() || "";
if (resolvedSkills.length > 0) {
const skillInjection = buildSkillInjection(resolvedSkills);
const skillInjection = agent.skillInjection === "light"
? buildLightSkillInjection(resolvedSkills)
: buildSkillInjection(resolvedSkills);
systemPrompt = systemPrompt ? `${systemPrompt}\n\n${skillInjection}` : skillInjection;
}

Expand Down
Loading