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
4 changes: 3 additions & 1 deletion .fallowrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>/` 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
Expand Down Expand Up @@ -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/)

Expand Down
152 changes: 152 additions & 0 deletions docs/agent-directories.md
Original file line number Diff line number Diff line change
@@ -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/<name>/ # 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/<name>/SKILL.md` and
`skills/<name>.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_<name>` 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/<name>/` | one `delegate_to_<name>` 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.
4 changes: 4 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
14 changes: 14 additions & 0 deletions examples/agent-dir/README.md
Original file line number Diff line number Diff line change
@@ -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).
4 changes: 4 additions & 0 deletions examples/agent-dir/agent.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"description": "Counts words and suggests tone fixes",
"maxSteps": 5
}
28 changes: 28 additions & 0 deletions examples/agent-dir/index.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
34 changes: 34 additions & 0 deletions examples/agent-dir/index.ts
Original file line number Diff line number Diff line change
@@ -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;
});
3 changes: 3 additions & 0 deletions examples/agent-dir/instructions.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions examples/agent-dir/skills/tone.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions examples/agent-dir/tools/word_count.ts
Original file line number Diff line number Diff line change
@@ -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 }),
});
Loading
Loading