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 @@ -29,6 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- The project is now **lousho** (it was `loushy`), before the first npm release, so nothing was ever published under the old name. Everything that carried the name changed with it, and this changelog uses the new names throughout, including in older entries: the package `@lousho/build-ai-agent` (was `@loushy/build-ai-agent`), the `lousho` CLI (was `loushy`), `create-lousho-agent` (was `create-loushy-agent`), the exports `useLoushoAgent`, `loushoAgent`, `LoushoAgentSource`, `LoushoUIMessageChunk` and the other `Lousho*` types, every `LOUSHO_*` error code and environment variable (`LOUSHO_MODEL`, `LOUSHO_API_TOKEN`, `LOUSHO_STORE`, ...), the `.lousho/` directory, the `lousho.*` span attributes and the `data-lousho-approval` stream part. Migration for a checkout that used the old name: replace `loushy` with `lousho` (keeping the case) in imports, scripts, environment variables and config, and rename an existing `.loushy/` directory to `.lousho/`.

### Added
- `openApiTools(document, options)` (N8): an OpenAPI 3.0 / 3.1 document (object, JSON or YAML string, or https URL) becomes one typed tool per operation, exported from the root and `./tools`. Names come from `operationId` (or `<method>_<path>`); the input has path, query, header and cookie parameters plus an `application/json` `body`, with `$ref`s resolved; every HTTP response is returned as `{ status, statusText, body }`. GET, HEAD and OPTIONS run and other methods ask for approval by default (`approval`), with read-only / destructive annotations. Options: `baseUrl`, `include`, `exclude`, `prefix`, `headers`, `bearerToken`, `providedArguments` (removed from the model's schema), `timeoutMs`, `maxResponseChars`, `fetch`. The base URL must be https (http only for loopback), credentials never reach events or the transcript, and redirects are followed only on the base origin. Swagger 2.0, multipart bodies and remote `$ref`s are refused. `jsonSchemaToZod(schema, root?)` takes an optional root for `$ref` resolution. New page docs/openapi-tools.md.
- Built-in OpenAI, Anthropic and OpenRouter providers send PDF file parts on `ai` 6 and 7 (Anthropic also `text/plain`); `ai` 4 and Ollama keep the text note. A provider subclass opts more media types in with `fileMediaTypes()` (`acceptsFileParts = true` still sends every type). The text-note warning is now once per provider and media type and names the type and the `ai` major. Added `npm run test:live` (`vitest.live.config.ts`) for `*.live.test.ts` files, which the default suite excludes. See docs/providers.md#multimodal-input.
- Remote sub-agent approvals go through the lead run (LOU-Y7.3): a `remoteAgent()` task whose remote run pauses for a tool approval now pauses the lead run the way a local sub-agent does (it used to fail with `LOUSHO_SESSION_AWAITING_APPROVAL`): the lead's pending approval (`agent.approvals.list()`, channel buttons, the dev chat, ACP permission requests) has the remote tool's name and input and `subagentPath: [<remote agent>]`, and a remote `ask_question` arrives as a question. Deciding it on the lead (`resolve`, `streamResolve`, `answer`) posts the decision to the remote `POST <url>/chat/:sessionId/approvals/:id` and the continuation's final answer is the `task` result; a further pause pauses the lead again. The lead's approval snapshot stores the remote session id, the remote approval id, the `taskId` and the agent name (never the token), so a fresh lead process on the same store can decide it. Failures while deciding (401, other non-2xx including the remote's 404 for an approval no longer pending, network) are the `task` call's structured tool error with `LOUSHO_REMOTE_UNAUTHORIZED` / `LOUSHO_REMOTE_REQUEST_FAILED`. A remote agent with an `output` schema now returns its object as JSON with the footer (the V4.2 shape), read from `run.done`'s `object`. `RemoteSubagent.run()` takes `pausable` and `decision` (type `RemoteRunOptions`); the internal session client gains `resolveRemoteApproval()` and `SessionTurnSummary.object`. Only a lead run without an approval store keeps the old `LOUSHO_SESSION_AWAITING_APPROVAL` error. See docs/sub-agents.md#remote-approvals.
- Structured output for sessions and sub-agents, typed for either zod major (LOU-V4.2): `createAgent({ output })` and `ExecuteOptions.output` accept a zod 3 schema, a zod 4 schema (`zod/v4` on zod 3.25, or zod 4) or a Standard Schema that can produce JSON Schema, and `result.object` is inferred from each without a cast (`InferSchemaOutput`, as `defineTool`). `AgentSession` is generic (`AgentSession<TObject = unknown>`): `agent.session().send()` and `.stream()` results carry the typed `object`. A sub-agent with its own `output` returns its validated object as JSON (then the `taskId` footer) as the `task` and `agent_await` result; its `output-invalid` finish is a structured tool error. Sub-agents do not inherit the lead's `output`; `remoteAgent()` returns text only. The exported spec schemas (`agentSpecSchema`, ...) are typed as `SpecSchema<T>` / `SpecObjectSchema<T>`, so the published `schema-*.d.ts` no longer depends on zod 3 generics (`skipLibCheck: false` projects on zod 4). The Ollama missing-peer note now says `ollama-ai-provider-v2` needs zod 4 (`npm install zod@^4.0.0`). See docs/structured-output.md.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ const { text } = await agent.session({ id: 'user-42' }).send('What is my name?')
| [Quick Start](docs/quick-start.md) | Runnable, verified snippets: `createAgent()`, tools, streaming, sessions, approvals, offline tests, spec files |
| [Configuration](docs/configuration.md) | Spec fields, the `mcpServers` field, provider env vars, retries and fallback, `createAgent()` options, budgets, project instructions |
| [MCP](docs/mcp.md) | Use MCP servers as tools (`mcpServers`, `connectMcp()`, `loadMcpTools()`), approval for MCP tools, serve an agent with `serveMcp()` / `lousho mcp` |
| [OpenAPI tools](docs/openapi-tools.md) | `openApiTools()`: an OpenAPI 3.0 / 3.1 document becomes one tool per operation, with approval for mutating ones |
| [Providers](docs/providers.md) | Model strings, `resolveProvider()`, which model runs, custom providers |
| [CLI](docs/cli.md) | Every `lousho` command and its flags |
| [ACP](docs/acp.md) | `lousho acp` / `serveAcp()`: drive an agent from Zed and other Agent Client Protocol editors |
Expand Down
157 changes: 157 additions & 0 deletions docs/openapi-tools.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
# OpenAPI tools

Most services publish an OpenAPI document and no MCP server. `openApiTools()`
turns that document into one typed tool per API operation, so an agent can call
the API without a hand-written `defineTool()` per endpoint and without the raw
[`http` tool](./tools.md#built-in-tools). Operations that change data ask for
[approval](./approvals.md) by default.

`openApiTools` is exported from `@lousho/build-ai-agent` and
`@lousho/build-ai-agent/tools`. It needs no extra package.

## Quick start

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

const tools = await openApiTools('https://api.example.com/openapi.json', {
include: ['getOrder', 'refundOrder'],
bearerToken: () => process.env.ORDERS_API_TOKEN ?? '',
});

const agent = createAgent({ model: 'openai/gpt-4o-mini', tools });
const paused = await agent.send('Refund order 1042');
// getOrder ran. refundOrder (a POST) is waiting for approval:
// agent.approvals.resolve({ id: paused.approvalId!, approved: true })
```

`openApiTools()` is always async, because the document may be a URL. It returns
an array of tools you pass to `createAgent({ tools })` like any other.

`document` can be a parsed OpenAPI 3.0 or 3.1 object, a JSON or YAML string, or
an https URL (a string or a `URL`). Parsing happens once, inside
`openApiTools()`; a tool's `execute` only builds and sends a request.

## What each tool looks like

| Part | Value |
| ---- | ----- |
| Name | The `operationId`, with characters outside `A-Za-z0-9_-` replaced by `_`. Without an `operationId`: `<method>_<path>` (`GET /orders/{orderId}` becomes `get_orders_orderId`). With `prefix`: `<prefix>__<name>`. At most 64 characters. Two operations with the same name throw a `ConfigurationError` that lists both. |
| Description | The operation's `summary`, then its `description` (cut at 1,000 characters), then the method and path (`GET /orders/{orderId}`). |
| Input | An object with every path, query, header and cookie parameter as a top-level property (required as the document declares), plus `body` for an `application/json` request body. Schemas follow `$ref`s into `components`, including `parameters` and `requestBodies`. OpenAPI 3.0 `nullable` works. A parameter whose name is already taken by another location is named `<location>_<name>`. |
| Result | `{ status, statusText, body }` for every HTTP response, error statuses too, so the model can read a 404 and react. `body` is parsed JSON when the response says JSON, otherwise text, empty as `null`. |
| Errors | A network failure, a timeout and a refused redirect are tool errors. |

`Accept`, `Content-Type` and `Authorization` header parameters are ignored, as
the OpenAPI specification says; use `bearerToken` or `headers` for credentials.

## Options

| Option | Default | Meaning |
| ------ | ------- | ------- |
| `baseUrl` | first `servers` entry | Where requests go. Overrides the document. For a document fetched from a URL, a missing `servers` entry means that URL's origin. |
| `include` | all operations | Operation names (`operationId` or derived name), or a function `(op) => boolean`. Naming an operation the document does not have throws. |
| `exclude` | none | Operation names to leave out. |
| `approval` | `'mutating'` | `'mutating'`, `'always'`, `'never'`, or `(op) => boolean`. |
| `prefix` | none | Prefix for tool names. |
| `headers` | none | Headers sent on every request, or a function called per request. |
| `bearerToken` | none | Shorthand for `Authorization: Bearer <token>`; a function is called per request. |
| `providedArguments` | none | Values your application supplies; see below. |
| `timeoutMs` | `30000` | Per request. |
| `maxResponseChars` | `50000` | The response body is cut at this length, with a note saying how much was left out. |
| `fetch` | global `fetch` | Replaces `fetch` (tests, proxies). |

`op` is `{ name, operationId?, method, path, summary?, tags }`.

## Approval defaults

With `approval: 'mutating'`, GET, HEAD and OPTIONS run, and every other method
asks. Each tool also carries MCP-style annotations: `readOnlyHint: true,
destructiveHint: false` on GET, HEAD and OPTIONS; `readOnlyHint: false` on the
rest, with `destructiveHint: true` for DELETE. Anything that reads annotations treats these tools the way it treats MCP tools.

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

declare const spec: object;

// Ask for everything except two reads that are known to be harmless.
const tools = await openApiTools(spec, {
approval: (op) => !['getOrder', 'listOrders'].includes(op.name),
});
```

## Values your application supplies

`providedArguments` fills parameters the model should not choose, such as a
tenant id. A provided key is removed from the tool's input schema (the model
never sees it) and added to the request. The value can be a function, called per
request with the operation and the tool call id.

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

declare const spec: object;
declare const currentTenant: () => string;

const tools = await openApiTools(spec, {
providedArguments: {
'X-Tenant-Id': () => currentTenant(),
accountId: 'acct_123',
},
});
```

The key is the parameter's name (or `body` for the request body). A key that no
selected operation has throws, so a typo does not silently expose the parameter
to the model.

## Security rules

- **The base URL is yours, not the model's.** The model chooses parameter and
body values; it never chooses the host. The base URL must be https. Plain
http is accepted only for `localhost`, `127.0.0.1` and `[::1]`, and a URL with
a user name or password is refused. The same rule applies to a document URL.
- **Credentials stay out of what the model and your logs see.** Headers and
tokens are never part of a tool's input schema, its `tool.start` and
`tool.done` events or the transcript; only the model's own input and the
response are. A header the model supplies for a declared header parameter
never replaces `headers` or `bearerToken`.
- **Redirects stay on the base origin.** Requests use manual redirects: at most
3 are followed, and only to the same origin as the base URL. A redirect to
another origin is a tool error, so a token never leaves the configured origin.
- **Path values stay in their segment.** Path parameters are percent-encoded
(`../admin` cannot climb out of its segment), and a value of exactly `.` or
`..` is refused.
- **No private-address check.** The tools call the base URL you configured. If
that URL can resolve to an internal address that you do not control, put a
proxy in front or pass your own `fetch`. The `http` tool's address checks are
for model-chosen URLs.

## Large APIs

An API with hundreds of operations is hundreds of tools. Give the agent the ones
it needs with `include` or `exclude`, or with a predicate on `op.tags`:

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

declare const spec: object;

const tools = await openApiTools(spec, { include: (op) => op.tags.includes('orders') });
```

## Limits

- OpenAPI 3.0 and 3.1 only. A Swagger 2.0 document throws a `ConfigurationError`
that asks you to convert it first.
- Request bodies must be JSON (`application/json` or `+json`). An operation
with only a multipart, form or XML body is left out; naming it in `include`
throws.
- Only the first `servers` entry is used, with variables at their defaults.
Path-level and operation-level `servers` are ignored.
- Every `$ref` must be local (`#/components/...`); a reference to another file
or URL throws. Bundle the document first.
- No OAuth flows. `bearerToken` and `headers` cover static and per-request
credentials.
- Response bodies are read as text, and responses are not streamed.
1 change: 1 addition & 0 deletions docs/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ its kind by carrying a `toolErrorKind` property. An error extending
| `createFsTools()`, `createShellTool()` | File system and shell tools for coding agents; see [Workspace tools](./workspace-tools.md). |
| `createEmailTool()`, `createSlackTool()`, `createGitHubTools()`, `createJiraTools()` | Integrations that need credentials, so they are built with options. |
| `createAgent({ mcpServers })`, `connectMcp(servers)` | Every tool of MCP servers given as config (stdio `command` or HTTP `url`), named `<server>__<tool>`; see [Use MCP servers in an agent](./mcp.md#use-mcp-servers-in-an-agent). |
| `openApiTools(document, options)` | One tool per operation of an OpenAPI 3.0 / 3.1 document; mutating operations ask for approval. See [OpenAPI tools](./openapi-tools.md). |
| `loadMcpTools(client, name)` | Every tool of a connected MCP server; see [MCP tools](./mcp.md#tools-from-a-client-you-connected-yourself). |

Built-in descriptors are passed keyed by the name the agent uses:
Expand Down
Loading
Loading