Skip to content
Closed
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
9 changes: 9 additions & 0 deletions .changeset/yellow-cars-smile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@voltagent/core": patch
---

feat: add structured workspace skills prompt context API for custom prompt assembly

- Added `workspace.skills.getPromptContext(...)` to return structured prompt metadata for available and activated skills.
- Each item now includes `id`, `name`, `description`, `path`, and `active`, making it easier to build custom `## Skills` sections without re-assembling data from `discoverSkills(...)`.
- Updated `workspace.skills.buildPrompt(...)` to reuse the new prompt context path while preserving existing `<workspace_skills>` output behavior.
3 changes: 3 additions & 0 deletions packages/core/src/workspace/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,9 @@ export type {
WorkspaceSkillsConfig,
WorkspaceSkillsRootResolver,
WorkspaceSkillsRootResolverContext,
WorkspaceSkillsPromptContext,
WorkspaceSkillsPromptContextOptions,
WorkspaceSkillsPromptSkill,
WorkspaceSkillsPromptOptions,
WorkspaceSkillSearchHybridWeights,
} from "./skills/types";
49 changes: 49 additions & 0 deletions packages/core/src/workspace/skills/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,55 @@ Run deep analysis and produce charts.`.split("\n"),
expect(prompt).not.toContain("INSTRUCTIONS_SHOULD_NOT_APPEAR");
});

it("returns structured prompt context for custom prompt formatting", async () => {
const timestamp = new Date().toISOString();
const workspace = new Workspace({
filesystem: {
files: {
"/skills/data/SKILL.md": {
content: `---
name: Data Analyst
description: Analyze CSV data
---
Use pandas.`.split("\n"),
created_at: timestamp,
modified_at: timestamp,
},
},
},
skills: {
rootPaths: ["/skills"],
autoDiscover: false,
},
});

await workspace.skills?.discoverSkills();
await workspace.skills?.activateSkill("/skills/data");

const promptContext = await workspace.skills?.getPromptContext({
includeAvailable: true,
includeActivated: true,
});

expect(promptContext?.available).toHaveLength(1);
expect(promptContext?.available[0]).toMatchObject({
id: "/skills/data",
name: "Data Analyst",
description: "Analyze CSV data",
path: "/skills/data/SKILL.md",
active: true,
});

expect(promptContext?.activated).toHaveLength(1);
expect(promptContext?.activated[0]).toMatchObject({
id: "/skills/data",
name: "Data Analyst",
description: "Analyze CSV data",
path: "/skills/data/SKILL.md",
active: true,
});
});

it("includes explicit guidance to use workspace skill tools", () => {
const workspace = new Workspace({
skills: {
Expand Down
89 changes: 56 additions & 33 deletions packages/core/src/workspace/skills/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,10 @@ import type {
WorkspaceSkillSearchOptions,
WorkspaceSkillSearchResult,
WorkspaceSkillsConfig,
WorkspaceSkillsPromptContext,
WorkspaceSkillsPromptContextOptions,
WorkspaceSkillsPromptOptions,
WorkspaceSkillsPromptSkill,
WorkspaceSkillsRootResolver,
WorkspaceSkillsRootResolverContext,
} from "./types";
Expand Down Expand Up @@ -977,51 +980,71 @@ export class WorkspaceSkills {
);
}

async buildPrompt(
options: WorkspaceSkillsPromptOptions & { context?: WorkspaceFilesystemCallContext } = {},
): Promise<string | null> {
await this.ensureDiscovered({ context: options.context });
async getPromptContext(
options: WorkspaceSkillsPromptContextOptions & {
context?: WorkspaceFilesystemCallContext;
} = {},
): Promise<WorkspaceSkillsPromptContext> {
if (options.refresh) {
await this.discoverSkills({ refresh: true, context: options.context });
} else {
await this.ensureDiscovered({ context: options.context });
}

const includeAvailable = options.includeAvailable ?? true;
const includeActivated = options.includeActivated ?? true;
const maxAvailable = options.maxAvailable ?? DEFAULT_MAX_AVAILABLE;
const maxActivated = options.maxActivated ?? DEFAULT_MAX_ACTIVATED;
const maxInstructionChars = options.maxInstructionChars ?? DEFAULT_MAX_INSTRUCTION_CHARS;
const activeIds = new Set(this.activeSkills);

const toPromptSkill = (skill: WorkspaceSkillMetadata): WorkspaceSkillsPromptSkill => ({
id: skill.id,
name: skill.name,
description: skill.description
? truncateText(skill.description, maxInstructionChars)
: undefined,
path: skill.path,
active: activeIds.has(skill.id),
});

const available = includeAvailable
? Array.from(this.skillsById.values()).slice(0, maxAvailable).map(toPromptSkill)
: [];

const activated = includeActivated
? Array.from(this.activeSkills)
.slice(0, maxActivated)
.map((id) => this.skillsById.get(id))
.filter((skill): skill is WorkspaceSkillMetadata => Boolean(skill))
.map(toPromptSkill)
: [];

return { available, activated };
}

async buildPrompt(
options: WorkspaceSkillsPromptOptions & { context?: WorkspaceFilesystemCallContext } = {},
): Promise<string | null> {
const promptContext = await this.getPromptContext(options);
const maxPromptChars = options.maxPromptChars ?? DEFAULT_MAX_PROMPT_CHARS;

const sections: string[] = [];

if (includeAvailable) {
const skills = Array.from(this.skillsById.values()).slice(0, maxAvailable);
if (skills.length > 0) {
const lines = skills.map((skill) => {
const descriptionText = skill.description
? truncateText(skill.description, maxInstructionChars)
: "";
const description = descriptionText ? ` - ${descriptionText}` : "";
return `- ${skill.name} (${skill.id})${description}`;
});
sections.push(`Available skills:\n${lines.join("\n")}`);
}
if (promptContext.available.length > 0) {
const lines = promptContext.available.map((skill) => {
const description = skill.description ? ` - ${skill.description}` : "";
return `- ${skill.name} (${skill.id})${description}`;
});
sections.push(`Available skills:\n${lines.join("\n")}`);
}

if (includeActivated) {
const activeIds = Array.from(this.activeSkills).slice(0, maxActivated);
if (activeIds.length > 0) {
const lines = activeIds
.map((id) => this.skillsById.get(id))
.filter((skill): skill is WorkspaceSkillMetadata => Boolean(skill))
.map((skill) => {
const descriptionText = skill.description
? truncateText(skill.description, maxInstructionChars)
: "";
const description = descriptionText ? ` - ${descriptionText}` : "";
return `- ${skill.name} (${skill.id})${description}`;
});
if (lines.length > 0) {
sections.push(`Activated skills:\n${lines.join("\n")}`);
}
}
if (promptContext.activated.length > 0) {
const lines = promptContext.activated.map((skill) => {
const description = skill.description ? ` - ${skill.description}` : "";
return `- ${skill.name} (${skill.id})${description}`;
});
sections.push(`Activated skills:\n${lines.join("\n")}`);
}

if (sections.length === 0) {
Expand Down
79 changes: 79 additions & 0 deletions packages/core/src/workspace/skills/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,16 +36,75 @@ export type WorkspaceSkillsConfig = {
hybrid?: WorkspaceSkillSearchHybridWeights;
};

/**
* Normalized metadata discovered from a skill's `SKILL.md`.
*
* @example
* ```ts
* const skill: WorkspaceSkillMetadata = {
* id: "/skills/playwright-cli",
* name: "playwright-cli",
* description: "Automates browser interactions for web testing...",
* path: "/skills/playwright-cli/SKILL.md",
* root: "/skills/playwright-cli",
* };
* ```
*/
export type WorkspaceSkillMetadata = {
/**
* Unique skill identifier.
* Usually defaults to the normalized skill root (for example `/skills/playwright-cli`).
*/
id: string;

/**
* Skill name.
*/
name: string;

/**
* Human-readable skill description.
*/
description?: string;

/**
* Skill version from `SKILL.md` frontmatter.
*/
version?: string;

/**
* Optional skill tags from `SKILL.md` frontmatter.
*/
tags?: string[];

/**
* Full path to the `SKILL.md` file.
* Example: `/skills/playwright-cli/SKILL.md`.
*/
path: string;

/**
* Root directory path of the skill.
* Example: `/skills/playwright-cli`.
*/
root: string;

/**
* Readable files under `references/`, relative to `root`.
* Example: `["references/running-code.md"]`.
*/
references?: string[];

/**
* Readable scripts under `scripts/`, relative to `root`.
* Example: `["scripts/run.sh"]`.
*/
scripts?: string[];

/**
* Readable assets under `assets/`, relative to `root`.
* Example: `["assets/input.csv"]`.
*/
assets?: string[];
};

Expand Down Expand Up @@ -86,3 +145,23 @@ export type WorkspaceSkillsPromptOptions = {
maxInstructionChars?: number;
maxPromptChars?: number;
};

export type WorkspaceSkillsPromptSkill = {
id: string;
name: string;
description?: string;
path: string;
active: boolean;
};

export type WorkspaceSkillsPromptContext = {
available: WorkspaceSkillsPromptSkill[];
activated: WorkspaceSkillsPromptSkill[];
};

export type WorkspaceSkillsPromptContextOptions = Omit<
WorkspaceSkillsPromptOptions,
"maxPromptChars"
> & {
refresh?: boolean;
};
31 changes: 21 additions & 10 deletions packages/server-core/src/schemas/agent.schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,16 +87,27 @@ export const WorkspaceReadFileSchema = z.object({
});

export const WorkspaceSkillMetadataSchema = z.object({
id: z.string(),
name: z.string(),
description: z.string().optional(),
version: z.string().optional(),
tags: z.array(z.string()).optional(),
path: z.string(),
root: z.string(),
references: z.array(z.string()).optional(),
scripts: z.array(z.string()).optional(),
assets: z.array(z.string()).optional(),
id: z.string().describe("Unique skill identifier (for example `/skills/playwright-cli`)"),
name: z.string().describe("Skill name"),
description: z.string().optional().describe("Human-readable skill description"),
version: z.string().optional().describe("Skill version from `SKILL.md` frontmatter"),
tags: z.array(z.string()).optional().describe("Optional skill tags from frontmatter"),
path: z
.string()
.describe("Full path to `SKILL.md` (for example `/skills/playwright-cli/SKILL.md`)"),
root: z.string().describe("Skill root directory path (for example `/skills/playwright-cli`)"),
references: z
.array(z.string())
.optional()
.describe("Readable files under `references/` (for example `references/running-code.md`)"),
scripts: z
.array(z.string())
.optional()
.describe("Readable scripts under `scripts/` (for example `scripts/run.sh`)"),
assets: z
.array(z.string())
.optional()
.describe("Readable assets under `assets/` (for example `assets/input.csv`)"),
Comment on lines +99 to +110
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

find packages/core/src/workspace/skills -type f -name "*.ts" | head -20

Repository: VoltAgent/voltagent

Length of output: 199


🏁 Script executed:

rg -n --type=ts -C5 'references|scripts|assets' packages/core/src/workspace/skills/ --max-count=30

Repository: VoltAgent/voltagent

Length of output: 24194


🏁 Script executed:

# Also check the actual agent.schemas.ts file to see the exact examples given
sed -n '90,115p' packages/server-core/src/schemas/agent.schemas.ts

Repository: VoltAgent/voltagent

Length of output: 1256


Add "relative to root" clarification to references, scripts, and assets descriptions for consistency with the source-of-truth TypeScript interface.

The descriptions in lines 99-110 use relative-path examples (references/running-code.md, scripts/run.sh, assets/input.csv), which are correct. However, the TypeScript interface in packages/core/src/workspace/skills/types.ts (lines 93-108) explicitly documents these fields as "relative to root", while the Zod schema descriptions omit this detail. This creates a documentation inconsistency that will propagate into OpenAPI specs and tool definitions.

✏️ Suggested description updates
  references: z
    .array(z.string())
    .optional()
-   .describe("Readable files under `references/` (for example `references/running-code.md`)"),
+   .describe("Readable files under `references/`, relative to `root` (for example `references/running-code.md`)"),
  scripts: z
    .array(z.string())
    .optional()
-   .describe("Readable scripts under `scripts/` (for example `scripts/run.sh`)"),
+   .describe("Readable scripts under `scripts/`, relative to `root` (for example `scripts/run.sh`)"),
  assets: z
    .array(z.string())
    .optional()
-   .describe("Readable assets under `assets/` (for example `assets/input.csv`)"),
+   .describe("Readable assets under `assets/`, relative to `root` (for example `assets/input.csv`)"),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
references: z
.array(z.string())
.optional()
.describe("Readable files under `references/` (for example `references/running-code.md`)"),
scripts: z
.array(z.string())
.optional()
.describe("Readable scripts under `scripts/` (for example `scripts/run.sh`)"),
assets: z
.array(z.string())
.optional()
.describe("Readable assets under `assets/` (for example `assets/input.csv`)"),
references: z
.array(z.string())
.optional()
.describe("Readable files under `references/`, relative to `root` (for example `references/running-code.md`)"),
scripts: z
.array(z.string())
.optional()
.describe("Readable scripts under `scripts/`, relative to `root` (for example `scripts/run.sh`)"),
assets: z
.array(z.string())
.optional()
.describe("Readable assets under `assets/`, relative to `root` (for example `assets/input.csv`)"),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/server-core/src/schemas/agent.schemas.ts` around lines 99 - 110,
Update the Zod schema field descriptions for references, scripts, and assets in
the agent schemas so they match the TypeScript interface by explicitly stating
they are "relative to `root`"; locate the
z.array(z.string()).optional().describe(...) entries for the references,
scripts, and assets fields and modify their describe(...) text to include the
phrase "relative to `root`" while keeping the existing examples (e.g., "Readable
files under `references/` relative to `root` (for example
`references/running-code.md`)").

});

export const WorkspaceSkillListItemSchema = WorkspaceSkillMetadataSchema.extend({
Expand Down
26 changes: 26 additions & 0 deletions website/docs/workspaces/skills.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,32 @@ const agent = new Agent({
});
```

For custom prompt formatting, use `workspace.skills.getPromptContext(...)` and build your own section:

```ts
const promptContext = await workspace.skills?.getPromptContext({
refresh: true,
includeAvailable: true,
includeActivated: true,
});

const skillLines =
promptContext && promptContext.available.length > 0
? promptContext.available.map(
(skill) => `- ${skill.name}: ${skill.description ?? "No description"} (path: ${skill.path})`
)
: ["- (none discovered)"];

const customSkillsPrompt = [
"## Skills",
"",
"A skill is a set of local instructions stored in a `SKILL.md` file.",
"",
"### Available Skills",
...skillLines,
].join("\n");
```

Disable auto prompt injection:

```ts
Expand Down