diff --git a/.fallowrc.json b/.fallowrc.json index cdfbf2922..6fa5f6152 100644 --- a/.fallowrc.json +++ b/.fallowrc.json @@ -13,7 +13,9 @@ "dynamicallyLoaded": [ "src/deploy/runtime.ts", "src/deploy/runtime.worker.ts", - "src/deploy/shims/sandboxCore.worker.ts" + "src/deploy/shims/sandboxCore.worker.ts", + "src/agentDir/__fixtures__/**/*.{ts,js}", + "examples/agent-dir/tools/*.ts" ], "ignoreDependencies": [ "@ai-sdk/anthropic", diff --git a/README.md b/README.md index dbb5ccc67..19c3a8ee6 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ dashboard — any provider, any deploy target, no lock-in. >>>>>>> origin/main - **Parallel tool calls** — when the model asks for several tools in one turn they run concurrently (cap it with `toolConcurrency`, or `1` for sequential), and results still reach the transcript in the model's call order - **Skills** — `defineSkill()` / `loadSkills('./skills')`: only each skill's name and description sit in the system prompt; the model loads the full markdown on demand through an auto-registered `load_skill` tool +- **Agents as directories** — `loadAgentDir('./my-agent')` turns `instructions.md`, `tools/*.ts`, `skills/` and `subagents//` into the same object `createAgent()` returns, so you can start with files and drop to code later — see [Agent directories](docs/agent-directories.md) - **Multi-agent delegation** — wrap a child agent as a tool with `createDelegateTool()`, with a `maxDepth` guard against delegation loops - **Pre/post hooks** — a `HookRegistry` of `AgentHook`s that can inspect or mutate a tool call/LLM generate step, or throw to abort it, run sandboxed by Agent Forge's hook editor - **Guardrails** — fail-closed, concurrently-run checks (secret scan, diff size, test/lint commands) that gate a fixer agent's patch before it's used @@ -258,6 +259,7 @@ console.log(session.id, session.messages.length); - [Evals](docs/evals.md) - trajectory evals with `defineEval()`, datasets, `mockModel`, judge evals, `loushy eval` with JUnit/JSON reports - [Sessions](docs/sessions.md) - multi-turn conversations: `agent.session()`, `MemorySessionStore`, `FileSessionStore` - [Skills](docs/skills.md) - on-demand instructions: `defineSkill()`, `loadSkills()`, how they save context +- [Agent directories](docs/agent-directories.md) - define an agent as a folder: layout, mapping to `createAgent()`, moving to code, security - [Agent Forge](docs/agent-forge.md) - the visual dashboard (`loushy studio`): quickstart, first-agent walkthrough, hook authoring - Full guides site: [linuxdevil.github.io/agent-sdk-docs](https://linuxdevil.github.io/agent-sdk-docs/) diff --git a/docs/agent-directories.md b/docs/agent-directories.md new file mode 100644 index 000000000..d9d26d718 --- /dev/null +++ b/docs/agent-directories.md @@ -0,0 +1,152 @@ +# Agent directories + +An agent can be defined as a directory. `loadAgentDir()` reads it and calls +`createAgent()` with the options the files describe, so it returns exactly what +`createAgent()` returns. The typed code API stays the source of truth: a +directory is a different way to write the same options, and you can switch to +code at any time without a rewrite. + +> **Security:** loading an agent directory **executes its code** (`agent.ts`, +> everything in `tools/`). Only load directories you trust. No sandboxing is +> implied; the files run with your process's full permissions. + +```ts +import { loadAgentDir } from '@loushy/build-ai-agent'; + +const agent = await loadAgentDir('./my-agent'); +const { text } = await agent.send('Hello!'); +``` + +## Layout + +```text +my-agent/ + agent.ts | agent.js | agent.json | agent.yaml # optional config + instructions.md # system prompt + tools/*.ts | *.js # defineTool() tools + skills/ # same layouts as loadSkills() + subagents// # nested agent directories +``` + +Only one config file may exist. Files inside `tools/` and the entries of +`skills/` and `subagents/` are read in sorted order, so loading is +deterministic. + +### Config file + +```ts +// agent.ts +export default { + model: 'openai/gpt-4o-mini', + description: 'Triages bug reports', + maxSteps: 8, + toolConcurrency: 2, +}; +``` + +| Key | Maps to | +| --- | ------- | +| `name` | `createAgent({ name })` (default: the directory name) | +| `description` | not a `createAgent` option; what a parent agent reads for a sub-agent (required there) | +| `model` | `createAgent({ model })` | +| `provider` | `createAgent({ provider })` (code config files only) | +| `instructions` | `createAgent({ instructions })` (use this or `instructions.md`, not both) | +| `maxSteps` | `createAgent({ maxSteps })` | +| `toolConcurrency` | `createAgent({ toolConcurrency })` | +| `projectInstructions` | `createAgent({ projectInstructions })` | + +Unknown keys are an error with a suggestion (`'modle' (did you mean 'model'?)`). + +### Tools + +Each file in `tools/` default-exports a `defineTool()` tool, and may also export +more tools by name (or an array of tools). Other exports are ignored, but a file +that exports no tool at all is an error, as are two tools with the same name +(both files are named in the message). + +```ts no-verify +// tools/send_email.ts +import { z } from 'zod'; +import { defineTool } from '@loushy/build-ai-agent'; + +export default defineTool({ + name: 'send_email', + description: 'Send an email', + input: z.object({ to: z.string(), body: z.string() }), + execute: async ({ to, body }) => ({ sent: true, to, length: body.length }), +}); +``` + +### Skills + +`skills/` accepts what `loadSkills()` accepts: `skills//SKILL.md` and +`skills/.md`, each with a `description` in its frontmatter. See +[Skills](skills.md). + +### Sub-agents + +Every directory in `subagents/` is itself an agent directory (and can have its +own `subagents/`). It must have a `description` in its config. The parent gets a +`delegate_to_` tool that runs the sub-agent with the task text and returns +its final answer. A sub-agent uses its own `model` if it sets one, otherwise it +inherits the parent's; a `provider` override passed to `loadAgentDir()` reaches +all of them. + +## How it maps to `createAgent()` + +| Directory | `createAgent()` option | +| --------- | ---------------------- | +| `instructions.md` | `instructions` | +| `agent.*` keys | the options in the table above | +| `tools/` | `tools: [...]` | +| `skills/` | `skills: await loadSkills('./skills')` | +| `subagents//` | one `delegate_to_` entry in `tools` | + +`resolveAgentDir()` returns the assembled options and a manifest, which is +handy for tests and tooling: + +```ts +import { resolveAgentDir, createAgent } from '@loushy/build-ai-agent'; + +const { config, manifest } = await resolveAgentDir('./my-agent'); +console.log(manifest.tools, manifest.skills, manifest.subagents); +const agent = createAgent({ ...config, maxSteps: 3 }); +``` + +## Overrides + +The second argument takes the same options as `createAgent()` and wins over the +files. Use it to swap the model in tests: + +```ts +import { loadAgentDir } from '@loushy/build-ai-agent'; +import { mockModel } from '@loushy/build-ai-agent/testing'; + +const agent = await loadAgentDir('./my-agent', { provider: mockModel(['Hi there.']) }); +``` + +`tools` and `skills` overrides replace the discovered ones (they are not +merged). A `provider/model` string in a file is dropped when you override +`provider`, so the instance is used as-is. + +## Moving from files to code + +Call `resolveAgentDir()`, print `config`, and paste what you need into a +`createAgent()` call; or load the directory and override just the parts you want +to own in code. Tools are plain `defineTool()` values, so a tool file can be +imported from code unchanged. + +## Loading TypeScript + +`.ts` files are loaded with a dynamic `import()`, so the process must already +run under a TypeScript loader: `npx tsx your-script.ts` (or ts-node, bun, deno, +or Node's built-in type stripping). Without one, loading fails with an error that +says so. Compile the directory first, or use `.js`/`.mjs` tools with an +`agent.json` / `agent.yaml` config, which work everywhere. A `.js` tool using +`import` syntax needs `"type": "module"` in the nearest `package.json` (or the +`.mjs` extension). + +## What is not covered + +`loushy dev` and `loushy mcp` still take an agent spec file +([Configuration](configuration.md)), not a directory. diff --git a/examples/README.md b/examples/README.md index 398e6b88b..faac16f91 100644 --- a/examples/README.md +++ b/examples/README.md @@ -5,6 +5,10 @@ is runnable directly (see its own README for exact instructions); most use `tsx` and a free mock provider by default, so nothing here needs an API key to try. +## [agent-dir](./agent-dir) + +An agent defined as a directory (`instructions.md`, `tools/`, `skills/`, `agent.json`) and loaded with `loadAgentDir()`; runs offline with a mock model (LOU-Y5). + ## [doc-qa](./doc-qa) A question-answering agent scoped to a single fixed document (LOU-H10). diff --git a/examples/agent-dir/README.md b/examples/agent-dir/README.md new file mode 100644 index 000000000..ca5ca61d4 --- /dev/null +++ b/examples/agent-dir/README.md @@ -0,0 +1,14 @@ +# agent-dir + +An agent defined as a directory: `instructions.md` is the system prompt, +`tools/word_count.ts` is a `defineTool()` tool, `skills/tone.md` is a skill and +`agent.json` holds config. `index.ts` loads it with `loadAgentDir()` and swaps in +a scripted `mockModel`, so it runs offline. + +```bash +npm run example:agent-dir +``` + +Drop the `provider` override and set `"model": "openai/gpt-4o-mini"` in +`agent.json` to run against a real model. Loading a directory executes its code, +so only load directories you trust. See [docs/agent-directories.md](../../docs/agent-directories.md). diff --git a/examples/agent-dir/agent.json b/examples/agent-dir/agent.json new file mode 100644 index 000000000..799d83151 --- /dev/null +++ b/examples/agent-dir/agent.json @@ -0,0 +1,4 @@ +{ + "description": "Counts words and suggests tone fixes", + "maxSteps": 5 +} diff --git a/examples/agent-dir/index.test.ts b/examples/agent-dir/index.test.ts new file mode 100644 index 000000000..fbb6e4339 --- /dev/null +++ b/examples/agent-dir/index.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest'; +import { loadAgentDir } from '../../src'; +import { mockModel } from '../../src/testing'; + +describe('examples/agent-dir', () => { + it('loads from disk and its tool and skill are callable offline', async () => { + const provider = mockModel([ + { + toolCalls: [ + { name: 'word_count', args: { text: 'one two three' } }, + { name: 'load_skill', args: { name: 'tone' } }, + ], + }, + 'done', + ]); + const agent = await loadAgentDir(__dirname, { provider }); + + const result = await agent.send('check it'); + + expect(result.text).toBe('done'); + const toolOutput = provider.calls[1].messages + .filter((m) => m.role === 'tool') + .map((m) => String(m.content)) + .join('\n'); + expect(toolOutput).toContain('"words":3'); + expect(toolOutput).toContain('active voice'); + }); +}); diff --git a/examples/agent-dir/index.ts b/examples/agent-dir/index.ts new file mode 100644 index 000000000..b17b8dcaf --- /dev/null +++ b/examples/agent-dir/index.ts @@ -0,0 +1,34 @@ +/** + * agent-dir - an agent defined as a directory (LOU-Y5). + * + * `loadAgentDir()` reads instructions.md, tools/, skills/ and agent.json from + * this folder and returns the same object `createAgent()` does. The model is + * overridden with a scripted `mockModel`, so this runs offline with no API key. + * + * Loading a directory executes its code (tools/*.ts), so only load directories + * you trust. Run with: npx tsx examples/agent-dir/index.ts + */ +import path from 'node:path'; +import { loadAgentDir } from '../../src'; +import { mockModel } from '../../src/testing'; + +const provider = mockModel([ + { + toolCalls: [ + { name: 'word_count', args: { text: 'This is a very simple sentence.' } }, + { name: 'load_skill', args: { name: 'tone' } }, + ], + }, + 'It has 6 words. Try: "This is a simple sentence."', +]); + +async function main() { + const agent = await loadAgentDir(path.join(__dirname), { provider }); + const result = await agent.send('Check this: "This is a very simple sentence."'); + console.log(result.text); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/examples/agent-dir/instructions.md b/examples/agent-dir/instructions.md new file mode 100644 index 000000000..53442928a --- /dev/null +++ b/examples/agent-dir/instructions.md @@ -0,0 +1,3 @@ +You are a writing assistant. When the user gives you text, use the `word_count` +tool to measure it, and load the `tone` skill before you suggest rewrites. +Keep answers short. diff --git a/examples/agent-dir/skills/tone.md b/examples/agent-dir/skills/tone.md new file mode 100644 index 000000000..bd4428255 --- /dev/null +++ b/examples/agent-dir/skills/tone.md @@ -0,0 +1,5 @@ +--- +description: How to adjust the tone of a piece of writing +--- +Prefer plain words and active voice. Cut filler such as "very" and "really". +Keep the author's meaning; only change how it sounds. diff --git a/examples/agent-dir/tools/word_count.ts b/examples/agent-dir/tools/word_count.ts new file mode 100644 index 000000000..bd6e10ac3 --- /dev/null +++ b/examples/agent-dir/tools/word_count.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; +import { defineTool } from '../../../src'; + +export default defineTool({ + name: 'word_count', + description: 'Count the words in a piece of text', + input: z.object({ text: z.string() }), + execute: ({ text }) => ({ words: text.split(/\s+/).filter(Boolean).length }), +}); diff --git a/llms-full.txt b/llms-full.txt index b9851f1e4..b6c3d3277 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -29,6 +29,7 @@ dashboard — any provider, any deploy target, no lock-in. >>>>>>> origin/main - **Parallel tool calls** — when the model asks for several tools in one turn they run concurrently (cap it with `toolConcurrency`, or `1` for sequential), and results still reach the transcript in the model's call order - **Skills** — `defineSkill()` / `loadSkills('./skills')`: only each skill's name and description sit in the system prompt; the model loads the full markdown on demand through an auto-registered `load_skill` tool +- **Agents as directories** — `loadAgentDir('./my-agent')` turns `instructions.md`, `tools/*.ts`, `skills/` and `subagents//` into the same object `createAgent()` returns, so you can start with files and drop to code later — see [Agent directories](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/agent-directories.md) - **Multi-agent delegation** — wrap a child agent as a tool with `createDelegateTool()`, with a `maxDepth` guard against delegation loops - **Pre/post hooks** — a `HookRegistry` of `AgentHook`s that can inspect or mutate a tool call/LLM generate step, or throw to abort it, run sandboxed by Agent Forge's hook editor - **Guardrails** — fail-closed, concurrently-run checks (secret scan, diff size, test/lint commands) that gate a fixer agent's patch before it's used @@ -256,6 +257,7 @@ console.log(session.id, session.messages.length); - [Evals](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/evals.md) - trajectory evals with `defineEval()`, datasets, `mockModel`, judge evals, `loushy eval` with JUnit/JSON reports - [Sessions](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/sessions.md) - multi-turn conversations: `agent.session()`, `MemorySessionStore`, `FileSessionStore` - [Skills](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/skills.md) - on-demand instructions: `defineSkill()`, `loadSkills()`, how they save context +- [Agent directories](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/agent-directories.md) - define an agent as a folder: layout, mapping to `createAgent()`, moving to code, security - [Agent Forge](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/agent-forge.md) - the visual dashboard (`loushy studio`): quickstart, first-agent walkthrough, hook authoring - Full guides site: [linuxdevil.github.io/agent-sdk-docs](https://linuxdevil.github.io/agent-sdk-docs/) @@ -1704,6 +1706,161 @@ run from the scheduled time (no drift, no double fire), and `stop()` clears it. - `DeploymentAdapter`, `registerAdapter()`, `getAdapter()`, `listAdapters()` - the adapter registry behind `loushy build` (see [Deployment](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/deployment.md)). +# Agent directories + +Source: docs/agent-directories.md + +An agent can be defined as a directory. `loadAgentDir()` reads it and calls +`createAgent()` with the options the files describe, so it returns exactly what +`createAgent()` returns. The typed code API stays the source of truth: a +directory is a different way to write the same options, and you can switch to +code at any time without a rewrite. + +> **Security:** loading an agent directory **executes its code** (`agent.ts`, +> everything in `tools/`). Only load directories you trust. No sandboxing is +> implied; the files run with your process's full permissions. + +```ts +import { loadAgentDir } from '@loushy/build-ai-agent'; + +const agent = await loadAgentDir('./my-agent'); +const { text } = await agent.send('Hello!'); +``` + +## Layout + +```text +my-agent/ + agent.ts | agent.js | agent.json | agent.yaml # optional config + instructions.md # system prompt + tools/*.ts | *.js # defineTool() tools + skills/ # same layouts as loadSkills() + subagents// # nested agent directories +``` + +Only one config file may exist. Files inside `tools/` and the entries of +`skills/` and `subagents/` are read in sorted order, so loading is +deterministic. + +### Config file + +```ts +// agent.ts +export default { + model: 'openai/gpt-4o-mini', + description: 'Triages bug reports', + maxSteps: 8, + toolConcurrency: 2, +}; +``` + +| Key | Maps to | +| --- | ------- | +| `name` | `createAgent({ name })` (default: the directory name) | +| `description` | not a `createAgent` option; what a parent agent reads for a sub-agent (required there) | +| `model` | `createAgent({ model })` | +| `provider` | `createAgent({ provider })` (code config files only) | +| `instructions` | `createAgent({ instructions })` (use this or `instructions.md`, not both) | +| `maxSteps` | `createAgent({ maxSteps })` | +| `toolConcurrency` | `createAgent({ toolConcurrency })` | +| `projectInstructions` | `createAgent({ projectInstructions })` | + +Unknown keys are an error with a suggestion (`'modle' (did you mean 'model'?)`). + +### Tools + +Each file in `tools/` default-exports a `defineTool()` tool, and may also export +more tools by name (or an array of tools). Other exports are ignored, but a file +that exports no tool at all is an error, as are two tools with the same name +(both files are named in the message). + +```ts no-verify +// tools/send_email.ts +import { z } from 'zod'; +import { defineTool } from '@loushy/build-ai-agent'; + +export default defineTool({ + name: 'send_email', + description: 'Send an email', + input: z.object({ to: z.string(), body: z.string() }), + execute: async ({ to, body }) => ({ sent: true, to, length: body.length }), +}); +``` + +### Skills + +`skills/` accepts what `loadSkills()` accepts: `skills//SKILL.md` and +`skills/.md`, each with a `description` in its frontmatter. See +[Skills](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/skills.md). + +### Sub-agents + +Every directory in `subagents/` is itself an agent directory (and can have its +own `subagents/`). It must have a `description` in its config. The parent gets a +`delegate_to_` tool that runs the sub-agent with the task text and returns +its final answer. A sub-agent uses its own `model` if it sets one, otherwise it +inherits the parent's; a `provider` override passed to `loadAgentDir()` reaches +all of them. + +## How it maps to `createAgent()` + +| Directory | `createAgent()` option | +| --------- | ---------------------- | +| `instructions.md` | `instructions` | +| `agent.*` keys | the options in the table above | +| `tools/` | `tools: [...]` | +| `skills/` | `skills: await loadSkills('./skills')` | +| `subagents//` | one `delegate_to_` entry in `tools` | + +`resolveAgentDir()` returns the assembled options and a manifest, which is +handy for tests and tooling: + +```ts +import { resolveAgentDir, createAgent } from '@loushy/build-ai-agent'; + +const { config, manifest } = await resolveAgentDir('./my-agent'); +console.log(manifest.tools, manifest.skills, manifest.subagents); +const agent = createAgent({ ...config, maxSteps: 3 }); +``` + +## Overrides + +The second argument takes the same options as `createAgent()` and wins over the +files. Use it to swap the model in tests: + +```ts +import { loadAgentDir } from '@loushy/build-ai-agent'; +import { mockModel } from '@loushy/build-ai-agent/testing'; + +const agent = await loadAgentDir('./my-agent', { provider: mockModel(['Hi there.']) }); +``` + +`tools` and `skills` overrides replace the discovered ones (they are not +merged). A `provider/model` string in a file is dropped when you override +`provider`, so the instance is used as-is. + +## Moving from files to code + +Call `resolveAgentDir()`, print `config`, and paste what you need into a +`createAgent()` call; or load the directory and override just the parts you want +to own in code. Tools are plain `defineTool()` values, so a tool file can be +imported from code unchanged. + +## Loading TypeScript + +`.ts` files are loaded with a dynamic `import()`, so the process must already +run under a TypeScript loader: `npx tsx your-script.ts` (or ts-node, bun, deno, +or Node's built-in type stripping). Without one, loading fails with an error that +says so. Compile the directory first, or use `.js`/`.mjs` tools with an +`agent.json` / `agent.yaml` config, which work everywhere. A `.js` tool using +`import` syntax needs `"type": "module"` in the nearest `package.json` (or the +`.mjs` extension). + +## What is not covered + +`loushy dev` and `loushy mcp` still take an agent spec file +([Configuration](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/configuration.md)), not a directory. + # Agent Forge Source: docs/agent-forge.md diff --git a/llms.txt b/llms.txt index 0c9fc3fa1..5b8b7f742 100644 --- a/llms.txt +++ b/llms.txt @@ -8,6 +8,7 @@ - [Installation](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/installation.md): `ai` (the Vercel AI SDK, `^4.3.19`) and `zod` (`^3.25.76`) are required peer dependencies. - [Quick Start](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/quick-start.md): Every TypeScript snippet on this page is a complete, standalone ES module (`.mts`). - [API Overview](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/api-overview.md): The package root (`@loushy/build-ai-agent`) exports everything below. +- [Agent directories](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/agent-directories.md): An agent can be defined as a directory. - [Configuration](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/configuration.md): A declarative agent is a YAML (`.yaml`/`.yml`) or JSON (`.json`) file, validated with zod by `loadSpec()` (`src/spec/schema.ts`). - [Evals](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/evals.md): Evals are regression tests for agent *behaviour*: which tools the agent called, in what order, with which arguments, how many steps it took, and what it said. - [Tracing and observability](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/observability.md): `AgentExecutor.execute()` and `FlowExecutor.execute()` emit spans through a `TraceExporter`. @@ -19,6 +20,7 @@ ## Examples +- [agent-dir](https://github.com/LinuxDevil/agent-sdk/blob/main/examples/agent-dir/README.md): An agent defined as a directory: `instructions.md` is the system prompt, `tools/word_count.ts` is a `defineTool()` tool, `skills/tone.md` is a skill and `agent.json` holds config. - [doc-qa](https://github.com/LinuxDevil/agent-sdk/blob/main/examples/doc-qa/README.md): A question-answering agent scoped to a single fixed document (a short refund policy, inlined in the prompt), built with `createAgent()`. - [openrouter](https://github.com/LinuxDevil/agent-sdk/blob/main/examples/openrouter/README.md): Runnable snippets showing `OpenRouterProvider` usage (LOU-B6): basic and streaming generation, multi-model comparison, tool calling, `AgentBuilder` configuration, model listing, cost-aware model choice, error handling, multi-turn conversation, and capability checks. - [ops-pipeline (LOU-J8)](https://github.com/LinuxDevil/agent-sdk/blob/main/examples/ops-pipeline/README.md): An end-to-end "flagship pipeline" demo composing every LOU-J primitive diff --git a/package.json b/package.json index da475688c..82bbc9486 100644 --- a/package.json +++ b/package.json @@ -105,6 +105,7 @@ "test:types": "tsc -p tsconfig.test-d.json && vitest run --typecheck.only", "example:tracing:console": "tsx examples/tracing/run-console.ts", "example:tracing:otel": "tsx examples/tracing/run-otel.ts", + "example:agent-dir": "tsx examples/agent-dir/index.ts", "example:support-bot": "tsx examples/support-bot/index.ts", "example:research-assistant": "tsx examples/research-assistant/index.ts", "example:workflow-router": "tsx examples/workflow-router/index.ts", diff --git a/src/agentDir/__fixtures__/err-bad-json/agent.json b/src/agentDir/__fixtures__/err-bad-json/agent.json new file mode 100644 index 000000000..121ac7eca --- /dev/null +++ b/src/agentDir/__fixtures__/err-bad-json/agent.json @@ -0,0 +1 @@ +{ "instructions": "x", } diff --git a/src/agentDir/__fixtures__/err-bad-tool/instructions.md b/src/agentDir/__fixtures__/err-bad-tool/instructions.md new file mode 100644 index 000000000..f5745cc79 --- /dev/null +++ b/src/agentDir/__fixtures__/err-bad-tool/instructions.md @@ -0,0 +1 @@ +Instructions. diff --git a/src/agentDir/__fixtures__/err-bad-tool/tools/nothing.ts b/src/agentDir/__fixtures__/err-bad-tool/tools/nothing.ts new file mode 100644 index 000000000..82ae11d82 --- /dev/null +++ b/src/agentDir/__fixtures__/err-bad-tool/tools/nothing.ts @@ -0,0 +1,2 @@ +export default { name: 'looks-like-a-tool-but-is-not' }; +export const helper = 42; diff --git a/src/agentDir/__fixtures__/err-double-instructions/agent.json b/src/agentDir/__fixtures__/err-double-instructions/agent.json new file mode 100644 index 000000000..0c68f360c --- /dev/null +++ b/src/agentDir/__fixtures__/err-double-instructions/agent.json @@ -0,0 +1 @@ +{ "instructions": "from the config" } diff --git a/src/agentDir/__fixtures__/err-double-instructions/instructions.md b/src/agentDir/__fixtures__/err-double-instructions/instructions.md new file mode 100644 index 000000000..c99e6532d --- /dev/null +++ b/src/agentDir/__fixtures__/err-double-instructions/instructions.md @@ -0,0 +1 @@ +From the file. diff --git a/src/agentDir/__fixtures__/err-dup-tools/instructions.md b/src/agentDir/__fixtures__/err-dup-tools/instructions.md new file mode 100644 index 000000000..f5745cc79 --- /dev/null +++ b/src/agentDir/__fixtures__/err-dup-tools/instructions.md @@ -0,0 +1 @@ +Instructions. diff --git a/src/agentDir/__fixtures__/err-dup-tools/tools/one.ts b/src/agentDir/__fixtures__/err-dup-tools/tools/one.ts new file mode 100644 index 000000000..8f6731f3e --- /dev/null +++ b/src/agentDir/__fixtures__/err-dup-tools/tools/one.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; +import { defineTool } from '../../../../tools/defineTool'; + +export default defineTool({ + name: 'same_name', + description: 'First', + input: z.object({}), + execute: () => 1, +}); diff --git a/src/agentDir/__fixtures__/err-dup-tools/tools/two.ts b/src/agentDir/__fixtures__/err-dup-tools/tools/two.ts new file mode 100644 index 000000000..12bf66f4c --- /dev/null +++ b/src/agentDir/__fixtures__/err-dup-tools/tools/two.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; +import { defineTool } from '../../../../tools/defineTool'; + +export default defineTool({ + name: 'same_name', + description: 'Second', + input: z.object({}), + execute: () => 2, +}); diff --git a/src/agentDir/__fixtures__/err-no-instructions/agent.json b/src/agentDir/__fixtures__/err-no-instructions/agent.json new file mode 100644 index 000000000..b9562ccd6 --- /dev/null +++ b/src/agentDir/__fixtures__/err-no-instructions/agent.json @@ -0,0 +1 @@ +{ "description": "has a config but no instructions" } diff --git a/src/agentDir/__fixtures__/err-subagent-no-description/instructions.md b/src/agentDir/__fixtures__/err-subagent-no-description/instructions.md new file mode 100644 index 000000000..b7eefd50a --- /dev/null +++ b/src/agentDir/__fixtures__/err-subagent-no-description/instructions.md @@ -0,0 +1 @@ +Parent. diff --git a/src/agentDir/__fixtures__/err-subagent-no-description/subagents/helper/instructions.md b/src/agentDir/__fixtures__/err-subagent-no-description/subagents/helper/instructions.md new file mode 100644 index 000000000..faab3c38d --- /dev/null +++ b/src/agentDir/__fixtures__/err-subagent-no-description/subagents/helper/instructions.md @@ -0,0 +1 @@ +Helper. diff --git a/src/agentDir/__fixtures__/err-two-configs/agent.json b/src/agentDir/__fixtures__/err-two-configs/agent.json new file mode 100644 index 000000000..e59843c74 --- /dev/null +++ b/src/agentDir/__fixtures__/err-two-configs/agent.json @@ -0,0 +1 @@ +{ "instructions": "x" } diff --git a/src/agentDir/__fixtures__/err-two-configs/agent.yaml b/src/agentDir/__fixtures__/err-two-configs/agent.yaml new file mode 100644 index 000000000..c92395a01 --- /dev/null +++ b/src/agentDir/__fixtures__/err-two-configs/agent.yaml @@ -0,0 +1 @@ +instructions: x diff --git a/src/agentDir/__fixtures__/err-unknown-key/agent.json b/src/agentDir/__fixtures__/err-unknown-key/agent.json new file mode 100644 index 000000000..39baef328 --- /dev/null +++ b/src/agentDir/__fixtures__/err-unknown-key/agent.json @@ -0,0 +1 @@ +{ "modle": "openai/gpt-4o-mini", "instructions": "x", "prompt2": 1 } diff --git a/src/agentDir/__fixtures__/full/agent.ts b/src/agentDir/__fixtures__/full/agent.ts new file mode 100644 index 000000000..bcfe946e8 --- /dev/null +++ b/src/agentDir/__fixtures__/full/agent.ts @@ -0,0 +1,5 @@ +export default { + description: 'Fixture agent covering every layout', + maxSteps: 4, + toolConcurrency: 1, +}; diff --git a/src/agentDir/__fixtures__/full/instructions.md b/src/agentDir/__fixtures__/full/instructions.md new file mode 100644 index 000000000..ad6e7f24e --- /dev/null +++ b/src/agentDir/__fixtures__/full/instructions.md @@ -0,0 +1,2 @@ + +You are the fixture agent. Use your tools and skills. diff --git a/src/agentDir/__fixtures__/full/skills/changelog.md b/src/agentDir/__fixtures__/full/skills/changelog.md new file mode 100644 index 000000000..c2cb40794 --- /dev/null +++ b/src/agentDir/__fixtures__/full/skills/changelog.md @@ -0,0 +1,4 @@ +--- +description: How to write a changelog entry +--- +Use the imperative mood and link the PR. diff --git a/src/agentDir/__fixtures__/full/skills/release/SKILL.md b/src/agentDir/__fixtures__/full/skills/release/SKILL.md new file mode 100644 index 000000000..52c5214e5 --- /dev/null +++ b/src/agentDir/__fixtures__/full/skills/release/SKILL.md @@ -0,0 +1,5 @@ +--- +description: Steps for cutting a release +--- +1. Bump the version. +2. Tag it. diff --git a/src/agentDir/__fixtures__/full/subagents/researcher/agent.json b/src/agentDir/__fixtures__/full/subagents/researcher/agent.json new file mode 100644 index 000000000..cd478e631 --- /dev/null +++ b/src/agentDir/__fixtures__/full/subagents/researcher/agent.json @@ -0,0 +1 @@ +{ "description": "Looks facts up" } diff --git a/src/agentDir/__fixtures__/full/subagents/researcher/instructions.md b/src/agentDir/__fixtures__/full/subagents/researcher/instructions.md new file mode 100644 index 000000000..5ab683cf2 --- /dev/null +++ b/src/agentDir/__fixtures__/full/subagents/researcher/instructions.md @@ -0,0 +1 @@ +You are the researcher. Look things up with your tool. diff --git a/src/agentDir/__fixtures__/full/subagents/researcher/tools/lookup.ts b/src/agentDir/__fixtures__/full/subagents/researcher/tools/lookup.ts new file mode 100644 index 000000000..3c60d417b --- /dev/null +++ b/src/agentDir/__fixtures__/full/subagents/researcher/tools/lookup.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; +import { defineTool } from '../../../../../../tools/defineTool'; + +export default defineTool({ + name: 'lookup', + description: 'Look up a fact', + input: z.object({ topic: z.string() }), + execute: ({ topic }) => `fact about ${topic}`, +}); diff --git a/src/agentDir/__fixtures__/full/tools/a_echo.ts b/src/agentDir/__fixtures__/full/tools/a_echo.ts new file mode 100644 index 000000000..4242add16 --- /dev/null +++ b/src/agentDir/__fixtures__/full/tools/a_echo.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; +import { defineTool } from '../../../../tools/defineTool'; + +export default defineTool({ + name: 'echo', + description: 'Echo the text back', + input: z.object({ text: z.string() }), + execute: ({ text }) => `echo: ${text}`, +}); diff --git a/src/agentDir/__fixtures__/full/tools/b_math.ts b/src/agentDir/__fixtures__/full/tools/b_math.ts new file mode 100644 index 000000000..aa5d19cb1 --- /dev/null +++ b/src/agentDir/__fixtures__/full/tools/b_math.ts @@ -0,0 +1,18 @@ +import { z } from 'zod'; +import { defineTool } from '../../../../tools/defineTool'; + +export const add = defineTool({ + name: 'add', + description: 'Add two numbers', + input: z.object({ a: z.number(), b: z.number() }), + execute: ({ a, b }) => a + b, +}); + +export const double = defineTool({ + name: 'double', + description: 'Double a number', + input: z.object({ n: z.number() }), + execute: ({ n }) => n * 2, +}); + +export const notATool = 'helpers can live in the same file'; diff --git a/src/agentDir/__fixtures__/js-json/agent.json b/src/agentDir/__fixtures__/js-json/agent.json new file mode 100644 index 000000000..655ccce92 --- /dev/null +++ b/src/agentDir/__fixtures__/js-json/agent.json @@ -0,0 +1 @@ +{ "name": "js-json-agent", "description": "JS tool, JSON config", "maxSteps": 3 } diff --git a/src/agentDir/__fixtures__/js-json/instructions.md b/src/agentDir/__fixtures__/js-json/instructions.md new file mode 100644 index 000000000..34da01f94 --- /dev/null +++ b/src/agentDir/__fixtures__/js-json/instructions.md @@ -0,0 +1 @@ +You are the JS fixture agent. diff --git a/src/agentDir/__fixtures__/js-json/tools/ping.js b/src/agentDir/__fixtures__/js-json/tools/ping.js new file mode 100644 index 000000000..449746006 --- /dev/null +++ b/src/agentDir/__fixtures__/js-json/tools/ping.js @@ -0,0 +1,9 @@ +import { z } from 'zod'; +import { defineTool } from '../../../../tools/defineTool'; + +export default defineTool({ + name: 'ping', + description: 'Reply with pong', + input: z.object({}), + execute: () => 'pong', +}); diff --git a/src/agentDir/__fixtures__/yaml-inline/agent.yaml b/src/agentDir/__fixtures__/yaml-inline/agent.yaml new file mode 100644 index 000000000..b4bd5cb5f --- /dev/null +++ b/src/agentDir/__fixtures__/yaml-inline/agent.yaml @@ -0,0 +1,3 @@ +description: Instructions live in the config, no other files +instructions: You are the YAML fixture agent. +maxSteps: 2 diff --git a/src/agentDir/closest.ts b/src/agentDir/closest.ts new file mode 100644 index 000000000..c937b700d --- /dev/null +++ b/src/agentDir/closest.ts @@ -0,0 +1,40 @@ +/** Edit distance between two short strings (single-row dynamic programming). */ +function distance(a: string, b: string): number { + const row = Array.from({ length: b.length + 1 }, (_, j) => j); + for (let i = 1; i <= a.length; i++) { + let diagonal = row[0]; + row[0] = i; + for (let j = 1; j <= b.length; j++) { + const above = row[j]; + row[j] = Math.min(above + 1, row[j - 1] + 1, diagonal + (a[i - 1] === b[j - 1] ? 0 : 1)); + diagonal = above; + } + } + return row[b.length]; +} + +/** Words people reach for that mean one of our keys but are far from it in spelling. */ +const SYNONYMS: Readonly> = { + prompt: 'instructions', + systemprompt: 'instructions', + system: 'instructions', + steps: 'maxSteps', + concurrency: 'toolConcurrency', +}; + +/** The candidate that `word` is most plausibly a typo (or synonym) of, if any. */ +export function closest(word: string, candidates: readonly string[]): string | undefined { + const lower = word.toLowerCase(); + const synonym = SYNONYMS[lower]; + if (synonym && candidates.includes(synonym)) return synonym; + let best: string | undefined; + let bestDistance = Infinity; + for (const candidate of candidates) { + const d = distance(lower, candidate.toLowerCase()); + if (d < bestDistance) { + best = candidate; + bestDistance = d; + } + } + return bestDistance <= Math.max(2, Math.floor(lower.length / 3)) ? best : undefined; +} diff --git a/src/agentDir/fsUtil.ts b/src/agentDir/fsUtil.ts new file mode 100644 index 000000000..a10aba7a8 --- /dev/null +++ b/src/agentDir/fsUtil.ts @@ -0,0 +1,44 @@ +import { promises as fs } from 'node:fs'; + +/** Names directly inside `dir` that satisfy `keep`, sorted; empty when `dir` does not exist. */ +export async function listSorted( + dir: string, + keep: (entry: { name: string; isFile: boolean; isDirectory: boolean }) => boolean +): Promise { + let entries; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw new Error(`loadAgentDir: cannot read directory '${dir}' (${(error as Error).message}).`); + } + return entries + .filter((e) => keep({ name: e.name, isFile: e.isFile(), isDirectory: e.isDirectory() })) + .map((e) => e.name) + .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); +} + +/** True when `file` exists and is a regular file. */ +export async function isFile(file: string): Promise { + return fs.stat(file).then( + (s) => s.isFile(), + () => false + ); +} + +/** True when `dir` exists and is a directory. */ +export async function isDirectory(dir: string): Promise { + return fs.stat(dir).then( + (s) => s.isDirectory(), + () => false + ); +} + +/** Reads a UTF-8 file, rethrowing failures with the path in the message. */ +export async function readText(file: string): Promise { + try { + return await fs.readFile(file, 'utf8'); + } catch (error) { + throw new Error(`loadAgentDir: cannot read ${file} (${(error as Error).message}).`); + } +} diff --git a/src/agentDir/importModule.ts b/src/agentDir/importModule.ts new file mode 100644 index 000000000..890fb10bd --- /dev/null +++ b/src/agentDir/importModule.ts @@ -0,0 +1,36 @@ +import { pathToFileURL } from 'node:url'; + +/** Node error codes that mean "this process cannot load a TypeScript file". */ +const NO_TS_LOADER_CODES = new Set(['ERR_UNKNOWN_FILE_EXTENSION', 'ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX']); + +const TS_FILE = /\.[cm]?ts$/; + +/** + * Turns a failed `import()` into an error that names the file and, for a + * `.ts` file in a process with no TypeScript loader, says how to fix it. + */ +export function explainImportError(file: string, error: unknown): Error { + const cause = error as NodeJS.ErrnoException; + if (TS_FILE.test(file) && cause.code !== undefined && NO_TS_LOADER_CODES.has(cause.code)) { + return new Error( + `loadAgentDir: cannot import ${file}: this process cannot load TypeScript files (${cause.code}). ` + + "Run your script under a TypeScript loader, e.g. 'npx tsx your-script.ts' (also works with " + + "ts-node, bun, deno, or Node's --experimental-strip-types), or compile the agent directory to " + + 'JavaScript first and load the compiled output. Alternatively write this file as .js/.mjs, or ' + + 'use agent.json / agent.yaml for config.', + { cause: error } + ); + } + return new Error(`loadAgentDir: failed to import ${file}: ${cause.message ?? String(error)}`, { + cause: error, + }); +} + +/** Dynamically imports a user file, with path-bearing errors (see {@link explainImportError}). */ +export async function importModule(file: string): Promise> { + try { + return (await import(pathToFileURL(file).href)) as Record; + } catch (error) { + throw explainImportError(file, error); + } +} diff --git a/src/agentDir/index.ts b/src/agentDir/index.ts new file mode 100644 index 000000000..64852b1ad --- /dev/null +++ b/src/agentDir/index.ts @@ -0,0 +1,7 @@ +export { loadAgentDir, resolveAgentDir } from './loadAgentDir'; +export type { + AgentDirConfig, + AgentDirManifest, + AgentDirOverrides, + ResolvedAgentDir, +} from './loadAgentDir'; diff --git a/src/agentDir/loadAgentDir.test.ts b/src/agentDir/loadAgentDir.test.ts new file mode 100644 index 000000000..9e7eaaccc --- /dev/null +++ b/src/agentDir/loadAgentDir.test.ts @@ -0,0 +1,231 @@ +import { describe, it, expect } from 'vitest'; +import path from 'node:path'; +import { z } from 'zod'; +import { loadAgentDir, resolveAgentDir } from './index'; +import { defineTool } from '../tools/defineTool'; +import { mockModel } from '../testing'; +import { explainImportError } from './importModule'; +import { closest } from './closest'; + +const fixture = (name: string): string => path.join(__dirname, '__fixtures__', name); +const systemOf = (call: { messages: readonly { role: string; content: unknown }[] }): string => + String(call.messages.find((m) => m.role === 'system')?.content); +const toolMessages = (call: { messages: readonly { role: string; content: unknown }[] }): string[] => + call.messages.filter((m) => m.role === 'tool').map((m) => String(m.content)); + +describe('resolveAgentDir', () => { + it('discovers every layout: ts config, ts tools (default + named), skills (both layouts), subagents', async () => { + const { config, manifest } = await resolveAgentDir(fixture('full'), { provider: mockModel(['x']) }); + + expect(manifest.name).toBe('full'); + expect(manifest.description).toBe('Fixture agent covering every layout'); + expect(manifest.tools).toEqual(['echo', 'add', 'double']); + expect(manifest.skills).toEqual(['changelog', 'release']); + expect(manifest.subagents).toEqual(['researcher']); + expect(manifest.files.map((f) => path.relative(fixture('full'), f).split(path.sep).join('/'))).toEqual([ + 'agent.ts', + 'instructions.md', + 'tools/a_echo.ts', + 'tools/b_math.ts', + ]); + expect(config.instructions).toBe('You are the fixture agent. Use your tools and skills.'); + expect(config.maxSteps).toBe(4); + expect(config.toolConcurrency).toBe(1); + expect(Array.isArray(config.tools) && config.tools.map((t) => t.name)).toEqual([ + 'echo', + 'add', + 'double', + 'delegate_to_researcher', + ]); + }); + + it('loads .js tools with a .json config', async () => { + const { config, manifest } = await resolveAgentDir(fixture('js-json'), { provider: mockModel(['x']) }); + expect(manifest.tools).toEqual(['ping']); + expect(config.name).toBe('js-json-agent'); + expect(config.maxSteps).toBe(3); + }); + + it('loads a .yaml config with inline instructions and no other files', async () => { + const { config, manifest } = await resolveAgentDir(fixture('yaml-inline')); + expect(config.instructions).toBe('You are the YAML fixture agent.'); + expect(config.maxSteps).toBe(2); + expect(config.tools).toBeUndefined(); + expect(config.skills).toBeUndefined(); + expect(manifest).toMatchObject({ tools: [], skills: [], subagents: [] }); + expect(manifest.files).toHaveLength(1); + }); + + it('lets overrides win over files', async () => { + const provider = mockModel(['x']); + const { config } = await resolveAgentDir(fixture('js-json'), { + provider, + name: 'renamed', + instructions: 'override prompt', + maxSteps: 9, + }); + expect(config).toMatchObject({ name: 'renamed', instructions: 'override prompt', maxSteps: 9, provider }); + const viaAlias = await resolveAgentDir(fixture('js-json'), { prompt: 'alias prompt' }); + expect(viaAlias.config.instructions).toBe('alias prompt'); + }); + + it('drops a file-level provider/model string when a provider instance is overridden', async () => { + const provider = mockModel(['x']); + const tweaked = await resolveAgentDir(fixture('full'), { provider, model: 'bare-model-id' }); + expect(tweaked.config).toMatchObject({ provider, model: 'bare-model-id' }); + }); + + it('replaces discovered tools and skills when overridden, without reading them', async () => { + const mine = defineTool({ name: 'mine', description: 'd', input: z.object({}), execute: () => 1 }); + const { config } = await resolveAgentDir(fixture('full'), { + provider: mockModel(['x']), + tools: [mine], + skills: [], + }); + expect(config.tools).toEqual([mine]); + expect(config.skills).toEqual([]); + }); + + it('rejects a path that is not a directory', async () => { + await expect(resolveAgentDir(path.join(fixture('full'), 'instructions.md'))).rejects.toThrow( + /is not a directory/ + ); + }); +}); + +describe('loadAgentDir', () => { + it('returns a working createAgent() agent whose disk tools and skills are callable', async () => { + const model = mockModel([ + { + toolCalls: [ + { name: 'echo', args: { text: 'hi' } }, + { name: 'add', args: { a: 2, b: 3 } }, + { name: 'load_skill', args: { name: 'release' } }, + ], + }, + 'All done.', + ]); + const agent = await loadAgentDir(fixture('full'), { provider: model }); + + const result = await agent.send('go'); + + expect(result.text).toBe('All done.'); + const first = model.calls[0]; + expect(systemOf(first)).toContain('You are the fixture agent.'); + expect(systemOf(first)).toContain('- changelog: How to write a changelog entry'); + expect(systemOf(first)).not.toContain('Bump the version'); + expect(first.tools?.map((t) => t.function.name).sort()).toEqual([ + 'add', + 'delegate_to_researcher', + 'double', + 'echo', + 'load_skill', + ]); + const outputs = toolMessages(model.calls[1]).join('\n'); + expect(outputs).toContain('echo: hi'); + expect(outputs).toContain('5'); + expect(outputs).toContain('Bump the version'); + }); + + it('runs a .js tool', async () => { + const model = mockModel([{ toolCalls: [{ name: 'ping' }] }, 'ok']); + const agent = await loadAgentDir(fixture('js-json'), { provider: model }); + await agent.send('ping it'); + expect(toolMessages(model.calls[1]).join('')).toContain('pong'); + }); + + it('delegates to a sub-agent directory, which uses its own tools and inherits the provider override', async () => { + const model = mockModel([ + { toolCalls: [{ name: 'delegate_to_researcher', args: { task: 'find cats' } }] }, + { toolCalls: [{ name: 'lookup', args: { topic: 'cats' } }] }, + 'Cats are great.', + 'The researcher says cats are great.', + ]); + const agent = await loadAgentDir(fixture('full'), { provider: model }); + + const result = await agent.send('research cats'); + + expect(result.text).toBe('The researcher says cats are great.'); + const parentTool = model.calls[0].tools?.find((t) => t.function.name === 'delegate_to_researcher'); + expect(parentTool?.function.description).toContain('Looks facts up'); + expect(systemOf(model.calls[1])).toContain('You are the researcher.'); + expect(toolMessages(model.calls[2]).join('')).toContain('fact about cats'); + expect(toolMessages(model.calls[3]).join('')).toContain('Cats are great.'); + }); +}); + +describe('validation errors', () => { + it('names the directory when instructions are missing', async () => { + const dir = fixture('err-no-instructions'); + const error = await resolveAgentDir(dir).catch((e: Error) => e); + expect((error as Error).message).toContain(dir); + expect((error as Error).message).toContain(path.join(dir, 'instructions.md')); + expect((error as Error).message).toMatch(/has no instructions/); + }); + + it('says what a tools file exported, and shows a defineTool example', async () => { + const file = path.join(fixture('err-bad-tool'), 'tools', 'nothing.ts'); + const error = (await resolveAgentDir(fixture('err-bad-tool')).catch((e: Error) => e)) as Error; + expect(error.message).toContain(file); + expect(error.message).toContain('default: object, helper: number'); + expect(error.message).toContain('defineTool({ name:'); + }); + + it('names both files for a duplicate tool name', async () => { + const dir = path.join(fixture('err-dup-tools'), 'tools'); + const error = (await resolveAgentDir(fixture('err-dup-tools')).catch((e: Error) => e)) as Error; + expect(error.message).toContain("duplicate tool name 'same_name'"); + expect(error.message).toContain(path.join(dir, 'one.ts')); + expect(error.message).toContain(path.join(dir, 'two.ts')); + }); + + it('suggests the right key for unknown config keys', async () => { + const file = path.join(fixture('err-unknown-key'), 'agent.json'); + const error = (await resolveAgentDir(fixture('err-unknown-key')).catch((e: Error) => e)) as Error; + expect(error.message).toContain(file); + expect(error.message).toContain("'modle' (did you mean 'model'?)"); + expect(error.message).toContain("'prompt2'"); + expect(error.message).toContain('Allowed keys:'); + }); + + it('requires a description on a sub-agent directory', async () => { + const dir = path.join(fixture('err-subagent-no-description'), 'subagents', 'helper'); + const error = (await resolveAgentDir(fixture('err-subagent-no-description'), { + provider: mockModel(['x']), + }).catch((e: Error) => e)) as Error; + expect(error.message).toContain(dir); + expect(error.message).toContain("needs a 'description'"); + }); + + it('rejects two config files, double instructions and invalid JSON, each naming the path', async () => { + await expect(resolveAgentDir(fixture('err-two-configs'))).rejects.toThrow( + /more than one config file \(agent\.json, agent\.yaml\)/ + ); + await expect(resolveAgentDir(fixture('err-double-instructions'))).rejects.toThrow( + /instructions are given twice/ + ); + await expect(resolveAgentDir(fixture('err-bad-json'))).rejects.toThrow( + new RegExp(`${path.join(fixture('err-bad-json'), 'agent.json').replace(/\\/g, '\\\\')}: invalid JSON`) + ); + }); +}); + +describe('helpers', () => { + it('explains a missing TypeScript loader with the fix, only for .ts files', () => { + const cause = Object.assign(new Error('Unknown file extension ".ts"'), { + code: 'ERR_UNKNOWN_FILE_EXTENSION', + }); + const ts = explainImportError('/x/tools/a.ts', cause); + expect(ts.message).toContain('/x/tools/a.ts'); + expect(ts.message).toContain('npx tsx your-script.ts'); + expect(ts.message).toContain('compile'); + expect(explainImportError('/x/tools/a.js', cause).message).toContain('failed to import /x/tools/a.js'); + expect(explainImportError('/x/a.ts', new Error('boom')).message).toContain('failed to import /x/a.ts: boom'); + }); + + it('suggests close keys and nothing for distant ones', () => { + expect(closest('maxStep', ['maxSteps', 'name'])).toBe('maxSteps'); + expect(closest('prompt', ['instructions', 'name'])).toBe('instructions'); + expect(closest('zzzzzzzz', ['maxSteps', 'name'])).toBeUndefined(); + }); +}); diff --git a/src/agentDir/loadAgentDir.ts b/src/agentDir/loadAgentDir.ts new file mode 100644 index 000000000..d8821dac5 --- /dev/null +++ b/src/agentDir/loadAgentDir.ts @@ -0,0 +1,252 @@ +/** + * Filesystem agent loader (LOU-Y5): define an agent as a directory. + * + * The typed code API stays the source of truth. A directory is just another + * way to produce the `createAgent()` options, so `loadAgentDir()` assembles + * them and calls `createAgent()` - there is no second implementation. + * + * SECURITY: loading a directory executes its code (`agent.ts`, `tools/*`). + * Only load directories you trust. + */ +import path from 'node:path'; +import { createAgent, type CreateAgentConfig, type SimpleAgent } from '../createAgent'; +import type { LLMProvider } from '../providers/llm'; +import { loadSkills } from '../skills/loadSkills'; +import type { Skill } from '../skills/defineSkill'; +import { isDirectory, isFile, readText } from './fsUtil'; +import { loadTools, type LoadedTool } from './loadTools'; +import { readConfig, type AgentDirConfig } from './readConfig'; +import { delegateTool, listSubagentDirs, requireDescription, type LoadedSubagent } from './subagents'; + +export type { AgentDirConfig } from './readConfig'; + +/** + * Options that win over what the directory's files say. Same shape as + * `createAgent()`'s options; `tools` and `skills` replace the discovered ones + * (they are not merged). + * + * @example + * ```ts + * const agent = await loadAgentDir('./my-agent', { provider: mockModel(['hi']) }); + * ``` + */ +export type AgentDirOverrides = CreateAgentConfig; + +/** What `resolveAgentDir()` found, for tooling and tests. Paths are absolute; lists are sorted. */ +export interface AgentDirManifest { + /** The resolved directory. */ + dir: string; + /** The agent's name (config `name`, else the directory name). */ + name: string; + /** The config `description`, if any (required for sub-agents). */ + description?: string; + /** Every config / instructions / tool file that was read (skills are listed by name only). */ + files: string[]; + /** Tool names discovered in `tools/`, in load order. */ + tools: string[]; + /** Skill names discovered in `skills/`. */ + skills: string[]; + /** Sub-agent directory names discovered in `subagents/`. */ + subagents: string[]; +} + +/** The result of {@link resolveAgentDir}: ready-to-use `createAgent()` options plus what was discovered. */ +export interface ResolvedAgentDir { + /** Pass to `createAgent()` to get the agent (this is exactly what `loadAgentDir` does). */ + config: CreateAgentConfig; + manifest: AgentDirManifest; +} + +/** The model source a parent hands down to sub-agents that do not choose their own. */ +interface Inherited { + provider?: LLMProvider; + model?: string; +} + +async function readInstructions(dir: string): Promise<{ file: string; text: string } | undefined> { + const file = path.join(dir, 'instructions.md'); + if (!(await isFile(file))) return undefined; + const text = (await readText(file)).trim(); + if (text === '') { + throw new Error(`loadAgentDir: ${file} is empty. Write the agent's system prompt in it.`); + } + return { file, text }; +} + +/** Picks the system prompt: an override, else `instructions.md` xor the config's `instructions`. */ +function chooseInstructions( + dir: string, + config: AgentDirConfig, + fromFile: { file: string; text: string } | undefined, + overrides: AgentDirOverrides +): string { + const override = overrides.instructions ?? overrides.prompt; + if (override !== undefined) return override; + if (fromFile && config.instructions !== undefined) { + throw new Error( + `loadAgentDir: ${fromFile.file}: instructions are given twice - here and as 'instructions' in the config file. ` + + 'Keep one of them.' + ); + } + const text = fromFile?.text ?? config.instructions; + if (text === undefined) { + throw new Error( + `loadAgentDir: ${dir} has no instructions. Create ${path.join(dir, 'instructions.md')} with the ` + + "agent's system prompt, or set 'instructions' in agent.ts / agent.json / agent.yaml." + ); + } + return text; +} + +/** The provider / model to build with. Overrides beat files; a parent's choice is only a fallback. */ +function chooseModelSource( + config: AgentDirConfig, + overrides: AgentDirOverrides, + inherited: Inherited +): Inherited { + let provider = overrides.provider ?? config.provider; + let model = overrides.model ?? config.model; + if (!provider && !model) { + provider = inherited.provider; + model = inherited.model; + } + // A 'provider/model' string from a file names its own provider; it cannot ride along with a provider instance. + if (provider && model?.includes('/') && overrides.model === undefined) model = undefined; + return { provider, model }; +} + +async function loadSkillsIfPresent(dir: string): Promise { + const skillsDir = path.join(dir, 'skills'); + return (await isDirectory(skillsDir)) ? loadSkills(skillsDir) : []; +} + +async function loadSubagents( + dir: string, + overrides: AgentDirOverrides, + inherited: Inherited +): Promise { + const subagents: LoadedSubagent[] = []; + const childOverrides: AgentDirOverrides = overrides.provider ? { provider: overrides.provider } : {}; + for (const name of await listSubagentDirs(dir)) { + const childDir = path.join(dir, 'subagents', name); + const child = await resolveWith(childDir, childOverrides, inherited); + const description = requireDescription(childDir, child.manifest.description); + subagents.push({ name, description, agent: createAgent(child.config) }); + } + return subagents; +} + +function optional(key: K, value: V | undefined): { [P in K]?: V } { + return (value === undefined ? {} : { [key]: value }) as { [P in K]?: V }; +} + +async function resolveWith( + rawDir: string, + overrides: AgentDirOverrides, + inherited: Inherited +): Promise { + const dir = path.resolve(rawDir); + if (!(await isDirectory(dir))) { + throw new Error(`loadAgentDir: '${dir}' is not a directory. Pass the path of an agent directory.`); + } + const { file: configFile, config } = await readConfig(dir); + const fromFile = await readInstructions(dir); + const instructions = chooseInstructions(dir, config, fromFile, overrides); + const source = chooseModelSource(config, overrides, inherited); + const tools: LoadedTool[] = await loadTools(dir); + const skills = await skillsFor(dir, overrides); + const subagents = await loadSubagents(dir, overrides, source); + const name = overrides.name ?? config.name ?? path.basename(dir); + + const fileTools = [...tools.map((t) => t.tool), ...subagents.map(delegateTool)]; + const assembled = { + name, + instructions, + ...optional('provider', source.provider), + ...optional('model', source.model), + ...optional('tools', overrides.tools ?? (fileTools.length > 0 ? fileTools : undefined)), + ...optional('skills', overrides.skills ?? (skills.length > 0 ? skills : undefined)), + ...optional('maxSteps', overrides.maxSteps ?? config.maxSteps), + ...optional('toolConcurrency', overrides.toolConcurrency ?? config.toolConcurrency), + ...optional('projectInstructions', overrides.projectInstructions ?? config.projectInstructions), + } as CreateAgentConfig; + + const files = [...new Set([configFile, fromFile?.file, ...tools.map((t) => t.file)])].filter( + (f): f is string => f !== undefined + ); + return { + config: assembled, + manifest: { + dir, + name, + ...optional('description', config.description), + files, + tools: tools.map((t) => t.tool.name), + skills: skills.map((s) => s.name), + subagents: subagents.map((s) => s.name), + }, + }; +} + +/** Discovered skills, unless the caller overrides them (then the skills directory is not even read). */ +async function skillsFor(dir: string, overrides: AgentDirOverrides): Promise { + return overrides.skills ? [...overrides.skills] : loadSkillsIfPresent(dir); +} + +/** + * Reads an agent directory and returns the `createAgent()` options it + * describes, plus a manifest of what was discovered. The agent itself is not + * created (sub-agents are, since the parent's delegate tools need them); use + * this for tooling, tests, or to tweak the options before calling + * `createAgent()` yourself. + * + * Layout (everything optional except instructions): + * + * ```text + * my-agent/ + * agent.ts | agent.js | agent.json | agent.yaml config: model, description, maxSteps, ... + * instructions.md system prompt + * tools/*.ts|js each exports defineTool() tools + * skills/ same layouts as loadSkills() + * subagents// nested agent directories (need a description) + * ``` + * + * Loading executes the directory's code. Only load directories you trust. + * + * @example + * ```ts + * const { config, manifest } = await resolveAgentDir('./my-agent'); + * console.log(manifest.tools, manifest.skills, manifest.subagents); + * const agent = createAgent({ ...config, maxSteps: 3 }); + * ``` + */ +export async function resolveAgentDir( + dir: string, + overrides: AgentDirOverrides = {} +): Promise { + return resolveWith(dir, overrides, {}); +} + +/** + * Loads an agent defined as a directory and returns what `createAgent()` + * returns. `overrides` take precedence over the directory's files. + * + * Sub-agents (`subagents//`) become `delegate_to_` tools on the + * parent. They inherit the parent's model unless they set their own, and a + * `provider` override is passed down to all of them. + * + * Loading executes the directory's code (`agent.ts`, `tools/*`): only load + * directories you trust. There is no sandboxing. + * + * @example + * ```ts + * const agent = await loadAgentDir('./my-agent'); + * const { text } = await agent.send('Hello!'); + * + * // in tests: swap the model, keep everything else + * const testAgent = await loadAgentDir('./my-agent', { provider: mockModel(['hi']) }); + * ``` + */ +export async function loadAgentDir(dir: string, overrides: AgentDirOverrides = {}): Promise { + return createAgent((await resolveAgentDir(dir, overrides)).config); +} diff --git a/src/agentDir/loadTools.ts b/src/agentDir/loadTools.ts new file mode 100644 index 000000000..d330ec0f2 --- /dev/null +++ b/src/agentDir/loadTools.ts @@ -0,0 +1,74 @@ +import path from 'node:path'; +import { isDefinedTool, type DefinedTool } from '../tools/defineTool'; +import { listSorted } from './fsUtil'; +import { importModule } from './importModule'; + +const TOOL_FILE = /\.[cm]?[jt]s$/; +const NOT_A_TOOL_FILE = /\.d\.[cm]?ts$|\.(?:test|spec)\./; + +/** A tool and the file that exported it. */ +export interface LoadedTool { + tool: DefinedTool; + file: string; +} + +const EXAMPLE = `Expected a tool made with defineTool(), for example: + + import { z } from 'zod'; + import { defineTool } from '@loushy/build-ai-agent'; + export default defineTool({ name: 'ping', description: 'Ping', input: z.object({}), execute: () => 'pong' });`; + +/** Tools found in one exported value: a tool, an array of tools, or (CJS) an exports object of tools. */ +function toolsIn(value: unknown, depth = 0): DefinedTool[] { + if (isDefinedTool(value)) return [value]; + if (Array.isArray(value)) return value.filter(isDefinedTool); + if (depth === 0 && typeof value === 'object' && value !== null) { + return Object.values(value).flatMap((v) => toolsIn(v, 1)); + } + return []; +} + +function describeExports(mod: Record): string { + const names = Object.keys(mod).filter((k) => k !== '__esModule'); + if (names.length === 0) return 'it exports nothing'; + const shown = names.map((n) => `${n}: ${typeof mod[n]}`).join(', '); + return ( + `it exports { ${shown} } but none of these is a defineTool() tool ` + + '(if it is, check the file imports defineTool from the same copy of @loushy/build-ai-agent that loads the directory)' + ); +} + +async function loadToolFile(file: string): Promise { + const mod = await importModule(file); + const exported = [mod.default, ...Object.entries(mod).filter(([k]) => k !== 'default').map(([, v]) => v)]; + const unique = [...new Set(exported.flatMap((v) => toolsIn(v)))]; + if (unique.length === 0) { + throw new Error(`loadAgentDir: ${file}: ${describeExports(mod)}. ${EXAMPLE}`); + } + return unique; +} + +/** + * Loads every `tools/*.{ts,js,mjs,cjs,mts}` file under `dir` (sorted by file + * name). Each file may default-export a tool, and/or export several tools by + * name or as an array. Duplicate tool names across files are an error. + */ +export async function loadTools(dir: string): Promise { + const toolsDir = path.join(dir, 'tools'); + const names = await listSorted(toolsDir, (e) => e.isFile && TOOL_FILE.test(e.name) && !NOT_A_TOOL_FILE.test(e.name)); + const byName = new Map(); + for (const name of names) { + const file = path.join(toolsDir, name); + for (const tool of await loadToolFile(file)) { + const earlier = byName.get(tool.name); + if (earlier) { + throw new Error( + `loadAgentDir: duplicate tool name '${tool.name}' in ${earlier.file} and ${file}. ` + + 'Rename one of the tools - the model addresses tools by name.' + ); + } + byName.set(tool.name, { tool, file }); + } + } + return [...byName.values()]; +} diff --git a/src/agentDir/readConfig.ts b/src/agentDir/readConfig.ts new file mode 100644 index 000000000..d1c3099f8 --- /dev/null +++ b/src/agentDir/readConfig.ts @@ -0,0 +1,176 @@ +import path from 'node:path'; +import { parse as parseYaml } from 'yaml'; +import type { LLMProvider } from '../providers/llm'; +import { assertToolConcurrency, type ToolConcurrency } from '../execution/toolBatch'; +import { closest } from './closest'; +import { isFile, readText } from './fsUtil'; +import { importModule } from './importModule'; + +/** + * The options an agent directory's config file (`agent.ts` / `.js` / `.json` + * / `.yaml`) may set. Everything here is optional; `instructions` may live in + * `instructions.md` instead. `provider` can only be given from a code file. + * + * @example + * ```ts + * // agent.ts + * export default { model: 'openai/gpt-4o-mini', description: 'Triages bugs', maxSteps: 8 }; + * ``` + */ +export interface AgentDirConfig { + /** Agent name; defaults to the directory name. */ + name?: string; + /** What this agent does. Required for agents in `subagents/` (the parent's model reads it). */ + description?: string; + /** `provider/model` string, or a bare model id when `provider` is also given. */ + model?: string; + /** An LLM provider instance (code config files only). */ + provider?: LLMProvider; + /** System prompt. Mutually exclusive with `instructions.md`. */ + instructions?: string; + /** Maximum model/tool round trips per `send()`. */ + maxSteps?: number; + /** How many tool calls from one turn may run at once. */ + toolConcurrency?: ToolConcurrency; + /** Append the nearest AGENTS.md / CLAUDE.md, see `createAgent`'s `projectInstructions`. */ + projectInstructions?: boolean | { cwd?: string; files?: readonly string[] }; +} + +const CONFIG_KEYS = [ + 'name', + 'description', + 'model', + 'provider', + 'instructions', + 'maxSteps', + 'toolConcurrency', + 'projectInstructions', +] as const; + +/** Config file names, in the order they are looked for. At most one may exist. */ +const CONFIG_FILES = [ + 'agent.ts', + 'agent.mts', + 'agent.js', + 'agent.mjs', + 'agent.cjs', + 'agent.json', + 'agent.yaml', + 'agent.yml', +] as const; + +function fail(file: string, message: string): never { + throw new Error(`loadAgentDir: ${file}: ${message}`); +} + +function describeValue(value: unknown): string { + if (value === null) return 'null'; + if (Array.isArray(value)) return 'an array'; + return typeof value === 'object' ? 'an object' : `${typeof value} ${JSON.stringify(value)}`; +} + +function assertKnownKeys(file: string, config: Record): void { + const problems: string[] = []; + for (const key of Object.keys(config)) { + if ((CONFIG_KEYS as readonly string[]).includes(key)) continue; + const suggestion = closest(key, CONFIG_KEYS); + problems.push(`'${key}'${suggestion ? ` (did you mean '${suggestion}'?)` : ''}`); + } + if (problems.length > 0) { + fail(file, `unknown config key ${problems.join(', ')}. Allowed keys: ${CONFIG_KEYS.join(', ')}.`); + } +} + +function assertString(file: string, key: string, value: unknown): void { + if (value !== undefined && (typeof value !== 'string' || value.trim() === '')) { + fail(file, `'${key}' must be a non-empty string, got ${describeValue(value)}.`); + } +} + +function assertMaxSteps(file: string, value: unknown): void { + if (value !== undefined && !(typeof value === 'number' && Number.isInteger(value) && value >= 1)) { + fail(file, `'maxSteps' must be a positive integer, got ${describeValue(value)}.`); + } +} + +function assertProvider(file: string, value: unknown): void { + if (value === undefined) return; + const generate = (value as { generate?: unknown } | null)?.generate; + if (typeof generate !== 'function') { + fail( + file, + `'provider' must be an LLM provider instance (an object with generate()), got ${describeValue(value)}. ` + + "Config files in JSON/YAML can only set 'model' as a 'provider/model' string." + ); + } +} + +function assertProjectInstructions(file: string, value: unknown): void { + const isObject = typeof value === 'object' && value !== null && !Array.isArray(value); + if (value !== undefined && typeof value !== 'boolean' && !isObject) { + fail(file, `'projectInstructions' must be a boolean or { cwd?, files? }, got ${describeValue(value)}.`); + } +} + +function assertValues(file: string, c: Record): void { + for (const key of ['name', 'description', 'model', 'instructions']) assertString(file, key, c[key]); + assertMaxSteps(file, c.maxSteps); + assertProvider(file, c.provider); + assertProjectInstructions(file, c.projectInstructions); + try { + assertToolConcurrency(c.toolConcurrency, 'config'); + } catch (error) { + fail(file, (error as Error).message.replace(/^config: /, '')); + } +} + +function parseText(file: string, kind: 'JSON' | 'YAML', text: string): unknown { + try { + return kind === 'JSON' ? JSON.parse(text) : parseYaml(text); + } catch (error) { + return fail(file, `invalid ${kind} (${(error as Error).message}).`); + } +} + +async function parseConfigFile(file: string): Promise { + const ext = path.extname(file); + if (ext === '.json') return parseText(file, 'JSON', await readText(file)); + if (ext === '.yaml' || ext === '.yml') return parseText(file, 'YAML', await readText(file)); + const mod = await importModule(file); + return 'default' in mod ? mod.default : mod; +} + +/** The config file found in `dir` (or none) and its validated contents. */ +export interface ReadConfigResult { + file: string | undefined; + config: AgentDirConfig; +} + +async function findConfigFile(dir: string): Promise { + const found: string[] = []; + for (const name of CONFIG_FILES) { + const file = path.join(dir, name); + if (await isFile(file)) found.push(file); + } + if (found.length > 1) { + throw new Error( + `loadAgentDir: ${dir} has more than one config file (${found.map((f) => path.basename(f)).join(', ')}). Keep exactly one.` + ); + } + return found[0]; +} + +/** Finds, parses and validates `dir`'s config file. A directory without one has an empty config. */ +export async function readConfig(dir: string): Promise { + const file = await findConfigFile(dir); + if (file === undefined) return { file, config: {} }; + const value = await parseConfigFile(file); + if (value === null || value === undefined) return { file, config: {} }; + if (typeof value !== 'object' || Array.isArray(value)) { + fail(file, `the config must be an object (export default { model: '...' }), got ${describeValue(value)}.`); + } + const config = value as Record; + assertKnownKeys(file, config); + assertValues(file, config); + return { file, config: config as AgentDirConfig }; +} diff --git a/src/agentDir/subagents.ts b/src/agentDir/subagents.ts new file mode 100644 index 000000000..740164ae6 --- /dev/null +++ b/src/agentDir/subagents.ts @@ -0,0 +1,55 @@ +import path from 'node:path'; +import { z } from 'zod'; +import type { SimpleAgent } from '../createAgent'; +import { defineTool, type DefinedTool } from '../tools/defineTool'; +import { listSorted } from './fsUtil'; + +/** A sub-agent directory that was resolved into a running agent. */ +export interface LoadedSubagent { + name: string; + description: string; + agent: SimpleAgent; +} + +const DIR_NAME = /^[a-zA-Z0-9_-]{1,50}$/; + +/** Names of the sub-agent directories under `dir/subagents`, sorted. */ +export async function listSubagentDirs(dir: string): Promise { + const names = await listSorted(path.join(dir, 'subagents'), (e) => e.isDirectory && !e.name.startsWith('.')); + for (const name of names) { + if (!DIR_NAME.test(name)) { + throw new Error( + `loadAgentDir: ${path.join(dir, 'subagents', name)}: sub-agent directory names may only contain ` + + "letters, digits, '_' and '-' (up to 50 characters), because the name becomes a tool name. Rename the directory." + ); + } + } + return names; +} + +/** Returns the description, or throws: the parent model needs it to decide when to delegate. */ +export function requireDescription(subagentDir: string, description: string | undefined): string { + if (description === undefined) { + throw new Error( + `loadAgentDir: ${subagentDir}: a sub-agent needs a 'description' so the parent agent knows when to ` + + 'delegate to it. Add one to its config, e.g. agent.json: { "description": "Reviews pull requests" }.' + ); + } + return description; +} + +/** + * The tool a parent agent calls to hand a task to `subagent`: it runs the + * sub-agent's own `send()` and returns its final text. + * + * Until `createAgent` has a native `subagents` option this is how + * sub-agent directories are wired in. + */ +export function delegateTool(subagent: LoadedSubagent): DefinedTool { + return defineTool({ + name: `delegate_to_${subagent.name}`, + description: `Delegate a task to the '${subagent.name}' agent. ${subagent.description}`, + input: z.object({ task: z.string().describe('The complete task for the agent, with all needed context') }), + execute: async ({ task }, ctx) => (await subagent.agent.send(task, { signal: ctx.abortSignal })).text, + }); +} diff --git a/src/index.ts b/src/index.ts index ddfe59b44..0092e6fa9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -62,6 +62,9 @@ export * from './projectInstructions'; // Skills: progressive disclosure of instructions (LOU-Y2) export * from './skills'; +// Filesystem agent loader: an agent as a directory (LOU-Y5) +export * from './agentDir'; + // Declarative agent spec file format (LOU-H9) export * from './spec';