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
31 changes: 22 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ dashboard — any provider, any deploy target, no lock-in.

## Features

- **Zero-config to full control** — `createAgent({ prompt, provider })` in one line, or the full `AgentBuilder` + `AgentExecutor` API when you need `maxSteps`, checkpoints, or tracing hooks
- **Zero-config to full control** — `createAgent({ model: 'openai/gpt-4o-mini' })` in one line, or the full `AgentBuilder` + `AgentExecutor` API when you need `maxSteps`, checkpoints, or tracing hooks
- **Human-in-the-loop** — flag a tool `needsApproval` and pause execution until a human approves or rejects it, then `resumeAfterApproval()` from any process
- **Durable execution** — pass a `sessionId` + `checkpointStore` and a crash mid-conversation resumes instead of restarting
- **Cancellation** — pass an `AbortSignal` (`agent.send(input, { signal })`) to stop a run; it resolves with `finishReason: 'aborted'` and the transcript so far, and the signal reaches the provider, tools and delegated agents
Expand All @@ -34,19 +34,32 @@ dashboard — any provider, any deploy target, no lock-in.

## Quick example

The zero-config path — one function call, no manually-wired executor or
registry:
Hello world in five lines (reads `OPENAI_API_KEY` from your environment):

```typescript
import { createAgent, resolveProvider } from '@loushy/build-ai-agent';
import { createAgent } from '@loushy/build-ai-agent';

const agent = createAgent({ model: 'openai/gpt-4o-mini', instructions: 'You are a helpful assistant.' });
const { text } = await agent.send('Hello!');
console.log(text);
```

`model` is a `provider/model` string (`openai`, `anthropic`, `openrouter`,
`ollama`); the key comes from the provider's conventional env var. Leave
`model` out and the agent uses `LOUSHY_MODEL` if set, otherwise the first of
`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `OPENROUTER_API_KEY`,
`OLLAMA_BASE_URL` that is present.

When you need a custom provider (your own `LLMProvider`, a mock, extra
config), pass the instance instead:

```typescript
import { createAgent, createMockProvider } from '@loushy/build-ai-agent';

const agent = createAgent({
prompt: 'You are a helpful customer support assistant.',
provider: resolveProvider('openai/gpt-4o-mini'), // reads OPENAI_API_KEY
instructions: 'You are a helpful customer support assistant.',
provider: createMockProvider({ responses: ['Hi! How can I help?'] }),
});

const result = await agent.send('Hello!');
console.log(result.text);
```

The same agent with an approval gate on a sensitive tool — `defineTool()`
Expand Down
2 changes: 1 addition & 1 deletion docs/api-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ npm run docs:build # writes docs/api/index.html

| Export | Description |
| ----------------------------- | --------------------------------------------------------------------------- |
| `createAgent(config)` | Zero-config `{ send(message) }` agent from a prompt + provider (+ tools). |
| `createAgent(config)` | Zero-config `{ send(message) }` agent from a `model` string or provider (+ instructions, tools). |
| `AgentBuilder` | Fluent builder for an `AgentConfig` (`AgentBuilder.create().setName(...)...build()`). |
| `AgentExecutor.execute(opts)` | Static executor: runs an agent (LLM + tool-calling loop) and resolves to an `ExecutionResult`. |
| `AgentType` | Agent type enum (e.g. `AgentType.SmartAssistant`). |
Expand Down
23 changes: 19 additions & 4 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,14 +75,29 @@ what the examples and the Quick Start use by default.

## `createAgent()` options

| Option | Description |
| ---------- | ------------------------------------------------------------------ |
| `prompt` | System prompt (required). |
| `provider` | An `LLMProvider` instance (required). |
| Option | Description |
| -------------- | -------------------------------------------------------------- |
| `model` | A `'provider/model'` string such as `'openai/gpt-4o-mini'`, resolved with `resolveProvider()` (key from the env var above). Alternative to `provider`. |
| `provider` | An `LLMProvider` instance (real or mock). Alternative to `model`. If you pass both, `provider` is used and `model` becomes the agent's per-run model setting (a bare model id such as `'gpt-4o'`). |
| `instructions` | System prompt. Optional (defaults to `'You are a helpful assistant.'`). |
| `prompt` | Working alias of `instructions`; passing both is an error. |
| `tools` | `Record<string, ToolDescriptor>`, keyed by the name the agent uses. |
| `name` | Agent name (default `'agent'`). |
| `maxSteps` | Passed through to `AgentExecutor.execute()`. |

With neither `model` nor `provider`, `createAgent()` resolves from the
environment: `LOUSHY_MODEL` (a `'provider/model'` string) if set, otherwise
the first provider whose variable is set, checked in this order:
`OPENAI_API_KEY` (`openai/gpt-4o-mini`), `ANTHROPIC_API_KEY`
(`anthropic/claude-3-5-sonnet-latest`), `OPENROUTER_API_KEY`
(`openrouter/openai/gpt-4o-mini`), `OLLAMA_BASE_URL` (`ollama/llama3`). If none
is set it throws an error listing exactly which options or variables fix it.

Misconfiguration errors say how to fix themselves: a missing key names the
variable (`createAgent: OPENAI_API_KEY is not set. ...`), an unknown prefix
lists the supported ones and suggests the closest, and a missing optional peer
dependency prints the exact `npm install` command.

## `AgentExecutor.execute()` options

`AgentExecutor` is static: call `AgentExecutor.execute(options)`. Only
Expand Down
54 changes: 38 additions & 16 deletions docs/quick-start.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
# Quick Start

Every TypeScript snippet on this page is a complete, standalone ES module
(`.mts`) that runs as-is with the SDK's built-in mock provider - no API key
needed. They are executed against a locally packed build of the SDK by
`npx tsx scripts/verify-docs-snippets.ts`, so they are kept in sync with the
real API.
(`.mts`). They are type-checked and executed against a locally packed build of
the SDK by `npx tsx scripts/verify-docs-snippets.ts`, so they are kept in sync
with the real API. All of them run as-is with the SDK's built-in mock
provider - no API key needed - except the first, which talks to a real model
and is therefore only type-checked.

Install first (see [Installation](./installation.md)):

Expand All @@ -13,31 +14,52 @@ npm install @loushy/build-ai-agent ai zod
npm install @ai-sdk/openai@^0.0.42 @ai-sdk/anthropic@^0.0.42 ollama-ai-provider@^1.2.0
```

## 1. One-liner agent with `createAgent()`
## 1. Hello world in five lines

`createAgent()` is the zero-config entry point: a prompt and a provider in, a
`{ send }` agent out.
`createAgent()` is the zero-config entry point: a `provider/model` string and
instructions in, a `{ send }` agent out. The API key is read from the
provider's conventional environment variable (`OPENAI_API_KEY` here;
`ANTHROPIC_API_KEY`, `OPENROUTER_API_KEY` or `OLLAMA_BASE_URL` for the other
providers).

```ts no-run
import { createAgent } from '@loushy/build-ai-agent';

const agent = createAgent({ model: 'openai/gpt-4o-mini', instructions: 'You are a helpful assistant.' });
const { text } = await agent.send('Hello!');
console.log(text);
```

Leave `model` out to let the environment decide: `LOUSHY_MODEL` (a
`provider/model` string) if set, otherwise the first of `OPENAI_API_KEY`,
`ANTHROPIC_API_KEY`, `OPENROUTER_API_KEY`, `OLLAMA_BASE_URL` that is present.
If a key is missing or the prefix is misspelled, the error tells you exactly
what to set or fix. `instructions` is optional; `prompt` is accepted as an
alias for it.

## 2. When you need a custom provider

Pass a provider instance instead of `model` when you have your own
`LLMProvider`, want the mock provider for tests, or need extra provider
config. This one needs no API key:

```ts
import { createAgent, createMockProvider } from '@loushy/build-ai-agent';

const agent = createAgent({
prompt: 'You are a helpful assistant.',
instructions: 'You are a helpful assistant.',
provider: createMockProvider({ responses: ['Hello! How can I help you today?'] }),
});

const result = await agent.send('Hi there');
console.log(result.text); // "Hello! How can I help you today?"
```

## 2. Switching to a real provider

`resolveProvider('<provider>/<model>')` builds a real provider, reading its
credential from the environment (`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`,
`OPENROUTER_API_KEY`, or `OLLAMA_BASE_URL` for Ollama). This snippet uses
OpenAI when `OPENAI_API_KEY` is set and falls back to the mock provider
otherwise - the same pattern the runnable [examples](../examples/README.md)
use.
`resolveProvider('<provider>/<model>')` is the function `model` uses under
the hood; call it yourself when you want the provider object, as in this
snippet, which uses OpenAI when `OPENAI_API_KEY` is set and falls back to the
mock provider otherwise - the same pattern the runnable
[examples](../examples/README.md) use.

```ts
import { createAgent, createMockProvider, resolveProvider } from '@loushy/build-ai-agent';
Expand Down
7 changes: 2 additions & 5 deletions packages/create-loushy-agent/src/template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,14 +122,11 @@ function buildAgentSource(answers: AnswerConfig): string {
const toolsSource = toolsObjectSource(answers.tools);

return `import { createAgent } from '@loushy/build-ai-agent';
import { resolveProvider } from '@loushy/build-ai-agent';
${toolImports}

const provider = resolveProvider('${answers.provider}/${defaultModelFor(answers.provider)}');

const agent = createAgent({
prompt: 'You are a helpful assistant built with @loushy/build-ai-agent.',
provider,
model: '${answers.provider}/${defaultModelFor(answers.provider)}',
instructions: 'You are a helpful assistant built with @loushy/build-ai-agent.',
${toolsSource ? ` tools: {\n${toolsSource}\n },\n` : ''}});

async function main() {
Expand Down
13 changes: 11 additions & 2 deletions scripts/verify-docs-snippets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,9 @@
* Snippets run with provider credential env vars (OPENAI_API_KEY, ...)
* removed, so they exercise the mock-provider path deterministically and
* never make network calls. A fence whose info string contains
* `no-verify` (e.g. ```ts no-verify) is skipped.
* `no-verify` (e.g. ```ts no-verify) is skipped. One containing `no-run`
* (e.g. ```ts no-run) is still type-checked by both stages but not executed,
* for snippets that need a real API key to run (LOU-D1).
*/
import { execFileSync, spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
Expand Down Expand Up @@ -82,6 +84,8 @@ export interface Snippet {
/** 1-based line number of the opening fence. */
line: number;
source: string;
/** The fence says `no-run`: type-check only, never execute. */
noRun: boolean;
}

/** Index of the closing fence at or after `from`, or lines.length when the block is unterminated. */
Expand All @@ -99,7 +103,8 @@ export function extractSnippets(markdown: string, file: string): Snippet[] {
if (!open) continue;
const end = findFenceEnd(lines, i + 1);
if (!open[2].includes('no-verify')) {
snippets.push({ file, line: i + 1, source: lines.slice(i + 1, end).join('\n') + '\n' });
const source = lines.slice(i + 1, end).join('\n') + '\n';
snippets.push({ file, line: i + 1, source, noRun: open[2].includes('no-run') });
}
i = end;
}
Expand Down Expand Up @@ -264,6 +269,10 @@ function runSnippets(projectDir: string, files: string[], snippets: Snippet[], t
const failures: Failure[] = [];
files.forEach((name, index) => {
const snippet = snippets[index];
if (snippet.noRun) {
console.log(` SKIP (no-run) ${snippet.file}:${snippet.line} (${name})`);
return;
}
const typeOk = !typeFailures.some((f) => f.snippet === snippet);
const failure = runSnippet(projectDir, env, name, snippet, typeOk);
if (failure) failures.push(failure);
Expand Down
137 changes: 133 additions & 4 deletions src/createAgent.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,28 @@
/**
* LOU-H1 test: only imports createAgent() and a mock provider - zero other
* Loushy imports - to prove the one-liner surface is self-contained.
* LOU-D1 additions: `model` strings, env fallback, `instructions` alias and
* error messages (no network: env is stubbed and the registry's create() is
* the same boundary resolveProvider.test.ts mocks).
*/
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { createAgent } from './createAgent';
import { createMockProvider } from './providers/mock';
import { LLMProviderRegistry } from './providers/llm';
import { mockModel } from './testing';

const ENV_VARS = ['LOUSHY_MODEL', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'OPENROUTER_API_KEY', 'OLLAMA_BASE_URL'];

describe('createAgent', () => {
beforeEach(() => {
for (const name of ENV_VARS) vi.stubEnv(name, '');
});

afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
});

it('sends a message and gets a non-empty response', async () => {
const agent = createAgent({
prompt: 'You are a helpful assistant.',
Expand All @@ -20,8 +36,121 @@ describe('createAgent', () => {
});

it('throws a guiding error when provider is missing', () => {
expect(() =>
createAgent({ prompt: 'x', provider: undefined as any })
).toThrow(/provider/i);
expect(() => createAgent({ prompt: 'x', provider: undefined })).toThrow(/provider/i);
});

describe('model string', () => {
it('resolves "provider/model" with the key from the conventional env var', async () => {
vi.stubEnv('OPENAI_API_KEY', 'sk-test');
const provider = mockModel(['Hi from the model']);
const createSpy = vi.spyOn(LLMProviderRegistry, 'create').mockReturnValue(provider);

const agent = createAgent({ model: 'openai/gpt-4o-mini', instructions: 'Be brief.' });
const { text } = await agent.send('Hello!');

expect(createSpy).toHaveBeenCalledWith('openai', { defaultModel: 'gpt-4o-mini', apiKey: 'sk-test' });
expect(text).toBe('Hi from the model');
});

it('works with instructions omitted (minimal default prompt)', async () => {
vi.stubEnv('OPENAI_API_KEY', 'sk-test');
const provider = mockModel(['ok']);
vi.spyOn(LLMProviderRegistry, 'create').mockReturnValue(provider);

await createAgent({ model: 'openai/gpt-4o-mini' }).send('hi');

expect(JSON.stringify(provider.lastCall?.messages)).toContain('You are a helpful assistant.');
});

it('with both provider and model: uses provider, and model is the per-agent model setting', async () => {
const provider = mockModel(['ok']);
const createSpy = vi.spyOn(LLMProviderRegistry, 'create');

await createAgent({ provider, model: 'gpt-4o' }).send('hi');

expect(createSpy).not.toHaveBeenCalled();
expect(provider.lastCall?.model).toBe('gpt-4o');
});
});

describe('environment fallback', () => {
it('uses LOUSHY_MODEL when set, even if provider keys are present', () => {
vi.stubEnv('LOUSHY_MODEL', 'anthropic/claude-3-5-haiku-latest');
vi.stubEnv('ANTHROPIC_API_KEY', 'sk-ant');
vi.stubEnv('OPENAI_API_KEY', 'sk-openai');
const createSpy = vi.spyOn(LLMProviderRegistry, 'create').mockReturnValue(mockModel([]));

createAgent({ instructions: 'x' });

expect(createSpy).toHaveBeenCalledWith('anthropic', {
defaultModel: 'claude-3-5-haiku-latest',
apiKey: 'sk-ant',
});
});

it.each([
[{ OPENAI_API_KEY: 'o', ANTHROPIC_API_KEY: 'a', OPENROUTER_API_KEY: 'r' }, 'openai'],
[{ ANTHROPIC_API_KEY: 'a', OPENROUTER_API_KEY: 'r', OLLAMA_BASE_URL: 'http://x' }, 'anthropic'],
[{ OPENROUTER_API_KEY: 'r', OLLAMA_BASE_URL: 'http://x' }, 'openrouter'],
[{ OLLAMA_BASE_URL: 'http://x' }, 'ollama'],
])('with keys %j picks %s first', (env, expected) => {
for (const [name, value] of Object.entries(env)) vi.stubEnv(name, value);
const createSpy = vi.spyOn(LLMProviderRegistry, 'create').mockReturnValue(mockModel([]));

createAgent();

expect(createSpy.mock.calls[0][0]).toBe(expected);
});

it('throws an error listing every fix when nothing is configured', () => {
expect(() => createAgent({ instructions: 'x' })).toThrow(
/createAgent: no model configured.*model: 'openai\/gpt-4o-mini'.*provider: \.\.\..*LOUSHY_MODEL.*OPENAI_API_KEY, ANTHROPIC_API_KEY, OPENROUTER_API_KEY, OLLAMA_BASE_URL/
);
});
});

describe('instructions / prompt', () => {
it('accepts instructions as the system prompt', async () => {
const provider = mockModel(['ok']);
await createAgent({ provider, instructions: 'Answer in French.' }).send('hi');
expect(JSON.stringify(provider.lastCall?.messages)).toContain('Answer in French.');
});

it('still accepts prompt as an alias', async () => {
const provider = mockModel(['ok']);
await createAgent({ provider, prompt: 'Answer in German.' }).send('hi');
expect(JSON.stringify(provider.lastCall?.messages)).toContain('Answer in German.');
});

it('throws naming both when instructions and prompt are given', () => {
const provider = mockModel([]);
const both = { provider, instructions: 'a', prompt: 'b' } as unknown as Parameters<typeof createAgent>[0];
expect(() => createAgent(both)).toThrow(/both 'instructions' and 'prompt'/);
});
});

describe('error messages', () => {
it('names the missing API key and the provider escape hatch', () => {
expect(() => createAgent({ model: 'openai/gpt-4o-mini' })).toThrow(
'createAgent: OPENAI_API_KEY is not set. Set it in your environment, or pass a provider instance: createAgent({ provider: ... })'
);
});

it('lists supported prefixes and suggests the closest for an unknown provider', () => {
expect(() => createAgent({ model: 'opnai/gpt-4o' })).toThrow(
/createAgent: unrecognized provider 'opnai'.*Supported prefixes: openai, anthropic, openrouter, ollama\. Did you mean 'openai\/gpt-4o'\?/
);
});

it('gives the exact npm install command when a peer dependency is missing', () => {
vi.stubEnv('ANTHROPIC_API_KEY', 'sk-ant');
vi.spyOn(LLMProviderRegistry, 'create').mockImplementation(() => {
throw Object.assign(new Error("Cannot find module '@ai-sdk/anthropic'"), { code: 'MODULE_NOT_FOUND' });
});

expect(() => createAgent({ model: 'anthropic/claude-3-5-sonnet-latest' })).toThrow(
/createAgent: .*'anthropic' provider.*Run: npm install @ai-sdk\/anthropic@\^0\.0\.42/
);
});
});
});
Loading
Loading