Skip to content
Merged
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
6 changes: 6 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ A tool call whose pending acknowledgment lets planning continue before its outco
is available. It remains owned by the run until completion or cancellation.
_Avoid_: Detached job, fire-and-forget work

**Skill**:
Instructions for one kind of task that the model loads by name when a task
matches its description. The application's skill source supplies them; a run
lists the skills once and keeps that list for the run.
_Avoid_: Plugin, prompt template

**Handoff**:
A change of active agent definition within a run that preserves its history,
context, root policies, and remaining budgets.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ repeat a completed model call.
| AG-UI clients | `onProgress`, `onApproval` and client-run tools as AG-UI events | [18](packages/examples/src/lessons/18_ag_ui.ts) |
| Guardrails | `beforeRun`, `beforeStep`, `beforeTool`, `afterTool`, `afterRun`, `before` | [13](packages/examples/src/lessons/13_tool_guardrail.ts) |
| Web search | `webSearch` (`/web-search`) | [14](packages/examples/src/lessons/14_web_search.ts) |
| Skills | `agent({skills})` with any `SkillSource`, `skills([...])` | [19](packages/examples/src/lessons/19_skills.ts) |
| Large tool results | `agent({toolResults})`: previews, read on with `read_tool_result` | — |
| Long runs | `agent({compaction, contextWindow})`, `ContextOverflowError` | — |
| Limits | `maxSteps`, `maxToolCalls`, `maxToolDepth`, `maxOutputChars`, `finalize` | — |
Expand Down
30 changes: 28 additions & 2 deletions packages/core/src/agent-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {applyOutputDecision, createOutputCandidate, summarizeStoppedRun} from ".
import {summarizeContext, truncateContext} from "./context-compaction.js";
import {estimateTokens, jsonTokens, textTokens} from "./context-size.js";
import {READ_TOOL, readToolResultTool, type PreviewSettings} from "./tool-result-preview.js";
import {listSkills, skillsSection, skillTools} from "./skills.js";
import {jsonSchema, modelResponse, steeringUpdate} from "./validation.js";
import type {
AgentOptions,
Expand Down Expand Up @@ -68,6 +69,12 @@ export function* runAgent<C, O>(
return yield* new AgentRun(root, config, history, options).run();
}

/** The agent's instructions followed by its skills section. */
function withSkills(instructions: string | undefined, section: string | undefined) {
if (!section) return instructions;
return instructions ? `${instructions}\n\n${section}` : section;
}

function allow(result: GuardrailResult): void {
const rejected = denial(result);
if (rejected !== undefined) throw new GuardrailError(rejected);
Expand Down Expand Up @@ -110,6 +117,8 @@ class AgentRun<C, O> {
private lastRequest: Pick<ModelRequest, "instructions" | "tools"> | undefined;
private active: AgentRuntime<C>;
private catalog: ToolCatalog<C>;
/** The root agent's skills, listed once at the start of the run. */
private skills: {section: string; tools: Record<string, Tool<C>>} | undefined;
private readonly context: RunContext<C>;
private readonly tasks = new RunTasks();
private readonly tools: RunTools<C>;
Expand Down Expand Up @@ -160,6 +169,15 @@ class AgentRun<C, O> {
private *start(): Operation<void> {
if (this.root.beforeRun)
allow(yield* this.root.beforeRun(this.history.snapshot(), this.context));
const source = this.root.skills;
const listed = source ? yield* listSkills(source) : [];
if (source && listed.length) {
this.skills = {
section: skillsSection(listed, source.read !== undefined),
tools: skillTools<C>(source, listed),
};
this.catalog = createToolCatalog(this.active.tools, this.runTools());
}
yield* this.history.compact();
const controls = resolveControls(this.options);
this.steeringSource = controls.steering;
Expand Down Expand Up @@ -300,7 +318,9 @@ class AgentRun<C, O> {
private requestOverhead(): number {
const {instructions} = this.active;
const text =
typeof instructions === "string" ? instructions : (this.lastRequest?.instructions ?? "");
typeof instructions === "function"
? (this.lastRequest?.instructions ?? "")
: (withSkills(instructions, this.skills?.section) ?? "");
return textTokens(text) + jsonTokens(JSON.stringify(this.visibleTools()));
}

Expand All @@ -317,6 +337,10 @@ class AgentRun<C, O> {
/** Tools that belong to the run rather than an agent, so they survive a handoff. */
private runTools(): Record<string, Tool<C>> {
const tools = {...this.options?.tools};
for (const [name, skillTool] of Object.entries(this.skills?.tools ?? {})) {
if (name in tools) throw new TypeError(`Duplicate tool name: ${name}`);
tools[name] = skillTool;
}
const settings = this.config.toolResults;
if (!settings) return tools;
if (READ_TOOL in tools) throw new TypeError(`Duplicate tool name: ${READ_TOOL}`);
Expand Down Expand Up @@ -352,10 +376,12 @@ class AgentRun<C, O> {
const {active} = this;
const request = {
// Called as a method, so an instructions function can use `this`.
instructions:
instructions: withSkills(
typeof active.instructions === "function"
? yield* active.instructions(this.context)
: active.instructions,
this.skills?.section,
),
outputSchema: this.config.outputSchema,
maxOutputTokens: this.config.maxOutputTokens,
step: this.step,
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
export {agent} from "./agent.js";
export {skills} from "./skills.js";
export type {SkillDefinition} from "./skills.js";
export {tool, asyncTool, handlerTool, awaitChild, handoff, agentTool} from "./tool.js";
export type {AgentToolOptions} from "./tool.js";
export {serviceTool} from "./service-tool.js";
Expand Down Expand Up @@ -26,6 +28,8 @@ export type {
ModelFinalResponse,
ModelToolCallsResponse,
ModelUsage,
SkillSource,
SkillSummary,
OutputCandidate,
OutputDecision,
RunOptions,
Expand Down
139 changes: 139 additions & 0 deletions packages/core/src/skills.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
// Skills: instructions for a kind of task, loaded when a task needs them.
// The model first sees each skill's name and description, after the agent's
// instructions; it loads a skill with `load_skill` and the files the skill
// refers to with `read_skill_file`:
//
// instructions + "## Skills\n- pdf-forms: Fill in PDF forms. ..."
// load_skill({name: "pdf-forms"}) -> the skill's instructions
// read_skill_file({name: "pdf-forms", path: "fields.md"}) -> one of its files
//
// The source is the application's: files, a repository, state, or code. The
// run lists it once, so the instructions, and with them the cached prefix,
// stay the same all run. Every call is journaled.
import {TerminalError} from "@restatedev/restate-sdk";
import {run, type Operation} from "@restatedev/restate-sdk-gen";
import {z} from "zod";
import {ToolError} from "./errors.js";
import {asyncTool} from "./tool.js";
import type {SkillSource, SkillSummary, Tool} from "./types.js";

export const LOAD_SKILL = "load_skill";
export const READ_SKILL_FILE = "read_skill_file";
const DEFAULT_RETRY = {maxAttempts: 3};
// Names as in Anthropic's Agent Skills format; the model calls skills by name.
const NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
const MAX_NAME = 64;

/** Lists a source's skills once, journaled, and checks the list. */
export function* listSkills(source: SkillSource): Operation<SkillSummary[]> {
const listed = yield* run(
async ({signal}) =>
(await source.list({signal})).map(({name, description, location}) =>
location === undefined ? {name, description} : {name, description, location},
),
{name: "skills:list", retry: source.retry ?? DEFAULT_RETRY},
);
const names = new Set<string>();
for (const {name, description} of listed) {
if (!NAME.test(name) || name.length > MAX_NAME)
throw new TerminalError(
`Invalid skill name "${name}": use up to ${MAX_NAME} lowercase letters, digits and hyphens`,
);
// The model picks skills by description, so each needs one. Its length is
// the source's choice: published skills exceed the format's 1024 characters.
if (!description) throw new TerminalError(`Skill "${name}" needs a description`);
if (names.has(name)) throw new TerminalError(`Duplicate skill name: ${name}`);
names.add(name);
}
return listed;
}

/** The section the run appends to the agent's instructions. */
export function skillsSection(skills: SkillSummary[], canRead: boolean): string {
const lines = [
"## Skills",
`Skills are instructions for specific kinds of tasks. When a task matches a skill's description, call ${LOAD_SKILL} with its name before you start, and follow what it says.` +
(canRead ? ` Read a file the skill refers to with ${READ_SKILL_FILE}.` : ""),
...skills.map(({name, description}) => `- ${name}: ${description}`),
];
return lines.join("\n");
}

/** The tools that load a run's skills. */
export function skillTools<C>(
source: SkillSource,
skills: SkillSummary[],
): Record<string, Tool<C>> {
const byName = new Map(skills.map((skill) => [skill.name, skill]));
const retry = source.retry ?? DEFAULT_RETRY;
const known = (name: string) => {
const skill = byName.get(name);
if (!skill)
throw new ToolError(
`Unknown skill "${name}". Skills: ${[...byName.keys()].join(", ")}`,
"invalid_input",
);
return skill;
};
const tools: Record<string, Tool<C>> = {
[LOAD_SKILL]: asyncTool({
description: "Load a skill's instructions by its name.",
input: z.object({name: z.string().describe("The skill's name, as listed.")}),
retry,
async execute({name}, {signal}) {
return await source.load(known(name), {signal});
},
}),
};
const read = source.read?.bind(source);
if (read)
tools[READ_SKILL_FILE] = asyncTool({
description: "Read a file that a loaded skill refers to.",
input: z.object({
name: z.string().describe("The skill's name."),
path: z.string().describe("The file's path relative to the skill, as the skill gives it."),
}),
retry,
async execute({name, path}, {signal}) {
const skill = known(name);
// A source reads from its own store; a path never leaves the skill.
if (path.startsWith("/") || path.split(/[\\/]/).includes(".."))
throw new ToolError(
"A skill file path is relative and stays in the skill",
"invalid_input",
);
return await read(skill, path, {signal});
},
});
return tools;
}

/** A skill written in code: its instructions and the files they refer to. */
export type SkillDefinition = SkillSummary & {
instructions: string;
files?: Record<string, string>;
};

/** A source for skills defined in code. */
export function skills(definitions: SkillDefinition[]): SkillSource {
const byName = new Map(definitions.map((skill) => [skill.name, skill]));
const find = (name: string) => {
const skill = byName.get(name);
if (!skill) throw new ToolError(`Unknown skill "${name}"`, "invalid_input");
return skill;
};
return {
async list() {
return definitions.map(({name, description}) => ({name, description}));
},
async load({name}) {
return find(name).instructions;
},
async read({name}, path) {
const file = find(name).files?.[path];
if (file === undefined)
throw new ToolError(`Skill "${name}" has no file "${path}"`, "invalid_input");
return file;
},
};
}
29 changes: 29 additions & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,35 @@ export type AgentOptions<Context, Output = string> = {
* loaded history. The saved and returned transcript keeps every result whole.
* `false` shows every result in full. */
toolResults?: false | {maxTokens?: number; previewTokens?: number};
/** Skills the model can load when a task needs them. The run lists them once
* at its start, after the instructions, and adds `load_skill` (and
* `read_skill_file` when the source can read files). They belong to the
* run, so they survive a handoff. */
skills?: SkillSource;
};
/** A skill as the model first sees it: picked by its description, loaded by name. */
export type SkillSummary = {
name: string;
description: string;
/** Where the source finds the skill, such as a folder or a URL pinned to a
* commit. It is journaled with the list, so every load and read of a run
* sees the same location, also after a replay. Never shown to the model. */
location?: string;
};
/**
* Where an agent's skills come from: files, a repository, state, or code.
* Each call runs journaled, like an `asyncTool`, so a source can do plain I/O
* and a replay reads the journal instead of calling it again.
*/
export type SkillSource = {
/** The skills to offer. Called once per run; the list holds for the run. */
list(options: {signal: AbortSignal}): Promise<SkillSummary[]>;
/** A skill's instructions, such as the body of its SKILL.md. */
load(skill: SkillSummary, options: {signal: AbortSignal}): Promise<string>;
/** A file a skill's instructions refer to, by a path relative to the skill. */
read?(skill: SkillSummary, path: string, options: {signal: AbortSignal}): Promise<string>;
/** Retries for each call. Default: three attempts. */
retry?: RetryOptions;
};
export type RunArguments<Context> = undefined extends Context
? [options?: RunOptions<Context>]
Expand Down
24 changes: 24 additions & 0 deletions packages/examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ the request body required; object fields use `.default(...)`.
| 16 | Follow an agent's progress | [16_progress_feed.ts](src/lessons/16_progress_feed.ts) |
| 17 | Tools from a remote MCP server | [17_mcp_tools.ts](src/lessons/17_mcp_tools.ts) |
| 18 | Serve an AG-UI client | [18_ag_ui.ts](src/lessons/18_ag_ui.ts) |
| 19 | Give an agent skills from a folder | [19_skills.ts](src/lessons/19_skills.ts) |

If you have a specific goal, start here:

Expand All @@ -50,6 +51,7 @@ If you have a specific goal, start here:
| Show progress while an agent works | [16](src/lessons/16_progress_feed.ts) |
| Use tools from an MCP server | [17](src/lessons/17_mcp_tools.ts) |
| Connect a UI such as CopilotKit | [18](src/lessons/18_ag_ui.ts) |
| Teach an agent task-specific skills | [19](src/lessons/19_skills.ts) |

## Start

Expand Down Expand Up @@ -496,6 +498,28 @@ resolves the signal the turn waits on.
The handler polls the object's state, which keeps the lesson small. It ignores
the client's `context` and `state`: the object holds the conversation.

## 19 — Give an agent skills from a folder

A skill is instructions for one kind of task, such as writing release notes.
`agent({skills})` takes a `SkillSource`: the run lists its skills once, puts
each name and description after the agent's instructions, and gives the model
`load_skill` to load one when a task needs it, and `read_skill_file` for the
files a skill refers to. Every source call is journaled, and the list holds
for the whole run, so the cached prompt prefix does too.

The SDK does not read files itself. This lesson's `folderSkills` reads
[skills/](skills), laid out like Anthropic's Agent Skills: one folder per
skill, with a `SKILL.md` whose frontmatter gives its `name` and `description`.
Skills defined in code need no folder: use `skills([{name, description,
instructions, files}])` from `@restate-agents/core`.

```sh
curl "$RESTATE_CALL_URL/SkilledWriter/write" --json '"Release notes for 2.4.0: added CSV export, fixed the login redirect loop, bumped lodash."'
```

A skill's scripts are out of scope: the SDK offers no shell to run them. An
agent with a sandbox, as in lesson 11, can copy a skill in and run them.

## Model configuration

[shared.ts](src/shared.ts) configures the example model from `AGENT_MODEL`:
Expand Down
4 changes: 4 additions & 0 deletions packages/examples/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@
"./ag-ui": {
"types": "./dist/lessons/18_ag_ui.d.ts",
"import": "./dist/lessons/18_ag_ui.js"
},
"./skills": {
"types": "./dist/lessons/19_skills.d.ts",
"import": "./dist/lessons/19_skills.js"
}
},
"scripts": {
Expand Down
15 changes: 15 additions & 0 deletions packages/examples/skills/incident-summary/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
name: incident-summary
description: Summarize a production incident for stakeholders. Use when given an incident timeline, alerts or a postmortem draft.
---

# Incident summary

Write four short paragraphs, in this order:

1. **Impact**: who was affected, how, and for how long.
2. **Cause**: the trigger and the underlying cause, without blame.
3. **Resolution**: what stopped the impact.
4. **Follow-ups**: the changes that keep it from happening again, each with an owner.

Use plain language; a reader outside engineering must follow it.
12 changes: 12 additions & 0 deletions packages/examples/skills/release-notes/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
name: release-notes
description: Write release notes from a list of changes. Use when asked for release notes, a changelog entry or an announcement of a release.
---

# Release notes

1. Group the changes into Added, Changed and Fixed. Leave out internal
refactors and dependency bumps that users cannot notice.
2. Write each entry as one sentence about what a user can now do, not about
the code that changed.
3. Lay out the notes as in `template.md`.
15 changes: 15 additions & 0 deletions packages/examples/skills/release-notes/template.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
## <version> — <date>

<One sentence on the theme of the release.>

### Added

- <entry>

### Changed

- <entry>

### Fixed

- <entry>
Loading
Loading