feat(coding-agent): added subagent skill preloads

- Inline preloaded skill contents in system prompts and allow task-level skill selection.
- Record session init entries with system prompt, task, tools, and output schema.
- Fix session persistence to keep pre-assistant entries in subagent logs.
This commit is contained in:
can1357
2026-01-27 05:17:17 +01:00
parent 0f4829376f
commit 34f03bc7fb
13 changed files with 171 additions and 17 deletions
+5
View File
@@ -2,8 +2,13 @@
## [Unreleased]
### Added
- Added subagent support for preloading skill contents into the system prompt instead of listing available skills
- Added session init entries to capture system prompt, task, tools, and output schema for subagent session logs
### Fixed
- Reduced Task tool progress update overhead to keep the UI responsive during high-volume streaming output
- Fixed subagent session logs dropping pre-assistant entries (user/task metadata) before the first assistant response
### Removed
- Removed enter-plan-mode tool
@@ -43,6 +43,20 @@ Use the read tool to load a skill's file when the task matches its description.
{{/list}}
</available_skills>
{{/if}}
{{#if preloadedSkills.length}}
The following skills are preloaded in full. Apply their instructions directly.
<preloaded_skills>
{{#list preloadedSkills join="\n"}}
<skill name="{{name}}">
<location>skill://{{escapeXml name}}</location>
<content>
{{content}}
</content>
</skill>
{{/list}}
</preloaded_skills>
{{/if}}
{{#if rules.length}}
The following rules define project-specific guidelines and constraints:
@@ -264,6 +264,18 @@ If a skill covers what you're producing, read it before proceeding.
{{/list}}
</skills>
{{/if}}
{{#if preloadedSkills.length}}
<preloaded_skills>
The following skills are preloaded in full. Apply their instructions directly.
{{#list preloadedSkills join="\n"}}
<skill name="{{name}}">
<location>skill://{{escapeXml name}}</location>
{{content}}
</skill>
{{/list}}
</preloaded_skills>
{{/if}}
{{#if rules.length}}
<rules>
Rules are local constraints.
@@ -38,6 +38,7 @@ Agents with `output="structured"` have a fixed schema enforced via frontmatter;
- `id`: Short CamelCase identifier (max 32 chars, e.g., "SessionStore", "LspRefactor")
- `description`: Short human-readable description of what the task does
- `args`: Object with keys matching `\{{placeholders}}` in context (always include this, even if empty)
- `skills`: (optional) Array of skill names to preload into this task's system prompt. When set, the skills index section is omitted and the full SKILL.md contents are embedded.
- `output`: (optional) JTD schema for structured subagent output (used by the complete tool)
</parameters>
+4
View File
@@ -156,6 +156,8 @@ export interface CreateAgentSessionOptions {
/** Skills. Default: discovered from multiple locations */
skills?: Skill[];
/** Skills to inline into the system prompt instead of listing available skills. */
preloadedSkills?: Skill[];
/** Context files (AGENTS.md content). Default: discovered walking up from cwd */
contextFiles?: Array<{ path: string; content: string }>;
/** Prompt templates. Default: discovered from cwd/.omp/prompts/ + agentDir/prompts/ */
@@ -993,6 +995,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
const defaultPrompt = await buildSystemPromptInternal({
cwd,
skills,
preloadedSkills: options.preloadedSkills,
contextFiles,
tools,
toolNames,
@@ -1007,6 +1010,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
return await buildSystemPromptInternal({
cwd,
skills,
preloadedSkills: options.preloadedSkills,
contextFiles,
tools,
toolNames,
@@ -110,6 +110,19 @@ export interface TtsrInjectionEntry extends SessionEntryBase {
injectedRules: string[];
}
/** Session init entry - captures initial context for subagent sessions (debugging/replay). */
export interface SessionInitEntry extends SessionEntryBase {
type: "session_init";
/** Full system prompt sent to the model */
systemPrompt: string;
/** Initial task/user message */
task: string;
/** Tools available to the agent */
tools: string[];
/** Output schema if structured output was requested */
outputSchema?: unknown;
}
/**
* Custom message entry for extensions to inject messages into LLM context.
* Use customType to identify your extension's entries.
@@ -140,7 +153,8 @@ export type SessionEntry =
| CustomEntry
| CustomMessageEntry
| LabelEntry
| TtsrInjectionEntry;
| TtsrInjectionEntry
| SessionInitEntry;
/** Raw file entry (includes header) */
export type FileEntry = SessionHeader | SessionEntry;
@@ -1263,7 +1277,7 @@ export class SessionManager {
if (this.persistError) throw this.persistError;
const hasAssistant = this.fileEntries.some(e => e.type === "message" && e.message.role === "assistant");
if (!hasAssistant) return;
if (!hasAssistant && !this.flushed) return;
if (!this.flushed) {
this.flushed = true;
@@ -1368,6 +1382,19 @@ export class SessionManager {
return entry.id;
}
/** Append session init metadata (for subagent debugging/replay). Returns entry id. */
appendSessionInit(init: { systemPrompt: string; task: string; tools: string[]; outputSchema?: unknown }): string {
const entry: SessionInitEntry = {
type: "session_init",
id: generateId(this.byId),
parentId: this.leafId,
timestamp: new Date().toISOString(),
...init,
};
this._appendEntry(entry);
return entry.id;
}
/** Append a compaction summary as child of current leaf, then advance leaf. Returns entry id. */
appendCompaction<T = unknown>(
summary: string,
+26 -1
View File
@@ -23,6 +23,24 @@ interface GitContext {
commits: string;
}
type PreloadedSkill = { name: string; content: string };
async function loadPreloadedSkillContents(preloadedSkills: Skill[]): Promise<PreloadedSkill[]> {
const contents = await Promise.all(
preloadedSkills.map(async skill => {
try {
const content = await Bun.file(skill.filePath).text();
return { name: skill.name, content };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(`Failed to load skill "${skill.name}" from ${skill.filePath}: ${message}`);
}
}),
);
return contents;
}
/**
* Load git context for the system prompt.
* Returns structured git data or null if not in a git repo.
@@ -643,6 +661,8 @@ export interface BuildSystemPromptOptions {
contextFiles?: Array<{ path: string; content: string; depth?: number }>;
/** Pre-loaded skills (skips discovery if provided). */
skills?: Skill[];
/** Skills to inline into the system prompt instead of listing available skills. */
preloadedSkills?: Skill[];
/** Pre-loaded rulebook rules (rules with descriptions, excluding TTSR and always-apply). */
rules?: Array<{ name: string; description?: string; path: string; globs?: string[] }>;
}
@@ -662,6 +682,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
cwd,
contextFiles: providedContextFiles,
skills: providedSkills,
preloadedSkills: providedPreloadedSkills,
rules,
} = options;
const resolvedCwd = cwd ?? process.cwd();
@@ -707,13 +728,15 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
const skills =
providedSkills ??
(skillsSettings?.enabled !== false ? (await loadSkills({ ...skillsSettings, cwd: resolvedCwd })).skills : []);
const preloadedSkills = providedPreloadedSkills;
const preloadedSkillContents = preloadedSkills ? await loadPreloadedSkillContents(preloadedSkills) : [];
// Get git context
const git = await loadGitContext(resolvedCwd);
// Filter skills to only include those with read tool
const hasRead = tools?.has("read");
const filteredSkills = hasRead ? skills : [];
const filteredSkills = preloadedSkills === undefined && hasRead ? skills : [];
if (resolvedCustomPrompt) {
return renderPromptTemplate(customSystemPromptTemplate, {
@@ -724,6 +747,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
agentsMdSearch,
git,
skills: filteredSkills,
preloadedSkills: preloadedSkillContents,
rules: rules ?? [],
dateTime,
cwd: resolvedCwd,
@@ -738,6 +762,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
agentsMdSearch,
git,
skills: filteredSkills,
preloadedSkills: preloadedSkillContents,
rules: rules ?? [],
dateTime,
cwd: resolvedCwd,
@@ -75,6 +75,7 @@ export interface ExecutorOptions {
eventBus?: EventBus;
contextFiles?: ContextFileEntry[];
skills?: Skill[];
preloadedSkills?: Skill[];
promptTemplates?: PromptTemplate[];
mcpManager?: MCPManager;
authStorage?: AuthStorage;
@@ -804,6 +805,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
pythonPreludeDocs: pythonPreludeDocsPayload,
contextFiles: options.contextFiles,
skills: options.skills,
preloadedSkills: options.preloadedSkills,
promptTemplates: options.promptTemplates,
mcpTools: options.mcpManager ? extractMCPToolMetadata(options.mcpManager) : undefined,
pythonToolProxy: pythonProxyEnabled,
+60 -13
View File
@@ -391,12 +391,57 @@ export class TaskTool implements AgentTool<typeof taskSchema, TaskToolDetails, T
// Build full prompts with context prepended
const tasksWithContext = tasksWithUniqueIds.map(t => renderTemplate(context, t));
const contextFiles = this.session.contextFiles;
const skills = this.session.skills;
const availableSkills = this.session.skills;
const availableSkillList = availableSkills ?? [];
const promptTemplates = this.session.promptTemplates;
const skillLookup = new Map(availableSkillList.map(skill => [skill.name, skill]));
const missingSkillsByTask: Array<{ id: string; missing: string[] }> = [];
const tasksWithSkills = tasksWithContext.map(task => {
if (task.skills === undefined) {
return { ...task, resolvedSkills: availableSkills, preloadedSkills: undefined };
}
const requested = task.skills;
const resolved = [] as typeof availableSkillList;
const missing: string[] = [];
const seen = new Set<string>();
for (const name of requested) {
const trimmed = name.trim();
if (!trimmed || seen.has(trimmed)) continue;
seen.add(trimmed);
const skill = skillLookup.get(trimmed);
if (skill) {
resolved.push(skill);
} else {
missing.push(trimmed);
}
}
if (missing.length > 0) {
missingSkillsByTask.push({ id: task.id, missing });
}
return { ...task, resolvedSkills: resolved, preloadedSkills: resolved };
});
if (missingSkillsByTask.length > 0) {
const available = availableSkillList.map(skill => skill.name).join(", ") || "none";
const details = missingSkillsByTask.map(entry => `${entry.id}: ${entry.missing.join(", ")}`).join("; ");
return {
content: [
{
type: "text",
text: `Unknown skills requested: ${details}. Available skills: ${available}`,
},
],
details: {
projectAgentsDir,
results: [],
totalDurationMs: Date.now() - startTime,
},
};
}
// Initialize progress for all tasks
for (let i = 0; i < tasksWithContext.length; i++) {
const t = tasksWithContext[i];
for (let i = 0; i < tasksWithSkills.length; i++) {
const t = tasksWithSkills[i];
progressMap.set(i, {
index: i,
id: t.id,
@@ -416,7 +461,7 @@ export class TaskTool implements AgentTool<typeof taskSchema, TaskToolDetails, T
}
emitProgress();
const runTask = async (task: (typeof tasksWithContext)[number], index: number) => {
const runTask = async (task: (typeof tasksWithSkills)[number], index: number) => {
if (!isIsolated) {
return runSubprocess({
cwd: this.session.cwd,
@@ -437,8 +482,8 @@ export class TaskTool implements AgentTool<typeof taskSchema, TaskToolDetails, T
eventBus: undefined,
onProgress: progress => {
progressMap.set(index, {
...progress,
args: tasksWithContext[index]?.args,
...structuredClone(progress),
args: tasksWithSkills[index]?.args,
});
emitProgress();
},
@@ -447,7 +492,8 @@ export class TaskTool implements AgentTool<typeof taskSchema, TaskToolDetails, T
settingsManager: this.session.settingsManager,
mcpManager: this.session.mcpManager,
contextFiles,
skills,
skills: task.resolvedSkills,
preloadedSkills: task.preloadedSkills,
promptTemplates,
});
}
@@ -480,8 +526,8 @@ export class TaskTool implements AgentTool<typeof taskSchema, TaskToolDetails, T
eventBus: undefined,
onProgress: progress => {
progressMap.set(index, {
...progress,
args: tasksWithContext[index]?.args,
...structuredClone(progress),
args: tasksWithSkills[index]?.args,
});
emitProgress();
},
@@ -490,7 +536,8 @@ export class TaskTool implements AgentTool<typeof taskSchema, TaskToolDetails, T
settingsManager: this.session.settingsManager,
mcpManager: this.session.mcpManager,
contextFiles,
skills,
skills: task.resolvedSkills,
preloadedSkills: task.preloadedSkills,
promptTemplates,
});
const patch = await captureDeltaPatch(worktreeDir, baseline);
@@ -527,7 +574,7 @@ export class TaskTool implements AgentTool<typeof taskSchema, TaskToolDetails, T
// Execute in parallel with concurrency limit
const { results: partialResults, aborted } = await mapWithConcurrencyLimit(
tasksWithContext,
tasksWithSkills,
MAX_CONCURRENCY,
runTask,
signal,
@@ -538,10 +585,10 @@ export class TaskTool implements AgentTool<typeof taskSchema, TaskToolDetails, T
if (result !== undefined) {
return {
...result,
args: tasksWithContext[index]?.args,
args: tasksWithSkills[index]?.args,
};
}
const task = tasksWithContext[index];
const task = tasksWithSkills[index];
return {
index,
id: task.id,
+3 -1
View File
@@ -5,10 +5,11 @@ type RenderResult = {
args: Record<string, string>;
id: string;
description: string;
skills?: string[];
};
export function renderTemplate(template: string, task: TaskItem): RenderResult {
const { id, description, args } = task;
const { id, description, args, skills } = task;
let usedPlaceholder = false;
const unknownArguments: string[] = [];
@@ -43,5 +44,6 @@ export function renderTemplate(template: string, task: TaskItem): RenderResult {
args: { id, description, ...args },
id,
description,
skills,
};
}
+5
View File
@@ -49,6 +49,11 @@ export const taskItemSchema = Type.Object({
description: "Arguments to fill {{placeholders}} in context",
}),
),
skills: Type.Optional(
Type.Array(Type.String(), {
description: "Skill names to preload into the subagent system prompt",
}),
),
});
export type TaskItem = Static<typeof taskItemSchema>;
@@ -107,6 +107,7 @@ export interface SubagentWorkerStartPayload {
pythonPreludeDocs?: PreludeHelper[];
contextFiles?: ContextFileEntry[];
skills?: Skill[];
preloadedSkills?: Skill[];
promptTemplates?: PromptTemplate[];
mcpTools?: MCPToolMetadata[];
pythonToolProxy?: boolean;
+9
View File
@@ -608,6 +608,7 @@ async function runTask(runState: RunState, payload: SubagentWorkerStartPayload):
requireCompleteTool: true,
contextFiles: payload.contextFiles,
skills: payload.skills,
preloadedSkills: payload.preloadedSkills,
promptTemplates: payload.promptTemplates,
// Append system prompt (equivalent to CLI's --append-system-prompt)
systemPrompt: defaultPrompt =>
@@ -627,6 +628,14 @@ async function runTask(runState: RunState, payload: SubagentWorkerStartPayload):
runState.session = session;
checkAbort();
// Write session init metadata for debugging/replay
session.sessionManager.appendSessionInit({
systemPrompt: session.agent.state.systemPrompt,
task: payload.task,
tools: session.getAllToolNames(),
outputSchema: payload.outputSchema,
});
signal.addEventListener(
"abort",
() => {