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
16 changes: 10 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,12 +147,16 @@ non-chat HTTP seam — never a `ChatModel`.

**Skills** (`src/skills.ts`) are host-owned instructions, not a protocol concept
(ADR-004): a directory of `<name>/SKILL.md` files, discovered from `--skills <dir>`
(plus the `./skills` convention) and exposed to the model as an ordinary toolset
(`skillsToolset`, namespaced `skills_*`), so reading one goes through
`AgentRuntime.execute` like any MCP tool. Only `name`/`description` enter the
system prompt — progressive disclosure — and the body is fetched on demand; the
read tool is confined to the skill's own directory, and a malformed `SKILL.md` is
warned about and skipped rather than failing the run.
(plus the `./skills` convention and the user-level `$XDG_CONFIG_HOME/agent/skills`)
and exposed to the model as an ordinary toolset (`skillsToolset`, namespaced
`skills_*`), so reading one goes through `AgentRuntime.execute` like any MCP tool.
The levels are searched in precedence order — explicit, then project, then user —
and a name defined at a higher level wins, so a project skill overrides the user's
copy of the same name rather than merging; the prompt groups the listing by level so
each skill's provenance is visible. Only `name`/`description` enter the system
prompt — progressive disclosure — and the body is fetched on demand; the read tool
is confined to the skill's own directory, and a malformed `SKILL.md` is warned about
and skipped rather than failing the run.

**Delegation** (`src/delegation.ts`) is a `task` tool, off unless `--task`: a
sub-agent that runs in its own session and returns only its result. Two rules
Expand Down
18 changes: 11 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,13 +94,17 @@ do nothing at all in the REPL.)
- **Learned context.** Before each turn the agent injects a compact summary of
observed tool reliability/insights into the system prompt (progressive
disclosure of evaluation output); `--no-context` disables it.
- **Skills.** `--skills <dir>` (repeatable; `./skills` is picked up automatically)
loads `<name>/SKILL.md` skills — instructions the model loads on demand. Only
each skill's `name` and `description` enter the system prompt; the body (or one
bundled file) is read through the `skills_read_skill` tool when the work calls
for it, so a large skill library costs almost nothing until it is used. A
malformed `SKILL.md` is reported and skipped rather than failing the run, and
the read tool cannot leave a skill's own directory.
- **Skills.** `--skills <dir>` (repeatable) loads `<name>/SKILL.md` skills —
instructions the model loads on demand. Three levels are searched, highest
precedence first: the explicit directories, the project's `./skills`, and the
user-level `$XDG_CONFIG_HOME/agent/skills` (`~/.config/agent/skills`), the latter
two only when they exist. A project skill overrides a user one of the same name,
and the prompt groups the listing by level so provenance is visible. Only each
skill's `name` and `description` enter the system prompt; the body (or one bundled
file) is read through the `skills_read_skill` tool when the work calls for it, so a
large skill library costs almost nothing until it is used. A malformed `SKILL.md`
is reported and skipped rather than failing the run, and the read tool cannot leave
a skill's own directory.
- **Delegation.** `--task` exposes a `task` tool: the model can hand a
self-contained job to a **sub-agent** that runs in its own session and returns
only its result — so a broad search or a long review cannot flood the caller's
Expand Down
2 changes: 1 addition & 1 deletion docs/archetypes/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ component_, not a commitment.
| 1 | Pause/resume primitive (`interrupt`/`resume` over durable state) | #35 | durable state (🟡) | small | **done** — `--pause`/`--resume`, verified end to end (#46) |
| 2 | Sub-agent delegation (isolated session, derived permissions, fg/bg, notify) | #36 | 1 (for suspension of children) | medium | **done** — `task` tool, isolation, fg/bg, named agents that narrow tools (#52); a child **denies** `require_confirmation` rather than suspending (a child must not amplify authority) |
| 3 | Shared task list + orchestration modes | #37 | 2 | medium | **done** — `tasks_*` tools over the `spec` `Task`, and `--mode coordinate\|route\|broadcast\|tasks` |
| 4 | Skills: `SKILL.md` discovery, progressive disclosure, supply chain | #38 | — | medium | **mostly done** — loader + `--skills`; supply chain (fetch/cache/version) open |
| 4 | Skills: `SKILL.md` discovery, progressive disclosure, supply chain | #38 | — | medium | **mostly done** — loader + `--skills`; project and user levels with precedence; supply chain (fetch/cache/version) open |
| 5 | Pipeline / step model (conditions, loops, parallel, fan-out) | #39 | 3 (task list) | large | not started |
| 6 | Planning passes + typed memory steps (task/action/plan records) | #40 | — | small | **done** — typed steps + `--plan-interval` |

Expand Down
7 changes: 4 additions & 3 deletions docs/archetypes/skills-agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ skill; "read a file" is a tool.
| --------------------------------------------- | ------ | --------------------------------------------------------------------------------------------------------------- |
| `SKILL.md` definition + trigger description | ✅ | `src/skills.ts`: frontmatter `name`/`description`, the `description` used as the trigger |
| Progressive disclosure | ✅ | the prompt carries names + descriptions only; the body loads through `skills_read_skill`, bundled files by name |
| Discovery (user / project / remote) | 🟡 | `--skills <dir>` (repeatable) plus the `./skills` convention; no user-level default, nothing remote |
| Discovery (user / project / remote) | 🟡 | `--skills <dir>` (repeatable), the `./skills` convention, and a user-level default; nothing remote |
| Supply chain (fetch / cache / version) | ❌ | local directories only — nothing is fetched, so nothing needs signing |
| Invocation (context injection vs sub-session) | ✅ | both, explicitly: the body enters the current context via `skills_read_skill`, or the skill runs as an isolated sub-session when a `task` names it (`agent`), where its `tools`/`deny` narrows what that child may use |

Expand All @@ -110,8 +110,9 @@ Skills are a **document format plus a loader**, not a runtime:

1. **Definition + discovery** — a `skills/` convention (project + user),
frontmatter parsing, and the `description`-as-trigger rule. **Done:**
`src/skills.ts` + `--skills` / `./skills`; a user-level default directory is
still missing.
`src/skills.ts` + `--skills` / `./skills` / the user default
(`$XDG_CONFIG_HOME/agent/skills`), searched in precedence order so a project
skill wins over a user one of the same name.
2. **Progressive disclosure** — expose only `name` + `description` up front; load
the body on selection; expose `reference/` only when the procedure asks.
**Done:** the prompt lists names + descriptions, and `skills_read_skill` reads
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ a protocol concept.

| Capability | Module | What it is |
| ---------------- | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Skills** | `src/skills.ts` | `<name>/SKILL.md` instructions found under `--skills <dir>` (and a `./skills` convention). Only names and descriptions enter the system prompt; the body loads on demand through `skills_read_skill`, which cannot leave a skill's own directory. Frontmatter may also declare `tools`/`deny`, which makes the skill an **agent definition**. |
| **Skills** | `src/skills.ts` | `<name>/SKILL.md` instructions, found at three levels: `--skills <dir>`, the project's `./skills`, and the user-level `$XDG_CONFIG_HOME/agent/skills` — a project skill wins over a user one. Only names and descriptions enter the system prompt; `skills_read_skill` loads the body on demand and cannot leave the skill's directory. Frontmatter may also declare `tools`/`deny`, making the skill an **agent definition**. |
| **Pause/resume** | `src/runtime.ts` | With `--pause`, a `require_confirmation` call is recorded as a suspension and the run stops with exit `5` instead of denying it; `--resume <id>` continues the saved conversation so the blocked call simply runs. The record is `Suspension` (`@adaptivemcp/spec`), persisted by `memory`. |
| **Delegation** | `src/delegation.ts` | `--task` exposes `task`: a sub-agent in its own session that returns a **result, never its transcript**. It shares the parent's store (same graph and learned state, its own session) and inherits no authority — it never auto-approves and never receives `task` itself. `agent: "<name>"` names a definition whose policy is intersected with the parent's (`narrowTools`), so it can only narrow. |
| **Task list** | `src/tasks.ts` | `--tasks` exposes the shared list: `tasks_create` writes down intended work and the ids it `dependsOn`, `tasks_list` reads it back (or only what is _ready_), and `tasks_update` moves a task on — reporting what it left unmet or unblocked. Transitions are not policed: the record says what was decided, and the store's `readyTasks` is what orders it, treating an unknown dependency id as unfinished so a typo blocks rather than unblocks. |
Expand Down
7 changes: 4 additions & 3 deletions src/cli-args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -478,9 +478,10 @@ Usage:
Options:
--server <name=command args> MCP server over stdio (repeatable)
(default: the sibling adaptive-mcp example server)
--skills <dir> Skills directory: <name>/SKILL.md per skill (repeatable;
./skills is added when it exists). Only names and
descriptions enter the prompt; bodies load on demand
--skills <dir> Skills directory: <name>/SKILL.md per skill (repeatable).
./skills and $XDG_CONFIG_HOME/agent/skills are searched
too, a project skill winning over a user one. Only names
and descriptions enter the prompt; bodies load on demand
--prompt <text> One-shot prompt instead of the interactive REPL
-i, --interactive Force the interactive REPL
--model <id> Pin one catalog model (disables adaptive selection)
Expand Down
54 changes: 52 additions & 2 deletions src/cli.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,12 @@ interface CliRun {
}

/** Run the real CLI as a child process, the way a user would. */
function runCli(args: string[]): Promise<CliRun> {
function runCli(args: string[], env: Record<string, string> = {}): Promise<CliRun> {
return new Promise((resolve) => {
const child = spawn(process.execPath, [TSX, "src/cli.ts", ...args], { cwd: root });
const child = spawn(process.execPath, [TSX, "src/cli.ts", ...args], {
cwd: root,
env: { ...process.env, ...env },
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk: Buffer) => (stdout += chunk.toString()));
Expand Down Expand Up @@ -626,6 +629,53 @@ describe("cli, end to end and offline", () => {
rmSync(skillsDir, { recursive: true, force: true });
}, 30_000);

it("loads a user-level skill from $XDG_CONFIG_HOME, and says which level it came from", async () => {
// A skill the machine keeps for every project. It has to load with no flag,
// and the prompt has to mark it user-level: otherwise a project-local name and
// a machine-wide one read the same, and "local" is a guess.
const configHome = mkdtempSync(join(tmpdir(), "agent-e2e-user-skills-"));
const dir = join(configHome, "agent", "skills", "commit-style");
mkdirSync(dir, { recursive: true });
writeFileSync(
join(dir, "SKILL.md"),
"---\nname: commit-style\ndescription: Write commit messages this way\n---\n\nUse the imperative mood.\n",
);
model = new StubModelServer({ respond: () => ({ text: "done" }) });
const baseUrl = await model.start();

const run = await runCli(
[
"--prompt",
"say hello",
"--server",
serverSpec(),
"--base-url",
baseUrl,
"--api-key",
"test-key",
"--no-ollama",
"--no-stream",
"--db",
":memory:",
"--session",
"cli-e2e-user-skills",
"--max-steps",
"1",
],
{ XDG_CONFIG_HOME: configHome },
);

expect(run.code).toBe(0);
// It is a skill like any other: the loader exposed the read tool…
expect(offeredTools(model.requests[0]!)).toContain("skills_read_skill");
// …and the prompt carries it under the user level rather than anonymously.
const system = model.requests[0]?.messages.find((message) => message.role === "system");
expect(system?.content).toContain("User skills:");
expect(system?.content).toContain("commit-style: Write commit messages this way");

rmSync(configHome, { recursive: true, force: true });
}, 30_000);

it("pauses a high-risk call, then resumes the run that stopped on it", async () => {
// A file-backed store on purpose: a paused run has to outlive the process
// that paused it, which is the whole point of the feature.
Expand Down
21 changes: 5 additions & 16 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
#!/usr/bin/env node
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { readFileSync } from "node:fs";
import { AggregateToolset, sanitize, type ResourceToolset, type Toolset } from "./mcp/toolset.js";
import { StdioToolset } from "./mcp/stdio.js";
import { HttpToolset } from "./mcp/http.js";
Expand Down Expand Up @@ -42,6 +41,7 @@ import {
import {
agentDefinition,
discoverSkills,
skillDirs,
skillsPrompt,
skillsToolset,
type AgentDefinition,
Expand Down Expand Up @@ -211,7 +211,7 @@ async function main(): Promise<void> {
// Skills are host-owned instructions exposed as an ordinary toolset, so
// reading one takes the same path as any MCP tool (gate, telemetry, graph).
// Only names and descriptions reach the prompt; the body is fetched on demand.
const skills = discoverSkills(skillDirs(args), (message) => {
const skills = discoverSkills(skillDirs(args.skills), (message) => {
if (args.verbose) console.warn(`[skills] ${message}`);
});
const sessionId = args.session ?? `cli-${Date.now()}`;
Expand Down Expand Up @@ -266,7 +266,8 @@ async function main(): Promise<void> {
const tools = new AggregateToolset(toolsetList);
if (args.verbose && skills.length > 0) {
console.log(
`[skills] ${skills.length} loaded: ${skills.map((skill) => skill.name).join(", ")}`,
`[skills] ${skills.length} loaded: ` +
`${skills.map((skill) => `${skill.name} (${skill.origin})`).join(", ")}`,
);
}

Expand Down Expand Up @@ -673,18 +674,6 @@ async function main(): Promise<void> {
}
}

/**
* The skills directories for this run: every `--skills` value, plus the
* `./skills` convention when it exists — so dropping a skills directory next to
* the project is enough, with no flag.
*/
function skillDirs(args: CliArgs): string[] {
const dirs = [...args.skills];
const convention = join(process.cwd(), "skills");
if (!dirs.includes(convention) && existsSync(convention)) dirs.push(convention);
return dirs;
}

/** Child step cap: a sub-agent is for one bounded piece of work, not a second run. */
const TASK_MAX_STEPS = 6;

Expand Down
Loading
Loading