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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- README revamped into a short front page (LOU-D52); the details it dropped moved to `docs/` (new pages: tools, approvals, providers, cli, flows, guardrails, utilities).

### Changed
- Small built-in tools are defined with `defineTool` (LOU-D24, not breaking): `currentDateTool`, `dayNameTool`, `createEmailTool()`, `createHttpTool()`, `createSlackTool()`, `createDelegateTool()` and the descriptors `loadMcpTools()` builds no longer call `ai`'s `tool()`. They now carry canonical `inputSchema` / `execute` (the legacy `.tool` stays), and the built-ins have the tool names `current_date`, `day_name`, `send_email`, `http_request`, `slack_alert` and `delegate_to_<agent>`. Descriptions, schemas, approval and sandbox behaviour and the factory signatures are unchanged. MCP descriptors are built with the new internal `toolDescriptorFromSchema()`, which (unlike `defineTool`) accepts any server-provided name or empty description. `routeFetchThroughSandbox()` reads the canonical `execute` first.
- BREAKING (types only): `Message.content` is widened from `string` to `string | ContentPart[]` (LOU-V11). Messages you build keep working unchanged, but code that reads `message.content` as a string (`.length`, `.startsWith()`, a template literal) no longer type-checks. **Migration:** read `textOf(message)` instead, or narrow with `typeof message.content === 'string'`. A custom `LLMProvider` that forwards `content` should handle the parts or send `textOf(message)`.
- The execute context is our own type (LOU-D23, not breaking): the new exported `ToolExecutionContext` (`{ toolCallId, messages: readonly Message[], abortSignal?, sessionId?, onDelegatedUsage? }`, `Message` from `@loushy/build-ai-agent`) replaces the `ai` SDK's `ToolExecutionOptions` as the type of the second argument of `defineTool()`'s `execute`, `ToolDescriptor.execute`, `DefinedTool.execute` and the third argument of `sandboxExecute`, and is what `buildToolRunContext()` returns; the public types no longer import `ToolExecutionOptions` from `ai`. A function typed for `ai`'s options that only reads `toolCallId` or `abortSignal` stays assignable. `ToolRunContext` is now a deprecated alias of the optional fields of `ToolExecutionContext` (it already existed with `onDelegatedUsage` and `toolCallId`). `AgentExecutionOptions.messages` is typed `Message[]` instead of the `ai` SDK's `CoreMessage[]` (the field is unused by the SDK). The legacy `ToolDescriptor.tool` (`ai` v4 `Tool`) is unchanged. See docs/tools.md#execute-context.
- BREAKING (small): `SDKError.code` values are now the `LOUSHY_*` codes (LOU-D2): `AGENT_EXECUTION_ERROR` -> `LOUSHY_AGENT_EXECUTION_FAILED`, `TOOL_EXECUTION_ERROR` -> `LOUSHY_TOOL_EXECUTION_FAILED`, `LLM_PROVIDER_ERROR` -> `LOUSHY_PROVIDER_REQUEST_FAILED`, `SESSION_AWAITING_APPROVAL` -> `LOUSHY_SESSION_AWAITING_APPROVAL`, `FLOW_EXECUTION_ERROR` -> `LOUSHY_FLOW_EXECUTION_FAILED`, `CONFIGURATION_ERROR` -> `LOUSHY_CONFIG_INVALID`, `VALIDATION_ERROR` -> `LOUSHY_VALIDATION_FAILED`, `TIMEOUT_ERROR` -> `LOUSHY_OPERATION_TIMEOUT`, `RATE_LIMIT_ERROR` -> `LOUSHY_PROVIDER_RATE_LIMITED`; `code` is no longer optional (`new SDKError(message)` gets `LOUSHY_GENERIC_ERROR`). No class was renamed. The messages of `SDKError`s other than `ToolExecutionError`, `LLMProviderError`, `TimeoutError` and `RateLimitError` gain a second line with the code, hint and docs link, and `loadSpec()` now rejects a spec whose top-level field is a likely typo of a spec field (`LOUSHY_SPEC_UNKNOWN_FIELD`; other unknown fields are still ignored). **Migration:** compare `code` against the new values (or use `instanceof`); match messages with `toContain` / a regex, or compare `error.detail`, instead of exact equality.
Expand Down
30 changes: 15 additions & 15 deletions examples/ops-pipeline/fixer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,13 @@
* this module's output with the LOU-E guardrails and LOU-J5 Slack tool
* rather than being fixer-core itself.
*/
import { tool } from 'ai';
import {
AgentExecutor,
ExecuteOptions,
ExecutionResult,
} from '../../src/execution/AgentExecutor';
import { AgentConfig, ToolDescriptor } from '../../src/types';
import { AgentConfig, ToolDescriptor, ToolExecutionContext } from '../../src/types';
import { getToolExecute } from '../../src/tools/toolContract';
import { LLMProvider } from '../../src/providers';
import {
createDelegateTool,
Expand Down Expand Up @@ -162,21 +162,21 @@ export async function runFixer(
*/
export function createFixerDelegateTool(opts: DelegateAgentOptions): ToolDescriptor {
const base = createDelegateTool(opts);
const baseTool = base.tool;
const baseExecute = getToolExecute(base)!;
// `base` carries a canonical `execute` (LOU-D24), which wins over the
// legacy `tool.execute`: wrap that one, and keep `tool` in step.
const execute = async (args: unknown, context: ToolExecutionContext) => {
const result = await baseExecute(args, context);
const patch = extractDiffBlock((result as { text: string }).text);
if (!patch.trim()) {
throw new EmptyPatchError();
}
return { ...(result as object), patch };
};

return {
...base,
tool: tool({
description: baseTool.description,
parameters: baseTool.parameters,
execute: async (args: any, context: any) => {
const result = await baseTool.execute!(args, context);
const patch = extractDiffBlock((result as { text: string }).text);
if (!patch.trim()) {
throw new EmptyPatchError();
}
return { ...(result as object), patch };
},
}) as typeof baseTool,
execute,
tool: { ...base.tool, execute: execute as typeof base.tool.execute },
};
}
102 changes: 46 additions & 56 deletions src/execution/DelegationTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,14 @@
*/

import { z } from 'zod';
import { tool } from 'ai';
import { AsyncLocalStorage } from 'node:async_hooks';
import { PropagatingToolError } from './propagatingToolError';
import { runSubagent } from './delegation';
import { AgentExecutor } from './AgentExecutor';
import { LLMProvider, Message } from '../providers';
import { AgentConfig, ToolDescriptor } from '../types';
import { ToolRegistry } from '../tools';
import type { ToolRunContext } from './sandboxGuard';
import { defineTool } from '../tools/defineTool';

/**
* Thrown when a delegation chain exceeds the configured maxDepth without
Expand Down Expand Up @@ -103,63 +102,54 @@ export interface DelegateAgentResult {
export function createDelegateTool(opts: DelegateAgentOptions): ToolDescriptor {
const maxDepth = opts.maxDepth ?? 3;

return {
return defineTool({
// Agent names are free text; tool names are not (see defineTool).
name: `delegate_to_${opts.agent.name.replace(/[^a-zA-Z0-9_-]+/g, '_')}`.slice(0, 64),
displayName: `Delegate to ${opts.agent.name}`,
tool: tool({
description: `Delegates a task to the "${opts.agent.name}" agent and returns its response.`,
parameters: z.object({
task: z.string().describe('The task/instructions to delegate to the child agent'),
context: z
.array(
z.object({
role: z.enum(['system', 'user', 'assistant', 'tool']),
content: z.string(),
})
)
.optional()
.describe(
'Optional prior conversation history to share with the child agent (only used when contextMode is "full-history")'
),
}),
execute: async (
{
task,
context,
}: {
task: string;
context?: Message[];
},
options?: { abortSignal?: AbortSignal } & ToolRunContext
): Promise<DelegateAgentResult> => {
const currentDepth = delegationDepthStorage.getStore() ?? 0;
description: `Delegates a task to the "${opts.agent.name}" agent and returns its response.`,
input: z.object({
task: z.string().describe('The task/instructions to delegate to the child agent'),
context: z
.array(
z.object({
role: z.enum(['system', 'user', 'assistant', 'tool']),
content: z.string(),
})
)
.optional()
.describe(
'Optional prior conversation history to share with the child agent (only used when contextMode is "full-history")'
),
}),
execute: async ({ task, context }, options): Promise<DelegateAgentResult> => {
const currentDepth = delegationDepthStorage.getStore() ?? 0;

if (currentDepth >= maxDepth) {
throw new DelegationDepthExceededError(maxDepth);
}
if (currentDepth >= maxDepth) {
throw new DelegationDepthExceededError(maxDepth);
}

const childInput: Message[] =
opts.contextMode === 'full-history' && context
? [...context, { role: 'user', content: task }]
: [{ role: 'user', content: task }];
const childInput: Message[] =
opts.contextMode === 'full-history' && context
? [...context, { role: 'user', content: task }]
: [{ role: 'user', content: task }];

return delegationDepthStorage.run(currentDepth + 1, async () => {
const result = await runSubagent(
{ agent: opts.agent, provider: opts.provider, toolRegistry: opts.toolRegistry, maxSteps: opts.maxSteps },
// LOU-V1: aborting the parent run aborts the child with it.
{ name: opts.agent.name, input: childInput, toolOptions: options },
(childOptions) => AgentExecutor.execute(childOptions)
);
return delegationDepthStorage.run(currentDepth + 1, async () => {
const result = await runSubagent(
{ agent: opts.agent, provider: opts.provider, toolRegistry: opts.toolRegistry, maxSteps: opts.maxSteps },
// LOU-V1: aborting the parent run aborts the child with it.
{ name: opts.agent.name, input: childInput, toolOptions: options },
(childOptions) => AgentExecutor.execute(childOptions)
);

// LOU-V5: runSubagent() rolled the child's full usage into the
// parent run's totals (`usage.delegated`); the model only sees the
// token counts.
const { promptTokens, completionTokens, totalTokens } = result.usage;
return {
text: result.text,
usage: { promptTokens, completionTokens, totalTokens },
};
});
},
}),
};
// LOU-V5: runSubagent() rolled the child's full usage into the
// parent run's totals (`usage.delegated`); the model only sees the
// token counts.
const { promptTokens, completionTokens, totalTokens } = result.usage;
return {
text: result.text,
usage: { promptTokens, completionTokens, totalTokens },
};
});
},
});
}
19 changes: 9 additions & 10 deletions src/tools/built-in/currentDate.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,17 @@
import { z } from 'zod';
import { tool } from 'ai';
import { defineTool } from '../defineTool';
import { ToolDescriptor } from '../../types';

/**
* Current Date Tool
* Returns the current date in ISO format
*/
export const currentDateTool: ToolDescriptor = {
export const currentDateTool: ToolDescriptor = defineTool({
name: 'current_date',
displayName: 'Get current date',
tool: tool({
description: 'Get the current date and time in ISO format (UTC timezone)',
parameters: z.object({}),
execute: async () => {
return new Date().toISOString();
},
}),
};
description: 'Get the current date and time in ISO format (UTC timezone)',
input: z.object({}),
execute: async () => {
return new Date().toISOString();
},
});
23 changes: 11 additions & 12 deletions src/tools/built-in/dayName.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,20 @@
import { z } from 'zod';
import { tool } from 'ai';
import { defineTool } from '../defineTool';
import { ToolDescriptor } from '../../types';

/**
* Day Name Tool
* Returns the name of the day for a given date
*/
export const dayNameTool: ToolDescriptor = {
export const dayNameTool: ToolDescriptor = defineTool({
name: 'day_name',
displayName: 'Get day name',
tool: tool({
description: 'Get the name of the day (e.g., Monday, Tuesday) for a given date',
parameters: z.object({
date: z.string().describe('The date to get the day name for in ISO format (e.g., 2024-01-15)'),
locale: z.string().optional().describe('The locale to use for the day name (e.g., en-US, pl-PL). Defaults to en-US'),
}),
execute: async ({ date, locale = 'en-US' }) => {
return new Date(date).toLocaleDateString(locale, { weekday: 'long' });
},
description: 'Get the name of the day (e.g., Monday, Tuesday) for a given date',
input: z.object({
date: z.string().describe('The date to get the day name for in ISO format (e.g., 2024-01-15)'),
locale: z.string().optional().describe('The locale to use for the day name (e.g., en-US, pl-PL). Defaults to en-US'),
}),
};
execute: async ({ date, locale = 'en-US' }) => {
return new Date(date).toLocaleDateString(locale, { weekday: 'long' });
},
});
57 changes: 28 additions & 29 deletions src/tools/built-in/email.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { z } from 'zod';
import { tool } from 'ai';
import { defineTool } from '../defineTool';
import { ToolDescriptor } from '../../types';

/**
Expand Down Expand Up @@ -87,34 +87,33 @@ export function createEmailTool(options: EmailToolOptions): ToolDescriptor {
throw new Error('Email tool requires an apiKey in options');
}

return {
return defineTool({
name: 'send_email',
displayName: 'Send email',
tool: tool({
description: 'Sends an email using the Resend.com API. Provide sender, recipients, subject, and content.',
parameters: z.object({
from: z.string().describe('The sender email address (e.g., "Sender Name <sender@example.com>"). Can use default if configured.').optional(),
to: z.array(z.string()).describe('Array of recipient email addresses (e.g., ["user@example.com"])'),
subject: z.string().describe('The subject line of the email'),
text: z.string().describe('Plain text content of the email'),
html: z.string().describe('HTML content of the email'),
}),
execute: async ({ from, to, subject, text, html }) => {
const senderEmail = from || defaultFrom;

if (!senderEmail) {
throw new Error('Email "from" address is required. Provide it in the parameters or set defaultFrom in options.');
}

return sendEmail({
from: senderEmail,
to,
subject,
text,
html,
apiKey,
apiUrl,
});
},
description: 'Sends an email using the Resend.com API. Provide sender, recipients, subject, and content.',
input: z.object({
from: z.string().describe('The sender email address (e.g., "Sender Name <sender@example.com>"). Can use default if configured.').optional(),
to: z.array(z.string()).describe('Array of recipient email addresses (e.g., ["user@example.com"])'),
subject: z.string().describe('The subject line of the email'),
text: z.string().describe('Plain text content of the email'),
html: z.string().describe('HTML content of the email'),
}),
};
execute: async ({ from, to, subject, text, html }) => {
const senderEmail = from || defaultFrom;

if (!senderEmail) {
throw new Error('Email "from" address is required. Provide it in the parameters or set defaultFrom in options.');
}

return sendEmail({
from: senderEmail,
to,
subject,
text,
html,
apiKey,
apiUrl,
});
},
});
}
44 changes: 19 additions & 25 deletions src/tools/built-in/http.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { z } from 'zod';
import { tool } from 'ai';
import { isIP } from 'net';
import { promises as dnsPromises } from 'dns';
import { lazyValue, loadOptionalPeer } from '../../providers/optionalPeer';
import { ToolDescriptor } from '../../types';
import { ToolDescriptor, ToolExecutionContext } from '../../types';
import { SandboxAdapter } from '../../security/sandboxCore';
import { sandboxHttpFetch } from './sandboxFetch';
import { defineTool } from '../defineTool';

/**
* HTTP Tool Configuration Options
Expand Down Expand Up @@ -403,38 +403,32 @@ export async function makeHttpRequestViaSandbox(
* Factory function to create an HTTP tool with custom options
*/
export function createHttpTool(options: HttpToolOptions = {}): ToolDescriptor {
return {
return defineTool({
name: 'http_request',
displayName: 'Make HTTP request',
tool: tool({
description: 'Makes HTTP requests to specified URLs with configurable method, headers, and body. Supports GET, POST, PUT, DELETE, and PATCH methods.',
parameters: z.object({
url: z.string().describe('The URL to make the request to (must be a valid HTTP/HTTPS URL)'),
method: z.enum(['GET', 'POST', 'PUT', 'DELETE', 'PATCH']).describe('The HTTP method to use'),
headers: z.record(z.string()).optional().describe('Optional headers to include in the request as key-value pairs'),
body: z.string().optional().describe('The body of the request. For POST/PUT/PATCH, this should be a JSON string. Not used for GET/DELETE.'),
}),
execute: async ({ url, method, headers, body }, executeOptions) => {
// `?.`: direct callers have historically passed no options object.
const signal = executeOptions?.abortSignal;
return makeHttpRequest({ url, method, headers, body, options, signal });
},
description: 'Makes HTTP requests to specified URLs with configurable method, headers, and body. Supports GET, POST, PUT, DELETE, and PATCH methods.',
input: z.object({
url: z.string().describe('The URL to make the request to (must be a valid HTTP/HTTPS URL)'),
method: z.enum(['GET', 'POST', 'PUT', 'DELETE', 'PATCH']).describe('The HTTP method to use'),
headers: z.record(z.string()).optional().describe('Optional headers to include in the request as key-value pairs'),
body: z.string().optional().describe('The body of the request. For POST/PUT/PATCH, this should be a JSON string. Not used for GET/DELETE.'),
}),
execute: async ({ url, method, headers, body }, ctx) => {
// `?.`: direct callers have historically passed no context object.
const signal = ctx?.abortSignal;
return makeHttpRequest({ url, method, headers, body, options, signal });
},
// LOU-K2: httpTool makes arbitrary, model-chosen outbound HTTP requests
// - the highest-risk built-in tool for a sandbox boundary to be
// meaningful on. A caller going through executeToolWithSandboxGuard()
// (AgentExecutor, resume.ts) gets the actual fetch routed through the
// configured SandboxAdapter via sandboxExecute(); execute() above is
// left unchanged (still real, directly callable) for callers that
// invoke descriptor.tool.execute() directly.
// invoke descriptor.execute() directly.
requiresSandbox: true,
sandboxExecute: async (args, sandbox, callOptions) => {
const { url, method, headers, body } = args as Omit<HttpRequestArgs, 'options' | 'signal'>;
return makeHttpRequestViaSandbox(
{ url, method, headers, body, options, signal: callOptions?.abortSignal },
sandbox
);
},
};
sandboxExecute: async (args, sandbox, callOptions?: ToolExecutionContext) =>
makeHttpRequestViaSandbox({ ...args, options, signal: callOptions?.abortSignal }, sandbox),
});
}

/**
Expand Down
Loading
Loading