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
1 change: 1 addition & 0 deletions .fallowrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"entry": [
"src/cli/{dev,build,studio}.ts",
"src/data/index.ts",
"**/*.test-d.ts",
"**/*.{test,spec}.{ts,tsx}",
"**/*.eval.ts"
],
Expand Down
73 changes: 38 additions & 35 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,19 +47,27 @@ const result = await agent.send('Hello!');
console.log(result.text);
```

The same agent with an approval gate on a sensitive tool — `AgentExecutor`
pauses before calling it and persists a resumable snapshot instead:
The same agent with an approval gate on a sensitive tool — `defineTool()`
infers the argument types from the zod `input`, and `AgentExecutor` pauses
before calling the tool and persists a resumable snapshot instead:

```typescript
import { AgentExecutor, resumeAfterApproval, StorageServiceApprovalStore, ToolRegistry } from '@loushy/build-ai-agent';
import { AgentExecutor, resumeAfterApproval, StorageServiceApprovalStore, ToolRegistry, defineTool } from '@loushy/build-ai-agent';
import { z } from 'zod';

const registry = new ToolRegistry();
registry.register('send_email', {
displayName: 'Send email',
tool: emailTool,
needsApproval: (args) => args.to.includes('@external.com'), // pauses for a human
const sendEmail = defineTool({
name: 'send_email',
description: 'Send an email',
input: z.object({ to: z.string().email(), subject: z.string(), body: z.string() }),
needsApproval: ({ to }) => !to.endsWith('@mycompany.com'), // `to` is typed; pauses for a human
async execute({ to, subject, body }) {
return { messageId: '...' };
},
});

const registry = new ToolRegistry();
registry.register(sendEmail);

const approvalStore = new StorageServiceApprovalStore(storage);
const paused = await AgentExecutor.execute({
agent, input, provider, toolRegistry: registry, approvalStore,
Expand Down Expand Up @@ -200,41 +208,36 @@ control over the resulting `AgentConfig`.

### Tools

Define a tool with `defineTool()` — argument and result types are inferred from
the zod `input`, and the result drops in anywhere tools are accepted
(`createAgent({ tools: [...] })`, `ToolRegistry.register(tool)`,
`AgentBuilder.addTool(tool)`):

```typescript
import { ToolRegistry } from '@loushy/build-ai-agent';
import { tool } from 'ai';
import { defineTool, createAgent, type ToolInput, type ToolOutput } from '@loushy/build-ai-agent';
import { z } from 'zod';

const registry = new ToolRegistry();

registry.register('weather', {
displayName: 'Get weather',
tool: tool({
description: 'Get weather information',
parameters: z.object({
location: z.string(),
units: z.enum(['celsius', 'fahrenheit']),
}),
execute: async ({ location, units }) => {
// Your implementation
return { temperature: 72, conditions: 'sunny' };
},
}),
const weather = defineTool({
name: 'weather', // 1-64 chars: letters, digits, _ and -
description: 'Get weather information',
input: z.object({ location: z.string(), units: z.enum(['celsius', 'fahrenheit']) }),
execute: async ({ location, units }) => ({ temperature: 72, conditions: 'sunny' }),
});
```

A `ToolDescriptor` can also opt into two safety primitives:
type WeatherArgs = ToolInput<typeof weather>; // { location: string; units: 'celsius' | 'fahrenheit' }
type WeatherResult = ToolOutput<typeof weather>; // { temperature: number; conditions: string }

```typescript
registry.register('send_email', {
displayName: 'Send email',
tool: emailTool,
needsApproval: (args) => args.to.includes('@external.com'), // pauses for a human
requiresSandbox: true, // routes through the configured SandboxAdapter
sandboxExecute: async (args, sandbox) => sandbox.run('node', ['send-email.js', JSON.stringify(args)]),
});
const agent = createAgent({ prompt: '...', provider, tools: [weather] });
```

Optional fields mirror `ToolDescriptor`: `displayName`, `needsApproval` (boolean
or a predicate typed from `input`), `requiresSandbox` and `sandboxExecute`
(routes through the configured `SandboxAdapter`).

**Advanced: `ToolRegistry`.** For raw `ToolDescriptor`s (built-in tools, MCP
tools, an existing `tool()` from the `ai` SDK) register by name:
`registry.register('weather', { displayName: 'Get weather', tool: aiSdkTool })`.

### Flows

Flows orchestrate multi-step workflows within a single agent:
Expand Down
24 changes: 23 additions & 1 deletion docs/api-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,32 @@ npm run docs:build # writes docs/api/index.html

| Export | Description |
| -------------------------------------------- | --------------------------------------------- |
| `ToolRegistry` | Holds the tools an agent config refers to. |
| `defineTool({ name, description, input, execute, ... })` | Define a tool; `execute`/`needsApproval` args are inferred from the zod `input`. Accepted by `createAgent({ tools: [...] })`, `ToolRegistry.register(tool)` and `AgentBuilder.addTool(tool)`. |
| `ToolInput<typeof t>`, `ToolOutput<typeof t>` | Argument and result types of a defined tool. |
| `ToolRegistry` | Holds the tools an agent config refers to (advanced: `register(tool)` or `register(name, descriptor)`). |
| `httpTool`, `currentDateTool`, `dayNameTool` | Built-in tools. |
| `loadMcpTools(client, connectionName)` | Load a connected MCP server's tools as `ToolDescriptor`s. Available from the package root, `@loushy/build-ai-agent/tools`, and `@loushy/build-ai-agent/mcp`. |

```ts
import { defineTool } from '@loushy/build-ai-agent';
import { z } from 'zod';

const sendEmail = defineTool({
name: 'send_email', // 1-64 chars: letters, digits, _ and -
description: 'Send an email',
input: z.object({ to: z.string().email(), subject: z.string() }),
needsApproval: ({ to }) => !to.endsWith('@mycompany.com'), // `to` is typed
async execute({ to, subject }) {
return { messageId: `${to}:${subject}` };
},
});
```

Optional fields: `displayName`, `needsApproval` (boolean or predicate),
`requiresSandbox` and `sandboxExecute`. Defining a tool validates its name,
description and zod `input` immediately; registering two tools with the same
name throws an error naming the conflict.

```ts
import { loadMcpTools } from '@loushy/build-ai-agent/mcp';

Expand Down
32 changes: 25 additions & 7 deletions docs/quick-start.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,26 +58,44 @@ console.log(result.text);

## 3. Adding tools

Tools are passed to `createAgent()` keyed by the name the agent calls them
by. The SDK ships several built-in tools (`currentDateTool`, `dayNameTool`,
`httpTool`, ...). The mock provider simulates a tool call whenever the user
message mentions a tool's name, so this snippet exercises a real tool round
trip without an LLM.
Define a tool with `defineTool()`: the `execute` and `needsApproval` arguments
are typed from the zod `input`, and the result goes straight into
`createAgent({ tools: [...] })`. The mock provider simulates a tool call
whenever the user message mentions a tool's name, so this snippet exercises a
real tool round trip without an LLM.

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

const currentDate = defineTool({
name: 'current_date',
description: 'Get the current date',
input: z.object({}),
async execute() {
return { date: new Date().toISOString().slice(0, 10) };
},
});

const agent = createAgent({
prompt: 'You are a scheduling assistant. Use tools when helpful.',
provider: createMockProvider({ responses: ['Let me check.', 'Here is the date you asked for.'] }),
tools: { current_date: currentDateTool },
tools: [currentDate],
});

const result = await agent.send('Please call current_date for me');
console.log(result.toolCalls.map((call) => call.function.name)); // [ 'current_date' ]
console.log(result.text);
```

Names must be 1-64 characters of letters, digits, `_` or `-`. The SDK also
ships built-in tools (`currentDateTool`, `dayNameTool`, `httpTool`, ...) that
you pass keyed by name: `tools: { current_date: currentDateTool }`.

*Advanced: `ToolRegistry`.* To share tools across agents or register raw
`ToolDescriptor`s, use `registry.register(tool)` for a defined tool or
`registry.register(name, descriptor)` for a descriptor.

## 4. Full control: `AgentBuilder` + `AgentExecutor`

`createAgent()` is a thin wrapper over `AgentBuilder` and the static
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@
"test:evals:judge": "vitest run --config vitest.judge.config.ts",
"lint": "eslint src",
"typecheck": "tsc --noEmit",
"test:types": "vitest run --typecheck.only",
"example:tracing:console": "tsx examples/tracing/run-console.ts",
"example:tracing:otel": "tsx examples/tracing/run-otel.ts",
"example:support-bot": "tsx examples/support-bot/index.ts",
Expand Down
21 changes: 18 additions & 3 deletions src/core/AgentBuilder.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { AgentConfig, AgentType, ToolConfiguration, AgentFlow } from '../types';
import { validateAgentConfig, validateAgentTools } from '../agent-types';
import { nanoid } from 'nanoid';
import type { DefinedTool } from '../tools/defineTool';

/**
* Fluent API for building agents
Expand Down Expand Up @@ -41,13 +42,27 @@
}

/**
* Add a tool
* Add a tool: either a `defineTool()` result (keyed by its name; register
* the same tool with a ToolRegistry for execution) or an explicit
* key + configuration.
*/
public addTool(key: string, config: ToolConfiguration): this {
public addTool(tool: DefinedTool): this;
public addTool(key: string, config: ToolConfiguration): this;
public addTool(keyOrTool: string | DefinedTool, config?: ToolConfiguration): this {
if (!this.config.tools) {
this.config.tools = {};
}
this.config.tools[key] = config;
if (typeof keyOrTool === 'string') {
if (!config) {
throw new Error(
`AgentBuilder.addTool('${keyOrTool}'): a configuration is required. ` +
`Example: addTool('${keyOrTool}', { tool: '${keyOrTool}' }), or pass a defineTool() result.`
);
}
this.config.tools[keyOrTool] = config;
} else {
this.config.tools[keyOrTool.name] = { tool: keyOrTool.name, description: keyOrTool.description };
}
return this;
}

Expand Down Expand Up @@ -91,7 +106,7 @@
/**
* Set expected result schema
*/
public setExpectedResult(schema: any): this {

Check warning on line 109 in src/core/AgentBuilder.ts

View workflow job for this annotation

GitHub Actions / ci

Unexpected any. Specify a different type
this.config.expectedResult = schema;
return this;
}
Expand All @@ -107,7 +122,7 @@
/**
* Set events
*/
public setEvents(events: any[]): this {

Check warning on line 125 in src/core/AgentBuilder.ts

View workflow job for this annotation

GitHub Actions / ci

Unexpected any. Specify a different type
this.config.events = events;
return this;
}
Expand All @@ -115,7 +130,7 @@
/**
* Set settings
*/
public setSettings(settings: Record<string, any>): this {

Check warning on line 133 in src/core/AgentBuilder.ts

View workflow job for this annotation

GitHub Actions / ci

Unexpected any. Specify a different type
this.config.settings = settings;
return this;
}
Expand All @@ -123,7 +138,7 @@
/**
* Set metadata
*/
public setMetadata(metadata: Record<string, any>): this {

Check warning on line 141 in src/core/AgentBuilder.ts

View workflow job for this annotation

GitHub Actions / ci

Unexpected any. Specify a different type
this.config.metadata = metadata;
return this;
}
Expand Down
27 changes: 20 additions & 7 deletions src/createAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { AgentExecutor, ExecutionResult } from './execution/AgentExecutor';
import { LLMProvider } from './providers/llm';
import { ToolRegistry } from './tools/ToolRegistry';
import { ToolDescriptor } from './types';
import type { DefinedTool } from './tools/defineTool';

/**
* Configuration for createAgent(). Tools are keyed by the name the agent
Expand All @@ -32,8 +33,14 @@ export interface CreateAgentConfig {
prompt: string;
/** LLM provider instance (real or mock) used to generate responses. */
provider: LLMProvider;
/** Optional tools, keyed by the name the agent should call them by. */
tools?: Record<string, ToolDescriptor>;
/**
* Optional tools: an array of `defineTool()` results (named by the tool),
* or a record of descriptors keyed by the name the agent should call them by.
*
* @example
* createAgent({ prompt: '...', provider, tools: [sendEmail] });
*/
tools?: readonly DefinedTool[] | Record<string, ToolDescriptor>;
/** Optional agent name; defaults to 'agent'. */
name?: string;
/** Optional maxSteps passed through to AgentExecutor.execute(). */
Expand Down Expand Up @@ -93,20 +100,26 @@ function assertCreateAgentConfig(config: CreateAgentConfig): void {
* the matching AgentConfig.tools entries. No registry is built when there
* are no tools.
*/
function registerTools(tools: Record<string, ToolDescriptor>): {
function registerTools(tools: readonly DefinedTool[] | Record<string, ToolDescriptor>): {
toolRegistry: ToolRegistry | undefined;
toolsConfig: Record<string, { tool: string }>;
} {
const toolNames = Object.keys(tools);
const entries: Array<[string, ToolDescriptor | DefinedTool]> = Array.isArray(tools)
? (tools as readonly DefinedTool[]).map((t): [string, DefinedTool] => [t.name, t])
: Object.entries(tools as Record<string, ToolDescriptor>);
const toolsConfig: Record<string, { tool: string }> = {};

if (toolNames.length === 0) {
if (entries.length === 0) {
return { toolRegistry: undefined, toolsConfig };
}

const toolRegistry = new ToolRegistry();
for (const name of toolNames) {
toolRegistry.register(name, tools[name]);
for (const [name, descriptor] of entries) {
if (Array.isArray(tools)) {
toolRegistry.register(descriptor as DefinedTool);
} else {
toolRegistry.register(name, descriptor);
}
toolsConfig[name] = { tool: name };
}
return { toolRegistry, toolsConfig };
Expand Down
60 changes: 52 additions & 8 deletions src/tools/ToolRegistry.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ToolDescriptor } from '../types';
import { DefinedTool, isDefinedTool } from './defineTool';

/**
* Tool Registry
Expand All @@ -8,20 +9,63 @@ export class ToolRegistry {
private tools: Map<string, ToolDescriptor> = new Map();

/**
* Register a tool
* Register a tool defined with `defineTool()` under its own name. Throws if
* a tool with that name is already registered.
*
* @example
* registry.register(sendEmail);
*/
public register(name: string, descriptor: ToolDescriptor): void {
if (this.tools.has(name)) {
console.warn(`Tool '${name}' is already registered. Overwriting.`);
public register(tool: DefinedTool): void;
/**
* Register a tool descriptor under an explicit name. Re-registering a name
* overwrites the previous entry (with a warning).
*/
public register(name: string, descriptor: ToolDescriptor): void;
public register(nameOrTool: string | DefinedTool, descriptor?: ToolDescriptor): void {
if (typeof nameOrTool !== 'string') {
this.registerDefined(nameOrTool);
return;
}
if (!descriptor) {
throw new Error(
`ToolRegistry.register('${nameOrTool}'): a descriptor is required. ` +
`Pass one (register('${nameOrTool}', descriptor)) or register a defineTool() result directly.`
);
}
this.tools.set(name, descriptor);
if (this.tools.has(nameOrTool)) {
console.warn(`Tool '${nameOrTool}' is already registered. Overwriting.`);
}
this.tools.set(nameOrTool, descriptor);
}

private registerDefined(tool: DefinedTool): void {
if (!isDefinedTool(tool)) {
throw new Error(
'ToolRegistry.register(tool): expected a tool created with defineTool(). ' +
'For a raw descriptor pass a name: register(name, descriptor).'
);
}
const existing = this.tools.get(tool.name);
if (existing) {
const describe = (t: ToolDescriptor) => `"${t.displayName}"`;
throw new Error(
`Tool name '${tool.name}' is already registered: existing tool ${describe(existing)} ` +
`conflicts with new tool ${describe(tool)}. Give one of them a different name.`
);
}
this.tools.set(tool.name, tool);
}

/**
* Register multiple tools at once
* Register multiple tools at once: a record of descriptors keyed by name,
* or an array of `defineTool()` results.
*/
public registerMany(tools: Record<string, ToolDescriptor>): void {
Object.entries(tools).forEach(([name, descriptor]) => {
public registerMany(tools: Record<string, ToolDescriptor> | readonly DefinedTool[]): void {
if (Array.isArray(tools)) {
(tools as readonly DefinedTool[]).forEach((tool) => this.register(tool));
return;
}
Object.entries(tools as Record<string, ToolDescriptor>).forEach(([name, descriptor]) => {
this.register(name, descriptor);
});
}
Expand Down
Loading
Loading