Merge origin/master into CharlieKolb/continue-merged-branch

Scope the tsgo typecheck enablement to editor-ui only and drop the
minimumReleaseAgeExclude entries.

CI showed the bridge's tsgo declaration emitter panics while building the
frontend libraries' `.d.ts` (`@n8n/chat#build`: "Unknown parent for
parameter: KindArrowFunction"), because a per-package `typescript` override
routes the build's declaration emit through the bridge too, not just
`vue-tsc --noEmit`. editor-ui is an app that emits no declarations, so it's
the one package that can safely typecheck on tsgo; the libraries stay on
stock TS6. bridge.7 has now aged past minimumReleaseAge, so the exclude is
no longer needed.
This commit is contained in:
Charlie Kolb 2026-07-31 13:49:39 +02:00
commit bb67bbea6b
No known key found for this signature in database
1744 changed files with 74846 additions and 15287 deletions

View File

@ -46,6 +46,10 @@ throw new UnexpectedError('message', { extra: { context } });
- SQLite/PostgreSQL only (app DB)
- Exception: DB nodes (MySQL Node, etc.) can use DB-specific features
**GitHub Workflows:**
- Every workflow declares a least-privilege top-level `permissions:` block
(usually `contents: read`); jobs needing more override at job level
**Commands:**
```bash
pnpm build > build.log 2>&1 # Always redirect

View File

@ -31,7 +31,7 @@ exhaustive field reference; this skill is the opinionated *how*.
>
> **Exception — seeded cases.** The case-write API can't hold any seeding mode, so
> seeded cases are never pushed. A `seedThread` case is a local throwaway (don't
> commit it either — it dies when its trace is pruned); a `seedFile` or
> commit it either — it dies when its trace is pruned); a `conversationSeed` or
> `priorConversation` case isn't transient and has no suite home, so it's the one
> sanctioned exception — it lives as committed JSON. See [`case-shapes.md`](case-shapes.md).
@ -104,7 +104,7 @@ case can still assert outcome), but the primary shape drives the work.
| **Build** (default) | Does the workflow the agent builds actually *work*? | `outcomeExpectations` + `executionScenarios` |
| **Behaviour / process** | Does the agent *converse* correctly (ask the right clarifying question, not re-ask, honour a correction, respect plan approval)? | `processExpectations` + multi-turn director script; often **build-only** |
| **Credential** | Does the build behave correctly given a specific credential view? | `credentials[]` |
| **Seeded** | Reproduce a conversation mid-thread and drive the turn under test | `seedThread` / `priorConversation` / `seedFile` |
| **Seeded** | Reproduce a conversation mid-thread and drive the turn under test | `seedThread` / `priorConversation` / `conversationSeed` |
**Build** is documented in full below. The other three, the director-script
vocabulary, and the seeding modes are in [`case-shapes.md`](case-shapes.md).
@ -207,7 +207,7 @@ calibration you hand the driver the thread link + login to review the real build
case's home, not the repo. Leave the `data/workflows/*.json` file uncommitted
(or delete it once it's in the suite). Committing new case JSONs into the repo
is no longer the approach. (Exception: seeded cases can't be pushed — a
`seedFile`/`priorConversation` case stays committed JSON, a `seedThread` case is
`conversationSeed`/`priorConversation` case stays committed JSON, a `seedThread` case is
a local throwaway; see [`case-shapes.md`](case-shapes.md).) For a sourced case,
finish by **linking it to its source thread/finding** over the MCP — see
[Link the pushed case to its source](#link-the-pushed-case-to-its-source-provenance-step--always-do-this).
@ -661,10 +661,10 @@ npx dotenvx run -f .env.eval -- pnpm eval:langtracer-push --suite baseline --cha
deployment predating that change silently ignores the key; if a pushed scenario
edit doesn't land, update the scenario in the lang-tracer UI.
- **Seeded cases can't be pushed:** the case-write API rejects every seeding mode
(`seedThread` / `seedFile` / `priorConversation`), so the push lists them under
(`seedThread` / `conversationSeed` / `priorConversation`), so the push lists them under
`skipped:` and they never reach the suite. A `seedThread` case shouldn't be
committed either — it dies when its trace is pruned or deleted — so derive a
durable synthetic case as the artifact instead. A `seedFile`/`priorConversation`
durable synthetic case as the artifact instead. A `conversationSeed`/`priorConversation`
case isn't transient and has no suite home, so it's the one exception to
"don't commit the JSON" — it lives as a committed artifact.

View File

@ -16,7 +16,7 @@ The schema
([`harness/schema.ts`](../../../packages/@n8n/instance-ai/evaluations/harness/schema.ts))
enforces the rules you must respect:
- `seedFile`, `priorConversation`, `seedThread` are **mutually exclusive** — pick
- `conversationSeed`, `priorConversation`, `seedThread` are **mutually exclusive** — pick
one seeding mode.
- A case needs a `conversation` **or** a `seedThread` (which supplies the live
turn from the trace).
@ -149,7 +149,7 @@ Pick the lightest mode that fits:
|---|---|---|
| Reproduce a real conversation (common case) | `seedThread` — fetch + reconstruct its LangSmith trace at run time; nothing committed | supplies its own live turn (omit `conversation`) |
| Prelude is just "what was discussed" (no tool calls, no workflows) | `priorConversation` — prose turns, authored inline | a normal `conversation` for the live turn |
| A synthetic/sanitised fixture you want durable in git | `seedFile` — a committed seed JSON (never real conversation data) | a normal `conversation` for the live turn |
| Prior work already exists (a workflow to repair) | `conversationSeed` — prior messages + the workflows they reference, in the case body | a normal `conversation` for the live turn |
| Shallow 2–3 turn prelude where the agent's live replies matter | none — a plain multi-turn `conversation` re-drives it live | — |
All three modes are implemented and wired (`harness/conversation-seed.ts` +
@ -175,7 +175,7 @@ after the correction. Asserting on the seeded prelude itself proves nothing.
### Which mode — and when to avoid seedThread
Default to a **synthetic** case (an authored prompt + director script, or a
`priorConversation` / `seedFile` prelude): it's durable, carries no real user
`priorConversation` / `conversationSeed` prelude): it's durable, carries no real user
data, never expires, and you control the setup exactly. Reach for **`seedThread`** only when
the misbehaviour genuinely needs real prior context that's impractical to
synthesize — a long accumulated thread, specific built workflows/tables — **and**
@ -236,10 +236,10 @@ which user turn goes live.
(a synthetic case whose `executionScenarios` precondition builds the stand-in),
or grade the live turn with `processExpectations` only.
- **Can't be pushed to a lang-tracer suite either.** The case-write API rejects
every seeding mode (`seedThread` / `seedFile` / `priorConversation`), so
every seeding mode (`seedThread` / `conversationSeed` / `priorConversation`), so
`eval:langtracer-push` silently lists them under `skipped:`. Combined with the
don't-commit rule above, a `seedThread` case has **no durable home by design** —
the durable artifact is always the synthetic case you derive from it. (`seedFile`
the durable artifact is always the synthetic case you derive from it. (`conversationSeed`
and `priorConversation` carry no thread dependency and can't be pushed either, so
— unlike a normal case — they're the one exception to the skill's "push, don't
commit the JSON" rule: they live as committed artifacts.)
@ -256,11 +256,20 @@ which user turn goes live.
Plain text only — no tool calls, no restored workflows. Paired with a normal
`conversation` for the live turn.
### `seedFile` — durable synthetic fixture
### `conversationSeed` — durable synthetic fixture
For a **synthetic, sanitised** fixture pinned in git (never a real user's
conversation): hand-author `data/workflows/seeds/<name>.seed.json` (schema in
For a **synthetic, sanitised** seed pinned in git (never a real user's
conversation): author the prior messages, plus the workflows they reference, in
the case body (schema in
[`harness/conversation-seed.ts`](../../../packages/@n8n/instance-ai/evaluations/harness/conversation-seed.ts)
— `messages` + optional `workflows` + `dataTables`) and point `seedFile` at it.
Real conversations belong in `seedThread`, which keeps their content out of the
repo.
— `messages` + optional `workflows` + `dataTables`). Real conversations belong in
`seedThread`, which keeps their content out of the repo.
Two constraints that bite: a workflow `id` must be ≥8 characters (the id remap
refuses shorter ones), and a seeded `build-workflow` tool call's
`output.workflowId` must match the seeded workflow's `id` — otherwise the remap
separates them and the agent can't find the workflow it should act on.
The seed sits in the case body, not a sibling file, so it travels with the case
whether it comes off disk, out of a LangTracer suite, or from a dispatched case
body.

View File

@ -56,7 +56,7 @@ connected.)
5. **Push it to a curated suite** (don't commit the JSON) with
`eval:langtracer-push` — see
[Push to a lang-tracer suite](SKILL.md#push-to-a-lang-tracer-suite). Exception:
seeded cases (`seedThread` / `seedFile` / `priorConversation`) can't be pushed —
seeded cases (`seedThread` / `conversationSeed` / `priorConversation`) can't be pushed —
the case-write API rejects every seeding mode, so the push lists them under
`skipped:`. And a `seedThread` case shouldn't be committed either — it dies when
its trace is pruned or deleted — so it has no durable home; that's exactly why

View File

@ -1,23 +1,62 @@
---
name: n8n:public-api
description: >-
Adds or updates n8n Public API v1 endpoints, OpenAPI specs, and handler wiring.
Use when creating public API handlers, registering paths in openapi.yml, or adding OpenAPI tags. Always use @PublicApiController for new endpoints (API-37+).
Adds, migrates, or updates n8n Public API v1 endpoints with @PublicApiController
— public DTOs, API-key and RBAC scopes, cursor pagination, OpenAPI + coverage
wiring, and tests. Use when working under packages/cli/src/public-api/v1/ or
when exposing an existing service through /api/v1.
---
# Public API v1
Public API lives under `packages/cli/src/public-api/v1/`.
Public API v1 lives in `packages/cli/src/public-api/v1/`, mounted at `/api/v1`
with API-key auth and public error formatting via `PublicApiControllerRegistry`
(`packages/cli/src/public-api/public-api-controller.registry.ts`).
**Always use `@PublicApiController` for new endpoints (API-37+).** Do not add new business logic to `express-openapi-validator` handlers. Controllers use the same decorator style as internal `@RestController`, mounted at `/api/v1` with API-key auth and public errors via `PublicApiControllerRegistry`.
Two rule tiers: **invariants** (never break) and **team defaults** (follow unless
an existing public contract forces otherwise). When this skill and the code
disagree on a detail, the code wins — so open the files below. That is a reason to
check the code, not license to drop a team default.
**Legacy (existing endpoints only):** `express-openapi-validator` handlers under `handlers/`; OpenAPI path specs are YAML under each handler's `spec/` directory.
When you touch a legacy endpoint, migrate it to `@PublicApiController` rather than extending the eov handler. Do not mix data-access styles within a new feature.
## Non-negotiable rules
- New endpoints are `@PublicApiController` classes under `v1/controllers/`, one
`*.public.controller.ts` per feature. A controller is a class — never
`export =` (the legacy tuple style; `require-public-api-controller` flags it).
- Public API and internal REST are separate HTTP surfaces. A public controller
never calls an internal controller/endpoint; both reuse the same service.
- Controllers and handlers delegate to a service — never import a repository or
`Container.get(…Repository)` (`no-repository-in-public-api-handler`).
- Input/output go through DTOs from `@n8n/api-types`; every JSON route declares
`@ApiResponse(Dto)`.
- Register each controller via a side-effect import in `v1/controllers/index.ts`
(`public-api-controllers.test.ts` fails otherwise).
- Don't add business logic to legacy `express-openapi-validator` (EOV) handlers.
- Migrating a legacy endpoint must not change its public contract.
These are `n8n-local-rules` ESLint rules (see `packages/cli/eslint.config.mjs`)
and can't be silenced inline (`no-public-api-guardrail-disable`). The `off`
allowlist there covers pre-existing legacy files only — it's shrink-only, don't
add to it.
## Team defaults
- List endpoints: cursor-based pagination (internal API uses both cursor- and
page-based — don't copy an internal endpoint's model).
- Updates: full-object `PUT`, not `PATCH`. A successful `GET` body should be
acceptable as a `PUT` body for the same resource (round-trip), aside from
server-managed/immutable fields.
- Strict input DTOs; output DTOs are an allowlist of public fields.
- Never return real secrets/tokens in responses or error details — mask with the
resource's sentinel/placeholder (or omit). Echoing that sentinel on `PUT`
means keep; any other value replaces. Detail:
[Updates and write-only secrets](reference.md#updates-and-write-only-secrets).
- "Test connection/config" endpoints validate the request body (test-before-save).
## Architecture
Convergence is at the **service layer**, not HTTP. Public and internal stay as
two sibling routes over one shared service — neither calls the other:
Public and internal are sibling routes over one shared, HTTP-agnostic service;
neither calls the other.
```
GET /rest/tags → TagsController ┐ JWT auth, internal shape
@ -25,95 +64,101 @@ GET /rest/tags → TagsController ┐ JWT auth, internal shape
GET /api/v1/tags → TagsPublicController ┘ API-key auth, public DTO
```
## Adding an endpoint (required: controller pattern)
Reuse the service behavior. Reuse a DTO only when public and internal contracts
are intentionally identical; otherwise make a public-specific DTO that doesn't
depend on a UI-oriented internal shape.
Reference: `v1/controllers/tags.public.controller.ts` (`GET /tags`).
## Before editing
1. **Public DTOs** in `@n8n/api-types` — input query/body + output resource DTO
(distinct from internal shapes when they diverge). Use `Z.class` so the
registry can validate/parse. Wrap list payloads as objects (e.g. `{ data: [...] }`),
same as `TagListPublicDto` / `WorkflowVersionHistoryListPublicDto`.
2. **Controller** — `v1/controllers/<feature>.public.controller.ts`
```ts
@PublicApiController('/tags')
export class TagsPublicController {
constructor(private readonly tagService: TagService) {}
Open these — they are the source of truth, not this skill:
@Get('/')
@ApiKeyScope('tag:list')
@ApiResponse(TagListPublicDto)
async getTags(_req, _res, @Query q: ListTagsQueryDto) { /* call service */ }
}
```
- Reuse `@Get/@Post/@Body/@Query/@Param/@GlobalScope/@ProjectScope` as-is.
- For `@ProjectScope` on workflow/credential routes, name the path param
`workflowId` / `credentialId` (or `projectId` / `dataTableId`) — the
registry passes `req.params` to `userHasScopes`, which does not remap `id`.
- `@ApiKeyScope` — string, or `{ anyOf }` / `{ allOf }` (no bare arrays).
- `@ApiResponse(Dto)` — registry `.parse()`s the return value (strips
undeclared fields). Shape is provisional until API-39 (doc-gen).
- Delegate to the same service as the internal REST controller.
3. **Side-effect import** the controller from
`packages/cli/src/public-api/v1/controllers/index.ts` (re-exported via
`public-api/index.ts`) so metadata is registered before
`PublicApiControllerRegistry.activate`. Name the file
`*.public.controller.ts` — `public-api-controllers.test.ts` fails CI if a
controller file is missing from that barrel or lives outside `controllers/`.
4. **OpenAPI path spec** — still required for docs + `scope-parity.test.ts`
(`handlers/<feature>/spec/paths/…`, `$ref` in `openapi.yml`). Set
`operationId`, `x-required-scope` matching `@ApiKeyScope`. **Do not** set
`x-eov-operation-id` / `x-eov-operation-handler` — those are legacy eov-only;
scope-parity and discover read `@ApiKeyScope` from the controller by matching
method + path.
5. **Coverage manifest** — add every new OpenAPI endpoint to
`packages/nodes-base/nodes/N8n/n8n-api-coverage.json`.
- `v1/controllers/` — copy structure from `tags.public.controller.ts` (list +
cursor) or `workflows.public.controller.ts` (`@Param` + `@ProjectScope`), and
`index.ts` for the barrel.
- Decorators in `packages/@n8n/decorators/src/controller/`:
`public-api-controller.ts`, `api-key-scope.ts`, `api-response.ts`, `route.ts`,
`scoped.ts`, `args.ts`, `licensed.ts`.
- Pagination helpers: `v1/shared/services/pagination.service.ts`
(`decodeCursor`, `encodeNextCursor`).
- DTOs: `packages/@n8n/api-types/src/dto/`.
- Gating tests: `v1/__tests__/public-api-controllers.test.ts`,
`v1/__tests__/scope-parity.test.ts`.
- The internal controller for this resource and its neighboring functional tests.
## Legacy eov handlers (do not use for new endpoints)
## Declaring a controller
Only for maintaining endpoints not yet migrated:
A controller is a class marked `@PublicApiController('/base')` that injects the
shared service via its constructor and delegates to it. Copy the shape from an
existing controller in `v1/controllers/` with the same operation type and auth
model; reuse only what applies. Decorators, all from `@n8n/decorators`:
1. **Handler** — `handlers/<feature>/<feature>.handler.ts`
- Export middleware arrays keyed by `x-eov-operation-id`.
- For scope-protected endpoints, use `publicApiScope()` or
`apiKeyHasScopeWithGlobalScopeFallback()` (they tag the scope-enforcement
middleware with `__apiKeyScope`) and add matching `x-required-scope` in the
path YAML; use `none` for endpoints without an API-key scope (see
`scope-parity.test.ts`).
- **Delegate to the same service layer as the internal REST API.**
Parse input with `@n8n/api-types` DTOs, call `Container.get(SomeService)`,
map errors — do not reach into repositories or duplicate business logic.
2. **OpenAPI path spec** — `handlers/<feature>/spec/paths/<path>.yml`
- Set `tags: [<TagName>]` matching a top-level tag in `openapi.yml`.
3. **Register path** — add a `$ref` entry under `paths:` in `openapi.yml`.
4. **Request types** — add a namespace in `packages/cli/src/public-api/types.ts`
when the handler needs typed query/body params.
5. **Coverage manifest** — add every new OpenAPI endpoint to
`packages/nodes-base/nodes/N8n/n8n-api-coverage.json`.
| Decorator | Use |
|---|---|
| `@PublicApiController('/base')` | Class marker; mounts routes at `/api/v1/base`. |
| `@Get/@Post/@Put/@Patch/@Delete('/path')` | Route method. |
| `@ApiKeyScope('res:action')` | API-key grant check. |
| `@ProjectScope/@GlobalScope('res:action')` | User RBAC check. |
| `@ApiResponse(Dto)` | Output DTO; registry `.parse()`s + strips the return value. |
| `@Query` / `@Body` / `@Param('name')` | Bind + validate via a `Z.class` DTO / path param. |
| `@Licensed('feat')` | Gate an EE-only endpoint. |
## `tags` array in `openapi.yml`
## Authorization (easy to get wrong)
When adding or editing the top-level `tags:` array in
`packages/cli/src/public-api/v1/openapi.yml`, **keep entries sorted
alphabetically by `name`**. Insert the new tag in order; do not append to the end.
- `@ApiKeyScope` (what the API key is granted) and `@ProjectScope`/`@GlobalScope`
(what the user may do) are independent. Use both when the model needs both.
- `@ProjectScope` reads `req.params` as-is and does not remap `id` — name the path
param what the resolver expects (`workflowId`, `credentialId`, `projectId`,
`dataTableId`, …). A generic `id` often fails.
- `@ApiKeyScope` takes a string, `{ anyOf: [...] }`, or `{ allOf: [...] }` — never
a bare array. The scope must exist in the permissions registry
(`API_KEY_RESOURCES` in `@n8n/permissions`); `scope-parity.test.ts` fails on an
orphan scope.
```yaml
tags:
- name: Audit
description: Operations about security audit
- name: CommunityPackage
description: Operations about community packages
# … remaining tags in A→Z order by name
```
## DTOs
`scope-parity.test.ts` asserts this ordering — CI fails if tags drift out of
sort order.
- Build the public response shape explicitly; don't return an ORM entity and lean
on `@ApiResponse` stripping to hide fields.
- Treat the output DTO as an allowlist. Re-check nested relations, ownership
fields, tokens, and encrypted values.
- Make input DTOs strict so unknown/partial fields aren't silently accepted.
- Secrets: never return a real secret; use the resource's sentinel/placeholder
(or omit). See [Updates and write-only secrets](reference.md#updates-and-write-only-secrets).
## Reference
## List endpoints (cursor pagination)
- **Controller pattern (required for new work):** `v1/controllers/tags.public.controller.ts`, `v1/controllers/workflows.public.controller.ts`
- **Registry:** `packages/cli/src/public-api/public-api-controller.registry.ts`
- Legacy eov examples (migration targets, not templates for new endpoints):
`handlers/insights/`, `handlers/variables/`, `handlers/folders/`,
`handlers/projects/`, `handlers/data-tables/`, `handlers/workflows/`
- Multipart: `handlers/n8n-packages/` (see also
`packages/cli/src/modules/n8n-packages/CLAUDE.md`)
Copy the cursor flow from `tags.public.controller.ts`. Use `publicApiPaginationSchema`
plus `decodeCursor` / `encodeNextCursor` from the shared pagination service; the
cursor is opaque; return `{ data, nextCursor }` (never a bare array) with
`nextCursor: null` on the last page; an invalid cursor is a `400`. Preserve an
existing endpoint's pagination as-is. Detail:
[List endpoints and cursor pagination](reference.md#list-endpoints-and-cursor-pagination).
## Wiring checklist
1. `v1/controllers/<feature>.public.controller.ts` + side-effect import in
`v1/controllers/index.ts`.
2. Public DTO in `@n8n/api-types` + export from the barrel (`src/dto/`).
3. `@ApiKeyScope` value exists in the permissions registry.
4. OpenAPI path with `x-required-scope` matching `@ApiKeyScope`; do **not** set
`x-eov-operation-*` for controller routes; keep top-level `tags:` sorted A→Z.
5. Add the route to `packages/nodes-base/nodes/N8n/n8n-api-coverage.json`.
6. Tests.
## Testing
Always cover: happy path, input-validation failure, missing API-key scope, RBAC
denial. Prefer covering the business path in
`packages/cli/test/integration/public-api/` (real HTTP + DB); mocked-service unit
tests don't replace that. Add the cases that apply (cursor pages,
not-found/conflict, no sensitive fields, credential keep/replace, migration
contract) — see [Testing matrix](reference.md#testing-matrix). Match the nearest
existing tests.
## More detail (reference.md)
- [List endpoints and cursor pagination](reference.md#list-endpoints-and-cursor-pagination)
- [Updates and write-only secrets](reference.md#updates-and-write-only-secrets)
- [Test-before-save endpoints](reference.md#test-before-save-endpoints)
- [Errors](reference.md#errors)
- [Testing matrix](reference.md#testing-matrix)
- [Migrating legacy EOV endpoints](reference.md#migrating-legacy-eov-endpoints)

View File

@ -0,0 +1,115 @@
# Public API v1 — reference
Detail for tasks that need it. Essentials and the rule tiers are in
[SKILL.md](SKILL.md). Open the cited files; they are the source of truth.
## List endpoints and cursor pagination
The internal API mixes cursor- and page-based pagination. New Public API list
endpoints use **cursor-based** pagination — do not copy an internal controller's
model.
Copy the working flow from `v1/controllers/tags.public.controller.ts` (and
`workflows.public.controller.ts` for a `@Param` list) rather than pasting a
snippet here — a copy would drift. The moving parts:
- Input DTO composes `publicApiPaginationSchema` from `@n8n/api-types` (a `limit`
plus an opaque `cursor`).
- `decodeCursor` / `encodeNextCursor` live in
`v1/shared/services/pagination.service.ts`. Decode the incoming cursor to
`{ offset, limit }`, guard the decoded shape, and pass `offset`/`limit` to the
service.
- Treat the cursor as opaque; never hand-encode a token.
- Return an envelope `{ data, nextCursor }` — never a bare array.
- `encodeNextCursor(...)` returns `null` when there is no further page; surface
that as `nextCursor: null`.
- An invalid/undecodable cursor is a `400` via the existing bad-request error.
- For an existing list endpoint, keep its current pagination semantics unchanged.
The output DTO wraps the list as `{ data, nextCursor }` and is declared with
`@ApiResponse(...)` so the registry strips undeclared fields.
## Updates and write-only secrets
- Default to `PUT`. The update DTO describes the full mutable object; the
validation layer rejects a partial payload (typically `400`). Don't implement
merge semantics behind a `PUT`.
- Don't add a new `PATCH` unless the task explicitly requires partial-update
semantics or an established resource-specific exception applies.
- Migrating an update endpoint keeps its current public HTTP semantics.
Write-only secrets (credentials, tokens, keys) support GET→PUT round-trip via a
resource-specific sentinel/placeholder (e.g. credentials use
`CREDENTIAL_BLANKING_VALUE` / related helpers — do not invent a new format):
- `GET` never returns the real secret; it returns that sentinel (or omits the
field). Never echo a real secret in responses or error details (including
test-connection and upstream error messages).
- On `PUT`, sending the **exact** sentinel from `GET` means **keep** the stored
secret. Sending **any other** value means **replace** it. Do not treat
"looks masked" or "field omitted" as keep unless the resource helper/tests say
so.
- Reuse the resource's redact/unredact (or equivalent) helper in the service —
don't reimplement sentinel detection or credential merge in the controller, and
never persist the sentinel as a real secret.
- Sentinel support does not make the whole `PUT` a partial update; other required
client-manageable fields stay required. Server-managed/immutable fields from
`GET` (`id`, timestamps, …) follow the resource DTO (ignored or not required on
write).
## Test-before-save endpoints
A connection/config-test endpoint validates the config in the **request body**,
not stored state — unless the endpoint explicitly verifies an already-saved
resource. Secret handling is the same as any other endpoint — see above.
## Errors
- Don't leak persistence errors, stack traces, or internal messages. Reuse
existing domain errors when the registry already maps them to the right public
errors; otherwise map at the controller boundary.
- Follow the error semantics of the nearest existing public controller; invalid
input and invalid cursors use the existing bad-request pattern.
- When migrating, preserve the documented status codes and public error behavior.
## Testing matrix
Always (in SKILL.md): happy path, input-validation failure, missing API-key
scope, RBAC denial. Add whichever apply, matching the nearest existing tests:
- At least one integration test under `packages/cli/test/integration/public-api/`
that exercises the real service/DB path for the main success case (and
paging/RBAC where they matter). Controller unit tests with a mocked service are
fine for wiring/validation edges — not as the only coverage of behavior.
- Cursor paging: first page, final page with `nextCursor: null`, invalid cursor,
`limit` handling.
- Not-found and conflict semantics.
- Response carries no sensitive/internal fields.
- Credential resources: response has no real secret (sentinel or omitted);
`PUT` with the exact sentinel keeps the secret; any other value replaces it;
the sentinel is never persisted as a real secret.
- Migration: path, method, status codes, scope, and response contract are
unchanged.
## Migrating legacy EOV endpoints
Legacy `express-openapi-validator` endpoints live under
`v1/handlers/`, wired through `openapi.yml` with `x-eov-operation-*` and request
types in `packages/cli/src/public-api/types.ts`. Treat these as migration targets,
not templates.
- Prefer migrating to `@PublicApiController` over extending the handler.
- Preserve the public contract: path, method, scopes, status codes, response
shape, and pagination.
- Move HTTP concerns into the controller and business orchestration into the
shared service; keep the controller a thin HTTP boundary.
- A route must be served by either the EOV handler or a controller, not both —
`scope-parity.test.ts` rejects a duplicated route. Remove the legacy wiring
(its `x-eov-operation-*` entry and handler middleware) only after the new
controller is registered and the spec + tests are updated.
- As a legacy file drops repository access / the `export =` tuple, remove its
entry from the `off` allowlists for `no-repository-in-public-api-handler` and
`require-public-api-controller` in `packages/cli/eslint.config.mjs` (shrink-only
— never extend them).
- For complex legacy-only, multipart, or non-standard endpoints, study the
nearest existing handler first.

7
.github/CLAUDE.md vendored
View File

@ -30,7 +30,12 @@ Reusable workflows: add `-reusable` or `-callable` suffix.
### Common Tasks
**Add workflow:** Create in `workflows/`, document in `WORKFLOWS.md`
**Add workflow:** Create in `workflows/`, document in `WORKFLOWS.md`.
Always declare a least-privilege top-level `permissions:` block (usually
`contents: read`) — without one the workflow runs with the repo's broad
default token and review flags it. Jobs needing more override at job level;
a job calling a reusable workflow must grant at least what that workflow
declares.
**Add script:** Create `.mjs` in `scripts/`, document in `WORKFLOWS.md`

View File

@ -179,7 +179,7 @@ These only run if specific files changed:
| `**/package.json`, `**/turbo.json` | `build-windows.yml` | master |
| `packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/**` | `test-evals-python.yml` | any |
| `packages/@n8n/benchmark/**` | `build-benchmark-image.yml` | master |
| `packages/cli/src/public-api/**/*.{css,yaml,yml}` | `util-sync-api-docs.yml` | master |
| `packages/cli/src/public-api/**/*.{css,yaml,yml}` | `util-publish-api-schema.yml` | master |
| `packages/@n8n/instance-ai/src/**`, `packages/@n8n/instance-ai/skills/**`, `packages/@n8n/instance-ai/knowledge-base/**`, `packages/@n8n/instance-ai/evaluations/**`, `packages/cli/src/modules/instance-ai/**`, `packages/core/src/execution-engine/eval-mock-helpers.ts` | `ci-instance-ai-evals.yml` | on PR `opened` / `reopened` / `ready_for_review` |
### On PR Review

File diff suppressed because one or more lines are too long

View File

@ -2,12 +2,47 @@ name: 'Release: Create Minor Release PR'
on:
workflow_dispatch:
inputs:
force:
description: 'Create the release PR even if a minor release already went live this week.'
type: boolean
default: false
# schedule:
# - cron: 0 8 * * 2 # 9am CET (UTC+1), Tuesday
permissions:
contents: read
jobs:
check-cadence:
name: Check release cadence
runs-on: ubuntu-latest
steps:
# Guards against a misbehaving scheduler dispatching this workflow more than once a week.
- name: Fail if a minor release already went live this week
env:
GH_TOKEN: ${{ github.token }}
FORCE: ${{ inputs.force }}
run: |
if [ "$FORCE" = "true" ]; then
echo "Force input set, skipping cadence check."
exit 0
fi
LAST_LIVE=$(gh release list --repo "$GITHUB_REPOSITORY" --limit 100 --json tagName,publishedAt \
--jq '[.[] | select(.publishedAt != null and (.tagName | test("^n8n@[0-9]+\\.[0-9]+\\.0$")))] | max_by(.publishedAt).publishedAt // empty')
if [ -n "$LAST_LIVE" ] && [ "$(date -u -d "$LAST_LIVE" +%G-%V)" = "$(date -u +%G-%V)" ]; then
echo "::error::A minor release already went live this week ($LAST_LIVE). Re-run with the 'force' input to override."
exit 1
fi
echo "No minor release this week yet (last went live: ${LAST_LIVE:-never}), proceeding."
create-release-pr:
name: Create release PR
needs: [check-cadence]
# The called workflow requires these; a caller cannot grant less than it requests.
permissions:
contents: write
pull-requests: write
uses: ./.github/workflows/release-create-pr.yml
secrets: inherit
with:

View File

@ -121,7 +121,33 @@ jobs:
FILTERED=$(echo "$CANDIDATES" | node packages/testing/janitor/dist/cli.js filter-shard | tr '\n' ' ' | sed 's/ *$//')
echo "specs=$FILTERED" >> "$GITHUB_OUTPUT"
- name: Resolve shard args
id: shard-args
env:
# On retry, prefer the filtered subset (failed specs from previous attempt)
RETRY_SPECS: ${{ steps.filter-shard.outputs.specs }}
MATRIX_SPECS: ${{ matrix.specs }}
# toJSON distinguishes an orchestrated-but-empty spec list ("") from a
# non-orchestrated matrix entry that never set `specs` (null). Only the
# latter may fall back to Playwright's own --shard splitting; an empty
# orchestrated list means "nothing to run", never "run everything".
MATRIX_SPECS_JSON: ${{ toJSON(matrix.specs) }}
SHARD: ${{ matrix.shard }}
JOB_TOTAL: ${{ strategy.job-total }}
run: |
if [ -n "$RETRY_SPECS" ]; then
echo "args=$RETRY_SPECS" >> "$GITHUB_OUTPUT"
elif [ "$MATRIX_SPECS_JSON" = "null" ]; then
echo "args=--shard=$SHARD/$JOB_TOTAL" >> "$GITHUB_OUTPUT"
elif [ -z "$MATRIX_SPECS" ]; then
echo "This shard's spec list is empty — nothing to run, skipping."
echo "skip=true" >> "$GITHUB_OUTPUT"
else
echo "args=$MATRIX_SPECS" >> "$GITHUB_OUTPUT"
fi
- name: Run Tests
if: steps.shard-args.outputs.skip != 'true'
run: |
# --pass-with-no-tests makes Playwright exit 0 when the selected specs
# resolve to zero runnable tests. Covers the impact-map vs current-suite
@ -137,8 +163,7 @@ jobs:
TEST_COMMAND: ${{ inputs.test-command }}
# Uses pre-distributed specs if orchestration enabled, otherwise falls back to Playwright sharding
WORKERS: ${{ env.PLAYWRIGHT_WORKERS }}
# On retry, prefer the filtered subset (failed specs from previous attempt)
SHARD_ARGS: ${{ steps.filter-shard.outputs.specs || matrix.specs || format('--shard={0}/{1}', matrix.shard, strategy.job-total) }}
SHARD_ARGS: ${{ steps.shard-args.outputs.args }}
# Args for actual test command runner
CURRENTS_RECORD_KEY: ${{ secrets.CURRENTS_RECORD_KEY }}
QA_METRICS_WEBHOOK_URL: ${{ secrets.QA_METRICS_WEBHOOK_URL }}

View File

@ -164,6 +164,7 @@ jobs:
- name: Start n8n containers
env:
EVALS_ANTHROPIC_KEY: ${{ secrets.EVALS_ANTHROPIC_KEY }}
INSTANCE_AI_BRAVE_SEARCH_API_KEY: ${{ secrets.INSTANCE_AI_BRAVE_SEARCH_API_KEY }}
N8N_LICENSE_ACTIVATION_KEY: ${{ secrets.N8N_LICENSE_ACTIVATION_KEY }}
N8N_LICENSE_CERT: ${{ secrets.N8N_LICENSE_CERT }}
N8N_ENCRYPTION_KEY: ${{ secrets.N8N_ENCRYPTION_KEY }}
@ -224,6 +225,7 @@ jobs:
-e N8N_ENABLED_MODULES=instance-ai \
-e N8N_AI_ENABLED=true \
-e N8N_INSTANCE_AI_MODEL_API_KEY="$EVALS_ANTHROPIC_KEY" \
-e INSTANCE_AI_BRAVE_SEARCH_API_KEY="$INSTANCE_AI_BRAVE_SEARCH_API_KEY" \
-e N8N_AI_ASSISTANT_BASE_URL="" \
-e N8N_INSTANCE_AI_SANDBOX_ENABLED=true \
"${SANDBOX_ARGS[@]}" \

View File

@ -188,11 +188,14 @@ jobs:
# instance-ai serves the verifier; mcp + mcp-registry are default modules,
# so MCP is available (the eval CLI enables the *setting* per lane after
# reset). No sandbox — the builder is `claude`, not the in-n8n agent.
# agents registers the agent-builder MCP tools so the suite measures
# workflow building with the production tool surface, catching
# tool-choice regressions the extra tools could introduce.
for i in "${!PORTS[@]}"; do
port="${PORTS[$i]}"
docker run -d --name "n8n-eval-mcp-$((i + 1))" \
-e E2E_TESTS=true \
-e N8N_ENABLED_MODULES=instance-ai \
-e N8N_ENABLED_MODULES=instance-ai,agents \
-e N8N_AI_ENABLED=true \
-e N8N_INSTANCE_AI_MODEL_API_KEY="$EVALS_ANTHROPIC_KEY" \
-e N8N_AI_ASSISTANT_BASE_URL="" \

View File

@ -0,0 +1,75 @@
name: 'Util: Publish API Schema'
on:
# Publish when the Public API source specs (or the bundler version) change
push:
branches:
- master
paths:
- 'packages/cli/src/public-api/**/*.{css,yaml,yml}'
# cli package.json catches a Redocly (bundler) version bump, the only
# other thing that changes the bundled output
- 'packages/cli/package.json'
# Allow manual re-publish
workflow_dispatch:
jobs:
publish-api-schema:
name: Cloudflare Pages
if: github.repository == 'n8n-io/n8n'
runs-on: blacksmith-2vcpu-ubuntu-2204
permissions:
contents: read
deployments: write
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node.js and install dependencies
uses: ./.github/actions/setup-nodejs
with:
build-command: ''
- name: Build Public API Schema
run: pnpm run build:data
working-directory: ./packages/cli
- name: Stage schema for publishing
env:
SCHEMA_PATH: packages/cli/dist/public-api/v1/openapi.yml
run: |
if [[ ! -f "$SCHEMA_PATH" ]]; then
echo "::error::OpenAPI schema not found at $SCHEMA_PATH after build."
exit 1
fi
# Publish a clean directory holding only the schema, at a versioned
# path that mirrors the API's own /v1/ route
mkdir -p api-schema-publish/v1
cp "$SCHEMA_PATH" api-schema-publish/v1/openapi.yml
# text/yaml so browsers render it inline instead of downloading it
cat > api-schema-publish/_headers <<'EOF'
/v1/openapi.yml
Content-Type: text/yaml; charset=utf-8
EOF
# wrangler-action self-installs Wrangler with `pnpm add` at the workspace
# root, which pnpm rejects (ERR_PNPM_ADDING_TO_ROOT). Pre-install it and
# allow the root add so the action finds Wrangler and skips its own install.
- name: Setup Wrangler Pre-requisites
run: |
echo "ignore-workspace-root-check=true" >> .npmrc
pnpm add --global wrangler
- name: Deploy to Cloudflare Pages
uses: cloudflare/wrangler-action@da0e0dfe58b7a431659754fdf3f186c529afbe65 # v3.14.1
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: pages deploy api-schema-publish --project-name=n8n-openapi --branch=${{ github.ref_name }} --commit-hash=${{ github.sha }}
gitHubToken: ${{ secrets.GITHUB_TOKEN }}

View File

@ -1,158 +0,0 @@
name: 'Util: Sync API Docs'
on:
# Triggers for the master branch if relevant Public API files have changed
push:
branches:
- master
paths:
# Trigger if:
# - any of the public API files change
- 'packages/cli/src/public-api/**/*.{css,yaml,yml}'
# - the build script or dependencies change
- 'packages/cli/package.json'
# - any main dependencies change
- 'pnpm-lock.yaml'
# Allow manual trigger
workflow_dispatch:
jobs:
sync-public-api:
runs-on: ubuntu-latest
permissions:
contents: read
actions: read # Needed for `gh` to read the workflow history
steps:
- name: Checkout Main n8n Repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0 # Fetch all history for git log
- name: Setup Node.js and install dependencies
uses: ./.github/actions/setup-nodejs
with:
build-command: ''
- name: Build Public API Schema
run: pnpm run build:data
working-directory: ./packages/cli
- name: Verify OpenAPI schema exists
id: verify_file
run: |
if [[ -f "packages/cli/dist/public-api/v1/openapi.yml" ]]; then
echo "OpenAPI file found: packages/cli/dist/public-api/v1/openapi.yml"
echo "file_exists=true" >> "$GITHUB_OUTPUT"
else
echo "ERROR: OpenAPI file not found at packages/cli/dist/public-api/v1/openapi.yml after build."
echo "file_exists=false" >> "$GITHUB_OUTPUT"
fi
- name: Generate GitHub App Token
if: steps.verify_file.outputs.file_exists == 'true'
id: generate_token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with:
app-id: ${{ secrets.N8N_ASSISTANT_APP_ID }}
private-key: ${{ secrets.N8N_ASSISTANT_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: n8n-docs
- name: Checkout Docs Repository
if: steps.verify_file.outputs.file_exists == 'true'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
repository: n8n-io/n8n-docs
token: ${{ steps.generate_token.outputs.token }}
path: public-docs
- name: Copy OpenAPI file to Docs Repo
if: steps.verify_file.outputs.file_exists == 'true'
run: |
# Destination path within the 'public-docs' checkout directory
DOCS_TARGET_PATH="public-docs/docs/api/v1/openapi.yml"
echo "Copying 'packages/cli/dist/public-api/v1/openapi.yml' to '${DOCS_TARGET_PATH}'"
cp packages/cli/dist/public-api/v1/openapi.yml "${DOCS_TARGET_PATH}"
- name: Find Relevant Source Commits
id: find_source_commits
if: steps.verify_file.outputs.file_exists == 'true'
env:
GH_TOKEN: ${{ github.token }}
GITHUB_REF: ${{ github.ref_name }}
run: |
echo "Finding last successful workflow run..."
LAST_SUCCESS_SHA=$(gh run list \
--workflow "${{ github.workflow }}" \
--branch "$GITHUB_REF" \
--status "success" \
--json "headSha" \
--jq '.[0].headSha // empty' \
-L 1)
RELEVANT_COMMITS=""
if [[ -n "$LAST_SUCCESS_SHA" ]]; then
echo "Last successful commit: $LAST_SUCCESS_SHA"
echo "Current commit: ${{ github.sha }}"
# Find all commits between the last success and now that touched the relevant files
# NOTE: The pathspecs here mirror the workflow's 'paths' trigger for precision
RELEVANT_COMMITS=$(git log --pretty=format:"- [%h](https://github.com/${{ github.repository }}/commit/%H) %s" "${LAST_SUCCESS_SHA}..${{ github.sha }}" -- \
'packages/cli/src/public-api/**/*.css' \
'packages/cli/src/public-api/**/*.yaml' \
'packages/cli/src/public-api/**/*.yml')
fi
if [[ -z "$RELEVANT_COMMITS" ]]; then
if [[ -z "$LAST_SUCCESS_SHA" ]]; then
echo "No previous successful run found. Using current commit as source."
else
echo "No commits touching source files found. Linking to trigger commit (likely a dependency update)."
fi
FULL_SHA="${{ github.sha }}"
SHORT_SHA="${FULL_SHA:0:7}"
SOURCE_LINK="Source commit: [$SHORT_SHA](https://github.com/${{ github.repository }}/commit/$FULL_SHA)"
else
echo "Found relevant source commits:"
echo "$RELEVANT_COMMITS"
# Build the string manually to avoid heredoc capturing leading whitespace from YAML
SOURCE_LINK="Source commit(s):"$'\n'"$RELEVANT_COMMITS"
fi
{
echo "source_link<<EOF"
echo "$SOURCE_LINK"
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Create PR in Docs Repo
if: steps.verify_file.outputs.file_exists == 'true'
env:
GH_ACTOR: ${{ github.actor }}
# Pin v7.0.8
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
token: ${{ steps.generate_token.outputs.token }}
path: public-docs
commit-message: 'feat(public-api): Update Public API schema'
committer: GitHub <noreply@github.com>
author: ${{ env.GH_ACTOR }} <${{ github.actor_id }}+${{ env.GH_ACTOR }}@users.noreply.github.com>
signoff: false
# Create a single branch for multiple PRs
branch: 'chore/sync-public-api-schema'
delete-branch: false
title: 'chore: Update Public API schema'
body: |
Automated update of the Public API OpenAPI YAML schema.
This PR was generated by a GitHub Action in the [${{ github.repository }} repository](https://github.com/${{ github.repository }}).
${{ steps.find_source_commits.outputs.source_link }}
Please review the changes and merge if appropriate.

View File

@ -327,6 +327,46 @@ reviews:
`@/app/stores/workflowDocument.store.ts` to access migrated fields.
This is an active migration. The list of migrated fields will grow over time.
- name: Use the Shared sleep Utility
description: |-
- Rule Statement:
Code must ALWAYS use `sleep` from `@n8n/utils/sleep` instead of
hand-rolling a helper that only wraps `setTimeout` in a promise. One
canonical helper keeps call sites consistent and makes its abortable
form discoverable — `sleep(ms, abortSignal?)` rejects early when the
signal fires, so a local abort-aware waiter is a violation too.
The `no-restricted-sleep-definition` and `no-restricted-sleep-import`
ESLint rules already catch helpers literally named `sleep` /
`sleepWithAbort` and named `sleep` imports from `n8n-workflow`. Do not
repeat those as review comments — this rule covers only what they
cannot see syntactically.
- Detection Criteria:
Flag NEW or MODIFIED code in `packages/**` where a delay is
re-implemented under a name ESLint does not restrict (`wait`, `delay`,
`pause`, `waitFor`, …) and the entire body is a promise wrapping
`setTimeout` with no additional behaviour:
- `const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms))`
- `async function wait(ms) { await new Promise((r) => setTimeout(r, ms)); }`
The shape is what matters, not the name. Also flag an inline, awaited
`new Promise((resolve) => setTimeout(resolve, ms))`.
- Do NOT flag:
- Wrappers that do more than wait: racing against another promise,
rejecting with a timeout error once the delay elapses, retry
backoff with jitter, debounce/throttle. (Rejecting on *abort* is a
violation — `sleep` takes the signal.)
- Bare `setTimeout` calls used for scheduling, not awaited as a delay
- Test-framework helpers such as `vi.advanceTimersByTime` or waiting
on fake timers
- `packages/@n8n/node-cli/**` and `packages/@n8n/typeorm/**`, which
cannot depend on `@n8n/utils` and are exempt from the ESLint rules
- Existing unchanged code (review only new or modified lines)
- Recommendation:
Import `sleep` from `@n8n/utils/sleep` — `await sleep(500)`, or
`await sleep(500, abortSignal)` when the wait must be cancellable.
pr_descriptions:
generate: false
instructions: Each PR is supposed to have a limited scope. In your review, focus on changes made in the PR and avoid pointing out problems you found in the code that already existed.

View File

@ -8,13 +8,14 @@ Auto-generated from the PostgreSQL migrations in @n8n/db. Do not edit by hand.
| Name | Columns | Comment | Type |
| ---- | ------- | ------- | ---- |
| [public.agent_chat_attachments](public.agent_chat_attachments.md) | 12 | | BASE TABLE |
| [public.agent_chat_subscriptions](public.agent_chat_subscriptions.md) | 6 | | BASE TABLE |
| [public.agent_checkpoints](public.agent_checkpoints.md) | 6 | | BASE TABLE |
| [public.agent_eval_dataset](public.agent_eval_dataset.md) | 10 | | BASE TABLE |
| [public.agent_eval_rating](public.agent_eval_rating.md) | 8 | | BASE TABLE |
| [public.agent_eval_result](public.agent_eval_result.md) | 15 | | BASE TABLE |
| [public.agent_eval_run](public.agent_eval_run.md) | 14 | | BASE TABLE |
| [public.agent_execution](public.agent_execution.md) | 19 | | BASE TABLE |
| [public.agent_execution](public.agent_execution.md) | 20 | | BASE TABLE |
| [public.agent_execution_threads](public.agent_execution_threads.md) | 17 | | BASE TABLE |
| [public.agent_files](public.agent_files.md) | 10 | | BASE TABLE |
| [public.agent_history](public.agent_history.md) | 9 | | BASE TABLE |
@ -98,8 +99,8 @@ Auto-generated from the PostgreSQL migrations in @n8n/db. Do not edit by hand.
| [public.role_mapping_rule](public.role_mapping_rule.md) | 7 | | BASE TABLE |
| [public.role_mapping_rule_project](public.role_mapping_rule_project.md) | 2 | | BASE TABLE |
| [public.role_scope](public.role_scope.md) | 2 | | BASE TABLE |
| [public.scheduled_job](public.scheduled_job.md) | 19 | | BASE TABLE |
| [public.scheduled_task](public.scheduled_task.md) | 17 | | BASE TABLE |
| [public.scheduled_job](public.scheduled_job.md) | 21 | | BASE TABLE |
| [public.scheduled_task](public.scheduled_task.md) | 18 | | BASE TABLE |
| [public.scope](public.scope.md) | 3 | | BASE TABLE |
| [public.secrets_provider_connection](public.secrets_provider_connection.md) | 7 | | BASE TABLE |
| [public.settings](public.settings.md) | 3 | | BASE TABLE |
@ -153,6 +154,8 @@ Auto-generated from the PostgreSQL migrations in @n8n/db. Do not edit by hand.
```mermaid
erDiagram
"public.agent_chat_attachments" }o--|| "public.project" : "FOREIGN KEY (#quot;projectId#quot;) REFERENCES project(id) ON DELETE CASCADE"
"public.agent_chat_attachments" }o--o| "public.agents" : "FOREIGN KEY (#quot;agentId#quot;) REFERENCES agents(id) ON DELETE CASCADE"
"public.agent_chat_subscriptions" }o--|| "public.agents" : "FOREIGN KEY (#quot;agentId#quot;) REFERENCES agents(id) ON DELETE CASCADE"
"public.agent_checkpoints" }o--o| "public.agents" : "FOREIGN KEY (#quot;agentId#quot;) REFERENCES agents(id) ON DELETE CASCADE"
"public.agent_eval_dataset" }o--o| "public.user" : "FOREIGN KEY (#quot;createdById#quot;) REFERENCES #quot;user#quot;(id) ON DELETE SET NULL"
@ -328,6 +331,20 @@ erDiagram
"public.workflows_tags" }o--|| "public.workflow_entity" : "FOREIGN KEY (#quot;workflowId#quot;) REFERENCES workflow_entity(id) ON DELETE CASCADE"
"public.workflows_tags" }o--|| "public.tag_entity" : "FOREIGN KEY (#quot;tagId#quot;) REFERENCES tag_entity(id) ON DELETE CASCADE"
"public.agent_chat_attachments" {
varchar_36_ agentId FK
text binaryDataId
timestamp_3__with_time_zone createdAt
varchar_255_ fileName
integer fileSizeBytes
varchar_16_ id
varchar_255_ mimeType
varchar_36_ projectId FK
varchar_255_ resourceId
varchar_32_ source
varchar_128_ threadId
timestamp_3__with_time_zone updatedAt
}
"public.agent_chat_subscriptions" {
varchar_36_ agentId FK
timestamp_3__with_time_zone createdAt
@ -400,6 +417,7 @@ erDiagram
timestamp_3__with_time_zone updatedAt
}
"public.agent_execution" {
json attachments
integer completionTokens
double_precision cost
timestamp_3__with_time_zone createdAt
@ -1213,6 +1231,8 @@ erDiagram
varchar_16_ kind
timestamp_3__with_time_zone lastFiredAt
integer maxAttempts
integer misfireGraceSeconds
varchar_16_ misfirePolicy
varchar_255_ name
timestamp_3__with_time_zone nextRunAt
varchar_36_ nodeId
@ -1236,6 +1256,7 @@ erDiagram
integer leaseEpoch
timestamp_3__with_time_zone leaseExpiresAt
integer maxAttempts
timestamp_3__with_time_zone missedAfter
json payload
timestamp_3__with_time_zone runAt
timestamp_3__with_time_zone scheduledFor

View File

@ -0,0 +1,97 @@
# public.agent_chat_attachments
## Columns
| Name | Type | Default | Nullable | Children | Parents | Comment |
| ---- | ---- | ------- | -------- | -------- | ------- | ------- |
| agentId | varchar(36) | | true | | [public.agents](public.agents.md) | Agent the attachment was sent to, when persisted (null for inline agents) |
| binaryDataId | text | | false | | | Opaque BinaryDataService reference (mode-prefixed, e.g. "filesystem-v2:\<uuid\>"); not an FK to binary_data, which only has rows in DB storage mode |
| createdAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | |
| fileName | varchar(255) | | false | | | |
| fileSizeBytes | integer | | false | | | Uploaded file size in bytes |
| id | varchar(16) | | false | | | Application-generated n8n nano ID |
| mimeType | varchar(255) | | false | | | |
| projectId | varchar(36) | | false | | [public.project](public.project.md) | Project owning the conversation; authorization scope for downloads |
| resourceId | varchar(255) | | true | | | Per-user scope within the thread (platform user), when known |
| source | varchar(32) | | false | | | Surface the file arrived from, e.g. "chat", "slack", "telegram" |
| threadId | varchar(128) | | false | | | Conversation thread the file belongs to |
| updatedAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | |
## Constraints
| Name | Type | Definition |
| ---- | ---- | ---------- |
| FK_41c246c30af3d2108d93e15dd5e | FOREIGN KEY | FOREIGN KEY ("agentId") REFERENCES agents(id) ON DELETE CASCADE |
| FK_5c048535b2132b40dfed2e640e5 | FOREIGN KEY | FOREIGN KEY ("projectId") REFERENCES project(id) ON DELETE CASCADE |
| PK_9c1250f77b41d1a0e71a3f1414a | PRIMARY KEY | PRIMARY KEY (id) |
| agent_chat_attachments_binaryDataId_not_null | n | NOT NULL "binaryDataId" |
| agent_chat_attachments_createdAt_not_null | n | NOT NULL "createdAt" |
| agent_chat_attachments_fileName_not_null | n | NOT NULL "fileName" |
| agent_chat_attachments_fileSizeBytes_not_null | n | NOT NULL "fileSizeBytes" |
| agent_chat_attachments_id_not_null | n | NOT NULL id |
| agent_chat_attachments_mimeType_not_null | n | NOT NULL "mimeType" |
| agent_chat_attachments_projectId_not_null | n | NOT NULL "projectId" |
| agent_chat_attachments_source_not_null | n | NOT NULL source |
| agent_chat_attachments_threadId_not_null | n | NOT NULL "threadId" |
| agent_chat_attachments_updatedAt_not_null | n | NOT NULL "updatedAt" |
## Indexes
| Name | Definition |
| ---- | ---------- |
| IDX_6806b43c8a0d11460f0c2552eb | CREATE INDEX "IDX_6806b43c8a0d11460f0c2552eb" ON public.agent_chat_attachments USING btree ("projectId", "threadId") |
| IDX_7e9556f25f995653997be3322b | CREATE INDEX "IDX_7e9556f25f995653997be3322b" ON public.agent_chat_attachments USING btree ("agentId", "threadId") |
| PK_9c1250f77b41d1a0e71a3f1414a | CREATE UNIQUE INDEX "PK_9c1250f77b41d1a0e71a3f1414a" ON public.agent_chat_attachments USING btree (id) |
## Relations
```mermaid
erDiagram
"public.agent_chat_attachments" }o--o| "public.agents" : "FOREIGN KEY (#quot;agentId#quot;) REFERENCES agents(id) ON DELETE CASCADE"
"public.agent_chat_attachments" }o--|| "public.project" : "FOREIGN KEY (#quot;projectId#quot;) REFERENCES project(id) ON DELETE CASCADE"
"public.agent_chat_attachments" {
varchar_36_ agentId FK
text binaryDataId
timestamp_3__with_time_zone createdAt
varchar_255_ fileName
integer fileSizeBytes
varchar_16_ id
varchar_255_ mimeType
varchar_36_ projectId FK
varchar_255_ resourceId
varchar_32_ source
varchar_128_ threadId
timestamp_3__with_time_zone updatedAt
}
"public.agents" {
varchar_36_ activeVersionId FK
boolean availableInMCP
timestamp_3__with_time_zone createdAt
varchar_36_ id
json integrations
varchar_128_ name
varchar_255_ projectId FK
json schema
json skills
json tools
timestamp_3__with_time_zone updatedAt
varchar_36_ versionId
}
"public.project" {
timestamp_3__with_time_zone createdAt
uuid creatorId FK
json customTelemetryTags
varchar_512_ description
json icon
varchar_36_ id
varchar_255_ name
varchar_36_ type
timestamp_3__with_time_zone updatedAt
}
```
---
> Generated by [tbls](https://github.com/k1LoW/tbls)

View File

@ -4,6 +4,7 @@
| Name | Type | Default | Nullable | Children | Parents | Comment |
| ---- | ---- | ------- | -------- | -------- | ------- | ------- |
| attachments | json | | true | | | Metadata of files attached to the user turn ({id, fileName, mimeType, sizeBytes}[]); bytes live in BinaryDataService |
| completionTokens | integer | | true | | | |
| cost | double precision | | true | | | |
| createdAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | |
@ -56,6 +57,7 @@ erDiagram
"public.agent_execution" }o--|| "public.agent_execution_threads" : "FOREIGN KEY (#quot;threadId#quot;) REFERENCES agent_execution_threads(id) ON DELETE CASCADE"
"public.agent_execution" {
json attachments
integer completionTokens
double_precision cost
timestamp_3__with_time_zone createdAt

View File

@ -95,6 +95,7 @@ erDiagram
varchar_36_ versionId
}
"public.agent_execution" {
json attachments
integer completionTokens
double_precision cost
timestamp_3__with_time_zone createdAt

View File

@ -7,7 +7,7 @@
| activeVersionId | varchar(36) | | true | | [public.agent_history](public.agent_history.md) | |
| availableInMCP | boolean | false | false | | | Whether MCP clients granted agent scopes may operate on this agent |
| createdAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | |
| id | varchar(36) | | false | [public.agent_chat_subscriptions](public.agent_chat_subscriptions.md) [public.agent_checkpoints](public.agent_checkpoints.md) [public.agent_eval_dataset](public.agent_eval_dataset.md) [public.agent_execution_threads](public.agent_execution_threads.md) [public.agent_files](public.agent_files.md) [public.agent_history](public.agent_history.md) [public.agent_task_definition](public.agent_task_definition.md) [public.agent_task_run_lock](public.agent_task_run_lock.md) [public.agents_memory_entries](public.agents_memory_entries.md) [public.agents_memory_entry_cursors](public.agents_memory_entry_cursors.md) [public.agents_memory_entry_locks](public.agents_memory_entry_locks.md) [public.agents_memory_entry_sources](public.agents_memory_entry_sources.md) [public.agents_observation_cursors](public.agents_observation_cursors.md) [public.agents_observation_locks](public.agents_observation_locks.md) [public.agents_observations](public.agents_observations.md) | | |
| id | varchar(36) | | false | [public.agent_chat_attachments](public.agent_chat_attachments.md) [public.agent_chat_subscriptions](public.agent_chat_subscriptions.md) [public.agent_checkpoints](public.agent_checkpoints.md) [public.agent_eval_dataset](public.agent_eval_dataset.md) [public.agent_execution_threads](public.agent_execution_threads.md) [public.agent_files](public.agent_files.md) [public.agent_history](public.agent_history.md) [public.agent_task_definition](public.agent_task_definition.md) [public.agent_task_run_lock](public.agent_task_run_lock.md) [public.agents_memory_entries](public.agents_memory_entries.md) [public.agents_memory_entry_cursors](public.agents_memory_entry_cursors.md) [public.agents_memory_entry_locks](public.agents_memory_entry_locks.md) [public.agents_memory_entry_sources](public.agents_memory_entry_sources.md) [public.agents_observation_cursors](public.agents_observation_cursors.md) [public.agents_observation_locks](public.agents_observation_locks.md) [public.agents_observations](public.agents_observations.md) | | |
| integrations | json | '[]'::json | false | | | |
| name | varchar(128) | | false | | | |
| projectId | varchar(255) | | false | | [public.project](public.project.md) | |
@ -48,6 +48,7 @@
erDiagram
"public.agents" }o--o| "public.agent_history" : "FOREIGN KEY (#quot;activeVersionId#quot;) REFERENCES agent_history(#quot;versionId#quot;) ON DELETE SET NULL"
"public.agent_chat_attachments" }o--o| "public.agents" : "FOREIGN KEY (#quot;agentId#quot;) REFERENCES agents(id) ON DELETE CASCADE"
"public.agent_chat_subscriptions" }o--|| "public.agents" : "FOREIGN KEY (#quot;agentId#quot;) REFERENCES agents(id) ON DELETE CASCADE"
"public.agent_checkpoints" }o--o| "public.agents" : "FOREIGN KEY (#quot;agentId#quot;) REFERENCES agents(id) ON DELETE CASCADE"
"public.agent_eval_dataset" }o--|| "public.agents" : "FOREIGN KEY (#quot;agentId#quot;) REFERENCES agents(id) ON DELETE CASCADE"
@ -90,6 +91,20 @@ erDiagram
timestamp_3__with_time_zone updatedAt
varchar_36_ versionId
}
"public.agent_chat_attachments" {
varchar_36_ agentId FK
text binaryDataId
timestamp_3__with_time_zone createdAt
varchar_255_ fileName
integer fileSizeBytes
varchar_16_ id
varchar_255_ mimeType
varchar_36_ projectId FK
varchar_255_ resourceId
varchar_32_ source
varchar_128_ threadId
timestamp_3__with_time_zone updatedAt
}
"public.agent_chat_subscriptions" {
varchar_36_ agentId FK
timestamp_3__with_time_zone createdAt

View File

@ -18,7 +18,7 @@
| Name | Type | Definition |
| ---- | ---- | ---------- |
| CHK_binary_data_sourceType | CHECK | CHECK ((("sourceType")::text = ANY ((ARRAY['execution'::character varying, 'chat_message_attachment'::character varying, 'agent_file'::character varying])::text[]))) |
| CHK_binary_data_sourceType | CHECK | CHECK ((("sourceType")::text = ANY ((ARRAY['execution'::character varying, 'chat_message_attachment'::character varying, 'agent_file'::character varying, 'agent_chat_attachment'::character varying])::text[]))) |
| PK_fc3691585b39408bb0551122af6 | PRIMARY KEY | PRIMARY KEY ("fileId") |
| binary_data_createdAt_not_null | n | NOT NULL "createdAt" |
| binary_data_data_not_null | n | NOT NULL data |

View File

@ -9,7 +9,7 @@
| customTelemetryTags | json | '[]'::json | false | | | |
| description | varchar(512) | | true | | | |
| icon | json | | true | | | |
| id | varchar(36) | | false | [public.agent_execution_threads](public.agent_execution_threads.md) [public.agents](public.agents.md) [public.data_table](public.data_table.md) [public.folder](public.folder.md) [public.insights_metadata](public.insights_metadata.md) [public.instance_ai_threads](public.instance_ai_threads.md) [public.project_relation](public.project_relation.md) [public.project_secrets_provider_access](public.project_secrets_provider_access.md) [public.role_mapping_rule_project](public.role_mapping_rule_project.md) [public.shared_credentials](public.shared_credentials.md) [public.shared_workflow](public.shared_workflow.md) [public.variables](public.variables.md) [public.workflow_review_request](public.workflow_review_request.md) | | |
| id | varchar(36) | | false | [public.agent_chat_attachments](public.agent_chat_attachments.md) [public.agent_execution_threads](public.agent_execution_threads.md) [public.agents](public.agents.md) [public.data_table](public.data_table.md) [public.folder](public.folder.md) [public.insights_metadata](public.insights_metadata.md) [public.instance_ai_threads](public.instance_ai_threads.md) [public.project_relation](public.project_relation.md) [public.project_secrets_provider_access](public.project_secrets_provider_access.md) [public.role_mapping_rule_project](public.role_mapping_rule_project.md) [public.shared_credentials](public.shared_credentials.md) [public.shared_workflow](public.shared_workflow.md) [public.variables](public.variables.md) [public.workflow_review_request](public.workflow_review_request.md) | | |
| name | varchar(255) | | false | | | |
| type | varchar(36) | | false | | | |
| updatedAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | |
@ -39,6 +39,7 @@
erDiagram
"public.project" }o--o| "public.user" : "FOREIGN KEY (#quot;creatorId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE SET NULL"
"public.agent_chat_attachments" }o--|| "public.project" : "FOREIGN KEY (#quot;projectId#quot;) REFERENCES project(id) ON DELETE CASCADE"
"public.agent_execution_threads" }o--|| "public.project" : "FOREIGN KEY (#quot;projectId#quot;) REFERENCES project(id) ON DELETE CASCADE"
"public.agents" }o--|| "public.project" : "FOREIGN KEY (#quot;projectId#quot;) REFERENCES project(id) ON DELETE CASCADE"
"public.data_table" }o--|| "public.project" : "FOREIGN KEY (#quot;projectId#quot;) REFERENCES project(id) ON DELETE CASCADE"
@ -81,6 +82,20 @@ erDiagram
json settings
timestamp_3__with_time_zone updatedAt
}
"public.agent_chat_attachments" {
varchar_36_ agentId FK
text binaryDataId
timestamp_3__with_time_zone createdAt
varchar_255_ fileName
integer fileSizeBytes
varchar_16_ id
varchar_255_ mimeType
varchar_36_ projectId FK
varchar_255_ resourceId
varchar_32_ source
varchar_128_ threadId
timestamp_3__with_time_zone updatedAt
}
"public.agent_execution_threads" {
varchar_36_ agentId FK
varchar_255_ agentName

View File

@ -13,6 +13,8 @@
| kind | varchar(16) | | false | | | Recurrence kind; selects which of the schedule columns below apply. |
| lastFiredAt | timestamp(3) with time zone | | true | | | Last time an occurrence was materialized; used to recompute nextRunAt. |
| maxAttempts | integer | 1 | false | | | Retry ceiling copied onto each occurrence this job materializes. |
| misfireGraceSeconds | integer | 60 | false | | | How late an occurrence may be before the misfire policy applies to it; an ordinary restart stays inside it. |
| misfirePolicy | varchar(16) | 'coalesce'::character varying | false | | | What to do with occurrences that came due while nothing ran them: 'coalesce' records a single catch-up run, 'skip' records none. |
| name | varchar(255) | | false | | | Human-readable job name. A well-known scheduler key for system jobs; generated for workflow trigger jobs. |
| nextRunAt | timestamp(3) with time zone | | true | | | Next time an occurrence is due; the scheduler sweep reads this to find work. NULL once disabled or a one-off has fired. |
| nodeId | varchar(36) | | true | | | Trigger node within the workflow that owns this job; NULL for non-trigger jobs. |
@ -32,6 +34,8 @@
| CHK_scheduled_job_fire_at | CHECK | CHECK ((((kind)::text <> 'one_off'::text) OR ("fireAt" IS NOT NULL))) |
| CHK_scheduled_job_interval_seconds | CHECK | CHECK ((((kind)::text <> 'interval'::text) OR ("intervalSeconds" IS NOT NULL))) |
| CHK_scheduled_job_kind | CHECK | CHECK (((kind)::text = ANY ((ARRAY['cron'::character varying, 'interval'::character varying, 'one_off'::character varying, 'recurring_cron'::character varying])::text[]))) |
| CHK_scheduled_job_misfireGraceSeconds | CHECK | CHECK (("misfireGraceSeconds" > 0)) |
| CHK_scheduled_job_misfirePolicy | CHECK | CHECK ((("misfirePolicy")::text = ANY ((ARRAY['coalesce'::character varying, 'skip'::character varying])::text[]))) |
| CHK_scheduled_job_recurrence_size | CHECK | CHECK (("recurrenceSize" >= 2)) |
| CHK_scheduled_job_recurrence_unit | CHECK | CHECK ((("recurrenceUnit")::text = ANY ((ARRAY['hours'::character varying, 'days'::character varying, 'weeks'::character varying, 'months'::character varying])::text[]))) |
| CHK_scheduled_job_recurring_cron | CHECK | CHECK ((((kind)::text <> 'recurring_cron'::text) OR (("cronExpression" IS NOT NULL) AND ("recurrenceUnit" IS NOT NULL) AND ("recurrenceSize" IS NOT NULL)))) |
@ -42,6 +46,8 @@
| scheduled_job_id_not_null | n | NOT NULL id |
| scheduled_job_kind_not_null | n | NOT NULL kind |
| scheduled_job_maxAttempts_not_null | n | NOT NULL "maxAttempts" |
| scheduled_job_misfireGraceSeconds_not_null | n | NOT NULL "misfireGraceSeconds" |
| scheduled_job_misfirePolicy_not_null | n | NOT NULL "misfirePolicy" |
| scheduled_job_name_not_null | n | NOT NULL name |
| scheduled_job_payload_not_null | n | NOT NULL payload |
| scheduled_job_taskType_not_null | n | NOT NULL "taskType" |
@ -74,6 +80,8 @@ erDiagram
varchar_16_ kind
timestamp_3__with_time_zone lastFiredAt
integer maxAttempts
integer misfireGraceSeconds
varchar_16_ misfirePolicy
varchar_255_ name
timestamp_3__with_time_zone nextRunAt
varchar_36_ nodeId
@ -97,6 +105,7 @@ erDiagram
integer leaseEpoch
timestamp_3__with_time_zone leaseExpiresAt
integer maxAttempts
timestamp_3__with_time_zone missedAfter
json payload
timestamp_3__with_time_zone runAt
timestamp_3__with_time_zone scheduledFor

View File

@ -15,6 +15,7 @@
| leaseEpoch | integer | 0 | false | | | Fencing token bumped on each claim; lets a reaped worker detect it lost ownership and not overwrite the new owner's results. |
| leaseExpiresAt | timestamp(3) with time zone | | true | | | When the current lease expires; the reaper reclaims running occurrences past this. |
| maxAttempts | integer | 1 | false | | | Attempt ceiling; once attempts reaches it, a failure is final rather than retried. |
| missedAfter | timestamp(3) with time zone | | true | | | When this occurrence stops being worth running (runAt plus the job's misfire grace). NULL means no deadline. |
| payload | json | '{}'::json | false | | | Handler input copied from the job. A snapshot, so editing the job later doesn't change runs already queued. |
| runAt | timestamp(3) with time zone | | false | | | Earliest time the executor may pick this up; starts at scheduledFor and is pushed out by retry backoff. |
| scheduledFor | timestamp(3) with time zone | | false | | | The logical fire time this occurrence represents; unique per job, so the same instant cannot be queued twice. |
@ -49,6 +50,7 @@
| IDX_scheduled_task_finishedAt | CREATE INDEX "IDX_scheduled_task_finishedAt" ON public.scheduled_task USING btree ("finishedAt") WHERE ("finishedAt" IS NOT NULL) |
| IDX_scheduled_task_jobId_scheduledFor | CREATE UNIQUE INDEX "IDX_scheduled_task_jobId_scheduledFor" ON public.scheduled_task USING btree ("jobId", "scheduledFor") |
| IDX_scheduled_task_leaseExpiresAt | CREATE INDEX "IDX_scheduled_task_leaseExpiresAt" ON public.scheduled_task USING btree ("leaseExpiresAt") WHERE ((status)::text = 'running'::text) |
| IDX_scheduled_task_missedAfter | CREATE INDEX "IDX_scheduled_task_missedAfter" ON public.scheduled_task USING btree ("missedAfter") WHERE (((status)::text = 'pending'::text) AND ("missedAfter" IS NOT NULL)) |
| IDX_scheduled_task_runAt | CREATE INDEX "IDX_scheduled_task_runAt" ON public.scheduled_task USING btree ("runAt") WHERE ((status)::text = 'pending'::text) |
| PK_d690af24e57e30594c1948af1e6 | CREATE UNIQUE INDEX "PK_d690af24e57e30594c1948af1e6" ON public.scheduled_task USING btree (id) |
@ -71,6 +73,7 @@ erDiagram
integer leaseEpoch
timestamp_3__with_time_zone leaseExpiresAt
integer maxAttempts
timestamp_3__with_time_zone missedAfter
json payload
timestamp_3__with_time_zone runAt
timestamp_3__with_time_zone scheduledFor
@ -88,6 +91,8 @@ erDiagram
varchar_16_ kind
timestamp_3__with_time_zone lastFiredAt
integer maxAttempts
integer misfireGraceSeconds
varchar_16_ misfirePolicy
varchar_255_ name
timestamp_3__with_time_zone nextRunAt
varchar_36_ nodeId

View File

@ -87,6 +87,8 @@ erDiagram
varchar_16_ kind
timestamp_3__with_time_zone lastFiredAt
integer maxAttempts
integer misfireGraceSeconds
varchar_16_ misfirePolicy
varchar_255_ name
timestamp_3__with_time_zone nextRunAt
varchar_36_ nodeId

View File

@ -8,13 +8,14 @@ Auto-generated from the SQLite migrations in @n8n/db. Do not edit by hand.
| Name | Columns | Comment | Type |
| ---- | ------- | ------- | ---- |
| [agent_chat_attachments](agent_chat_attachments.md) | 12 | | table |
| [agent_chat_subscriptions](agent_chat_subscriptions.md) | 6 | | table |
| [agent_checkpoints](agent_checkpoints.md) | 6 | | table |
| [agent_eval_dataset](agent_eval_dataset.md) | 10 | | table |
| [agent_eval_rating](agent_eval_rating.md) | 8 | | table |
| [agent_eval_result](agent_eval_result.md) | 15 | | table |
| [agent_eval_run](agent_eval_run.md) | 14 | | table |
| [agent_execution](agent_execution.md) | 19 | | table |
| [agent_execution](agent_execution.md) | 20 | | table |
| [agent_execution_threads](agent_execution_threads.md) | 17 | | table |
| [agent_files](agent_files.md) | 10 | | table |
| [agent_history](agent_history.md) | 9 | | table |
@ -98,8 +99,8 @@ Auto-generated from the SQLite migrations in @n8n/db. Do not edit by hand.
| [role_mapping_rule](role_mapping_rule.md) | 7 | | table |
| [role_mapping_rule_project](role_mapping_rule_project.md) | 2 | | table |
| [role_scope](role_scope.md) | 2 | | table |
| [scheduled_job](scheduled_job.md) | 19 | | table |
| [scheduled_task](scheduled_task.md) | 17 | | table |
| [scheduled_job](scheduled_job.md) | 21 | | table |
| [scheduled_task](scheduled_task.md) | 18 | | table |
| [scope](scope.md) | 3 | | table |
| [secrets_provider_connection](secrets_provider_connection.md) | 7 | | table |
| [settings](settings.md) | 3 | | table |
@ -136,6 +137,8 @@ Auto-generated from the SQLite migrations in @n8n/db. Do not edit by hand.
```mermaid
erDiagram
"agent_chat_attachments" }o--|| "project" : "FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"agent_chat_attachments" }o--o| "agents" : "FOREIGN KEY (agentId) REFERENCES agents (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"agent_chat_subscriptions" |o--|| "agents" : "FOREIGN KEY (agentId) REFERENCES agents (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"agent_checkpoints" }o--o| "agents" : "FOREIGN KEY (agentId) REFERENCES agents (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"agent_eval_dataset" }o--o| "user" : "FOREIGN KEY (createdById) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
@ -315,6 +318,20 @@ erDiagram
"workflows_tags" |o--|| "tag_entity" : "FOREIGN KEY (tagId) REFERENCES tag_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"workflows_tags" |o--|| "workflow_entity" : "FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"agent_chat_attachments" {
varchar_36_ agentId FK
TEXT binaryDataId
datetime_3_ createdAt
varchar_255_ fileName
INTEGER fileSizeBytes
varchar_16_ id PK
varchar_255_ mimeType
varchar_36_ projectId FK
varchar_255_ resourceId
varchar_32_ source
varchar_128_ threadId
datetime_3_ updatedAt
}
"agent_chat_subscriptions" {
varchar_36_ agentId PK
datetime_3_ createdAt
@ -387,6 +404,7 @@ erDiagram
datetime_3_ updatedAt
}
"agent_execution" {
TEXT attachments
INTEGER completionTokens
REAL cost
datetime_3_ createdAt
@ -400,7 +418,7 @@ erDiagram
datetime_3_ startedAt
varchar_16_ status
datetime_3_ stoppedAt
VARCHAR_2_ storedAt
varchar_2_ storedAt
varchar_128_ threadId FK
TEXT timeline
INTEGER totalTokens
@ -1200,6 +1218,8 @@ erDiagram
varchar_16_ kind
datetime_3_ lastFiredAt
INTEGER maxAttempts
INT misfireGraceSeconds
varchar_16_ misfirePolicy
varchar_255_ name
datetime_3_ nextRunAt
varchar_36_ nodeId
@ -1223,6 +1243,7 @@ erDiagram
INTEGER leaseEpoch
datetime_3_ leaseExpiresAt
INTEGER maxAttempts
datetime_3_ missedAfter
TEXT payload
datetime_3_ runAt
datetime_3_ scheduledFor

View File

@ -0,0 +1,99 @@
# agent_chat_attachments
## Description
<details>
<summary><strong>Table Definition</strong></summary>
```sql
CREATE TABLE "agent_chat_attachments" ("id" varchar(16) PRIMARY KEY NOT NULL, "agentId" varchar(36), "projectId" varchar(36) NOT NULL, "threadId" varchar(128) NOT NULL, "resourceId" varchar(255), "binaryDataId" text NOT NULL, "fileName" varchar(255) NOT NULL, "mimeType" varchar(255) NOT NULL, "fileSizeBytes" integer NOT NULL, "source" varchar(32) NOT NULL, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), CONSTRAINT "FK_41c246c30af3d2108d93e15dd5e" FOREIGN KEY ("agentId") REFERENCES "agents" ("id") ON DELETE CASCADE, CONSTRAINT "FK_5c048535b2132b40dfed2e640e5" FOREIGN KEY ("projectId") REFERENCES "project" ("id") ON DELETE CASCADE)
```
</details>
## Columns
| Name | Type | Default | Nullable | Children | Parents | Comment |
| ---- | ---- | ------- | -------- | -------- | ------- | ------- |
| agentId | varchar(36) | | true | | [agents](agents.md) | |
| binaryDataId | TEXT | | false | | | |
| createdAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | |
| fileName | varchar(255) | | false | | | |
| fileSizeBytes | INTEGER | | false | | | |
| id | varchar(16) | | false | | | |
| mimeType | varchar(255) | | false | | | |
| projectId | varchar(36) | | false | | [project](project.md) | |
| resourceId | varchar(255) | | true | | | |
| source | varchar(32) | | false | | | |
| threadId | varchar(128) | | false | | | |
| updatedAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | |
## Constraints
| Name | Type | Definition |
| ---- | ---- | ---------- |
| - (Foreign key ID: 0) | FOREIGN KEY | FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE |
| - (Foreign key ID: 1) | FOREIGN KEY | FOREIGN KEY (agentId) REFERENCES agents (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE |
| id | PRIMARY KEY | PRIMARY KEY (id) |
| sqlite_autoindex_agent_chat_attachments_1 | PRIMARY KEY | PRIMARY KEY (id) |
## Indexes
| Name | Definition |
| ---- | ---------- |
| IDX_6806b43c8a0d11460f0c2552eb | CREATE INDEX "IDX_6806b43c8a0d11460f0c2552eb" ON "agent_chat_attachments" ("projectId", "threadId") |
| IDX_7e9556f25f995653997be3322b | CREATE INDEX "IDX_7e9556f25f995653997be3322b" ON "agent_chat_attachments" ("agentId", "threadId") |
| sqlite_autoindex_agent_chat_attachments_1 | PRIMARY KEY (id) |
## Relations
```mermaid
erDiagram
"agent_chat_attachments" }o--o| "agents" : "FOREIGN KEY (agentId) REFERENCES agents (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"agent_chat_attachments" }o--|| "project" : "FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"agent_chat_attachments" {
varchar_36_ agentId FK
TEXT binaryDataId
datetime_3_ createdAt
varchar_255_ fileName
INTEGER fileSizeBytes
varchar_16_ id PK
varchar_255_ mimeType
varchar_36_ projectId FK
varchar_255_ resourceId
varchar_32_ source
varchar_128_ threadId
datetime_3_ updatedAt
}
"agents" {
varchar_36_ activeVersionId FK
boolean availableInMCP
datetime_3_ createdAt
varchar_36_ id PK
TEXT integrations
varchar_128_ name
varchar_255_ projectId FK
TEXT schema
TEXT skills
TEXT tools
datetime_3_ updatedAt
varchar_36_ versionId
}
"project" {
datetime_3_ createdAt
varchar creatorId FK
TEXT customTelemetryTags
varchar_512_ description
TEXT icon
varchar_36_ id PK
varchar_255_ name
varchar_36_ type
datetime_3_ updatedAt
}
```
---
> Generated by [tbls](https://github.com/k1LoW/tbls)

View File

@ -6,7 +6,7 @@
<summary><strong>Table Definition</strong></summary>
```sql
CREATE TABLE "agent_execution" ("id" varchar(36) PRIMARY KEY NOT NULL, "threadId" varchar(128) NOT NULL, "status" varchar(16) NOT NULL, "startedAt" datetime(3), "stoppedAt" datetime(3), "duration" integer NOT NULL DEFAULT (0), "userMessage" text, "model" varchar(255), "promptTokens" integer, "completionTokens" integer, "totalTokens" integer, "cost" real, "timeline" text, "error" text, "hitlStatus" varchar(16), "source" varchar(32), "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "storedAt" VARCHAR(2) NOT NULL DEFAULT 'db' CONSTRAINT "CHK_agent_execution_storedAt" CHECK("storedAt" IN ('db', 'fs', 's3', 'az')), CONSTRAINT "CHK_agent_execution_status" CHECK (((("status" IN ('success', 'error'))))), CONSTRAINT "CHK_agent_execution_hitlStatus" CHECK (((("hitlStatus" IN ('suspended', 'resumed'))))), CONSTRAINT "FK_add2432fb6034cc18b6af299dce" FOREIGN KEY ("threadId") REFERENCES "agent_execution_threads" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)
CREATE TABLE "agent_execution" ("id" varchar(36) PRIMARY KEY NOT NULL, "threadId" varchar(128) NOT NULL, "status" varchar(16) NOT NULL, "startedAt" datetime(3), "stoppedAt" datetime(3), "duration" integer NOT NULL DEFAULT (0), "userMessage" text, "model" varchar(255), "promptTokens" integer, "completionTokens" integer, "totalTokens" integer, "cost" real, "timeline" text, "error" text, "hitlStatus" varchar(16), "source" varchar(32), "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "storedAt" varchar(2) NOT NULL DEFAULT ('db'), "attachments" text, CONSTRAINT "CHK_agent_execution_storedAt" CHECK (("storedAt" IN ('db', 'fs', 's3', 'az'))), CONSTRAINT "CHK_agent_execution_status" CHECK ((((("status" IN ('success', 'error')))))), CONSTRAINT "CHK_agent_execution_hitlStatus" CHECK ((((("hitlStatus" IN ('suspended', 'resumed')))))), CONSTRAINT "FK_add2432fb6034cc18b6af299dce" FOREIGN KEY ("threadId") REFERENCES "agent_execution_threads" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)
```
</details>
@ -15,6 +15,7 @@ CREATE TABLE "agent_execution" ("id" varchar(36) PRIMARY KEY NOT NULL, "threadId
| Name | Type | Default | Nullable | Children | Parents | Comment |
| ---- | ---- | ------- | -------- | -------- | ------- | ------- |
| attachments | TEXT | | true | | | |
| completionTokens | INTEGER | | true | | | |
| cost | REAL | | true | | | |
| createdAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | |
@ -28,7 +29,7 @@ CREATE TABLE "agent_execution" ("id" varchar(36) PRIMARY KEY NOT NULL, "threadId
| startedAt | datetime(3) | | true | | | |
| status | varchar(16) | | false | | | |
| stoppedAt | datetime(3) | | true | | | |
| storedAt | VARCHAR(2) | 'db' | false | | | |
| storedAt | varchar(2) | 'db' | false | | | |
| threadId | varchar(128) | | false | | [agent_execution_threads](agent_execution_threads.md) | |
| timeline | TEXT | | true | | | |
| totalTokens | INTEGER | | true | | | |
@ -39,9 +40,9 @@ CREATE TABLE "agent_execution" ("id" varchar(36) PRIMARY KEY NOT NULL, "threadId
| Name | Type | Definition |
| ---- | ---- | ---------- |
| - | CHECK | CHECK("storedAt" IN ('db', 'fs', 's3', 'az')) |
| - | CHECK | CHECK (((("status" IN ('success', 'error'))))) |
| - | CHECK | CHECK (((("hitlStatus" IN ('suspended', 'resumed'))))) |
| - | CHECK | CHECK (("storedAt" IN ('db', 'fs', 's3', 'az'))) |
| - | CHECK | CHECK ((((("status" IN ('success', 'error')))))) |
| - | CHECK | CHECK ((((("hitlStatus" IN ('suspended', 'resumed')))))) |
| - (Foreign key ID: 0) | FOREIGN KEY | FOREIGN KEY (threadId) REFERENCES agent_execution_threads (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE |
| id | PRIMARY KEY | PRIMARY KEY (id) |
| sqlite_autoindex_agent_execution_1 | PRIMARY KEY | PRIMARY KEY (id) |
@ -61,6 +62,7 @@ erDiagram
"agent_execution" }o--|| "agent_execution_threads" : "FOREIGN KEY (threadId) REFERENCES agent_execution_threads (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"agent_execution" {
TEXT attachments
INTEGER completionTokens
REAL cost
datetime_3_ createdAt
@ -74,7 +76,7 @@ erDiagram
datetime_3_ startedAt
varchar_16_ status
datetime_3_ stoppedAt
VARCHAR_2_ storedAt
varchar_2_ storedAt
varchar_128_ threadId FK
TEXT timeline
INTEGER totalTokens

View File

@ -96,6 +96,7 @@ erDiagram
varchar_36_ versionId
}
"agent_execution" {
TEXT attachments
INTEGER completionTokens
REAL cost
datetime_3_ createdAt
@ -109,7 +110,7 @@ erDiagram
datetime_3_ startedAt
varchar_16_ status
datetime_3_ stoppedAt
VARCHAR_2_ storedAt
varchar_2_ storedAt
varchar_128_ threadId FK
TEXT timeline
INTEGER totalTokens

View File

@ -18,7 +18,7 @@ CREATE TABLE "agents" ("id" varchar(36) PRIMARY KEY NOT NULL, "name" varchar(128
| activeVersionId | varchar(36) | | true | | [agent_history](agent_history.md) | |
| availableInMCP | boolean | false | false | | | |
| createdAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | |
| id | varchar(36) | | false | [agent_chat_subscriptions](agent_chat_subscriptions.md) [agent_checkpoints](agent_checkpoints.md) [agent_eval_dataset](agent_eval_dataset.md) [agent_execution_threads](agent_execution_threads.md) [agent_files](agent_files.md) [agent_history](agent_history.md) [agent_task_definition](agent_task_definition.md) [agent_task_run_lock](agent_task_run_lock.md) [agents_memory_entries](agents_memory_entries.md) [agents_memory_entry_cursors](agents_memory_entry_cursors.md) [agents_memory_entry_locks](agents_memory_entry_locks.md) [agents_memory_entry_sources](agents_memory_entry_sources.md) [agents_observation_cursors](agents_observation_cursors.md) [agents_observation_locks](agents_observation_locks.md) [agents_observations](agents_observations.md) | | |
| id | varchar(36) | | false | [agent_chat_attachments](agent_chat_attachments.md) [agent_chat_subscriptions](agent_chat_subscriptions.md) [agent_checkpoints](agent_checkpoints.md) [agent_eval_dataset](agent_eval_dataset.md) [agent_execution_threads](agent_execution_threads.md) [agent_files](agent_files.md) [agent_history](agent_history.md) [agent_task_definition](agent_task_definition.md) [agent_task_run_lock](agent_task_run_lock.md) [agents_memory_entries](agents_memory_entries.md) [agents_memory_entry_cursors](agents_memory_entry_cursors.md) [agents_memory_entry_locks](agents_memory_entry_locks.md) [agents_memory_entry_sources](agents_memory_entry_sources.md) [agents_observation_cursors](agents_observation_cursors.md) [agents_observation_locks](agents_observation_locks.md) [agents_observations](agents_observations.md) | | |
| integrations | TEXT | '[]' | false | | | |
| name | varchar(128) | | false | | | |
| projectId | varchar(255) | | false | | [project](project.md) | |
@ -51,6 +51,7 @@ CREATE TABLE "agents" ("id" varchar(36) PRIMARY KEY NOT NULL, "name" varchar(128
erDiagram
"agents" }o--o| "agent_history" : "FOREIGN KEY (activeVersionId) REFERENCES agent_history (versionId) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
"agent_chat_attachments" }o--o| "agents" : "FOREIGN KEY (agentId) REFERENCES agents (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"agent_chat_subscriptions" |o--|| "agents" : "FOREIGN KEY (agentId) REFERENCES agents (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"agent_checkpoints" }o--o| "agents" : "FOREIGN KEY (agentId) REFERENCES agents (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"agent_eval_dataset" }o--|| "agents" : "FOREIGN KEY (agentId) REFERENCES agents (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
@ -93,6 +94,20 @@ erDiagram
datetime_3_ updatedAt
varchar_36_ versionId PK
}
"agent_chat_attachments" {
varchar_36_ agentId FK
TEXT binaryDataId
datetime_3_ createdAt
varchar_255_ fileName
INTEGER fileSizeBytes
varchar_16_ id PK
varchar_255_ mimeType
varchar_36_ projectId FK
varchar_255_ resourceId
varchar_32_ source
varchar_128_ threadId
datetime_3_ updatedAt
}
"agent_chat_subscriptions" {
varchar_36_ agentId PK
datetime_3_ createdAt

View File

@ -6,7 +6,7 @@
<summary><strong>Table Definition</strong></summary>
```sql
CREATE TABLE "binary_data" ("fileId" varchar PRIMARY KEY NOT NULL, "sourceType" varchar(50) NOT NULL, "sourceId" varchar(255) NOT NULL, "data" blob NOT NULL, "mimeType" varchar(255), "fileName" varchar(255), "fileSize" integer NOT NULL, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), CONSTRAINT "CHK_binary_data_sourceType" CHECK ("sourceType" IN ('execution', 'chat_message_attachment', 'agent_file')))
CREATE TABLE "binary_data" ("fileId" varchar PRIMARY KEY NOT NULL, "sourceType" varchar(50) NOT NULL, "sourceId" varchar(255) NOT NULL, "data" blob NOT NULL, "mimeType" varchar(255), "fileName" varchar(255), "fileSize" integer NOT NULL, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), CONSTRAINT "CHK_binary_data_sourceType" CHECK ("sourceType" IN ('execution', 'chat_message_attachment', 'agent_file', 'agent_chat_attachment')))
```
</details>
@ -29,7 +29,7 @@ CREATE TABLE "binary_data" ("fileId" varchar PRIMARY KEY NOT NULL, "sourceType"
| Name | Type | Definition |
| ---- | ---- | ---------- |
| - | CHECK | CHECK ("sourceType" IN ('execution', 'chat_message_attachment', 'agent_file')) |
| - | CHECK | CHECK ("sourceType" IN ('execution', 'chat_message_attachment', 'agent_file', 'agent_chat_attachment')) |
| fileId | PRIMARY KEY | PRIMARY KEY (fileId) |
| sqlite_autoindex_binary_data_1 | PRIMARY KEY | PRIMARY KEY (fileId) |

View File

@ -20,7 +20,7 @@ CREATE TABLE "project" ("id" varchar(36) PRIMARY KEY NOT NULL, "name" varchar(25
| customTelemetryTags | TEXT | '[]' | false | | | |
| description | varchar(512) | | true | | | |
| icon | TEXT | | true | | | |
| id | varchar(36) | | false | [agent_execution_threads](agent_execution_threads.md) [agents](agents.md) [data_table](data_table.md) [folder](folder.md) [insights_metadata](insights_metadata.md) [instance_ai_threads](instance_ai_threads.md) [project_relation](project_relation.md) [project_secrets_provider_access](project_secrets_provider_access.md) [role_mapping_rule_project](role_mapping_rule_project.md) [shared_credentials](shared_credentials.md) [shared_workflow](shared_workflow.md) [variables](variables.md) [workflow_review_request](workflow_review_request.md) | | |
| id | varchar(36) | | false | [agent_chat_attachments](agent_chat_attachments.md) [agent_execution_threads](agent_execution_threads.md) [agents](agents.md) [data_table](data_table.md) [folder](folder.md) [insights_metadata](insights_metadata.md) [instance_ai_threads](instance_ai_threads.md) [project_relation](project_relation.md) [project_secrets_provider_access](project_secrets_provider_access.md) [role_mapping_rule_project](role_mapping_rule_project.md) [shared_credentials](shared_credentials.md) [shared_workflow](shared_workflow.md) [variables](variables.md) [workflow_review_request](workflow_review_request.md) | | |
| name | varchar(255) | | false | | | |
| type | varchar(36) | | false | | | |
| updatedAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | |
@ -45,6 +45,7 @@ CREATE TABLE "project" ("id" varchar(36) PRIMARY KEY NOT NULL, "name" varchar(25
erDiagram
"project" }o--o| "user" : "FOREIGN KEY (creatorId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
"agent_chat_attachments" }o--|| "project" : "FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"agent_execution_threads" }o--|| "project" : "FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"agents" }o--|| "project" : "FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"data_table" }o--|| "project" : "FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
@ -87,6 +88,20 @@ erDiagram
TEXT settings
datetime_3_ updatedAt
}
"agent_chat_attachments" {
varchar_36_ agentId FK
TEXT binaryDataId
datetime_3_ createdAt
varchar_255_ fileName
INTEGER fileSizeBytes
varchar_16_ id PK
varchar_255_ mimeType
varchar_36_ projectId FK
varchar_255_ resourceId
varchar_32_ source
varchar_128_ threadId
datetime_3_ updatedAt
}
"agent_execution_threads" {
varchar_36_ agentId FK
varchar_255_ agentName

View File

@ -6,7 +6,7 @@
<summary><strong>Table Definition</strong></summary>
```sql
CREATE TABLE "scheduled_job" ("id" integer PRIMARY KEY NOT NULL, "name" varchar(255) NOT NULL, "workflowId" varchar(36), "nodeId" varchar(36), "taskType" varchar(128) NOT NULL, "payload" text NOT NULL DEFAULT ('{}'), "kind" varchar(16) NOT NULL, "cronExpression" varchar(255), "timezone" varchar(64), "intervalSeconds" integer, "fireAt" datetime(3), "enabled" boolean NOT NULL DEFAULT (true), "nextRunAt" datetime(3), "lastFiredAt" datetime(3), "maxAttempts" integer NOT NULL DEFAULT (1), "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "recurrenceUnit" varchar(16), "recurrenceSize" int, CONSTRAINT "CHK_scheduled_job_recurrence_unit" CHECK (("recurrenceUnit" IN ('hours', 'days', 'weeks', 'months'))), CONSTRAINT "CHK_scheduled_job_recurrence_size" CHECK (("recurrenceSize" >= 2)), CONSTRAINT "CHK_scheduled_job_cron_expression" CHECK (("kind" <> 'cron' OR "cronExpression" IS NOT NULL)), CONSTRAINT "CHK_scheduled_job_interval_seconds" CHECK (("kind" <> 'interval' OR "intervalSeconds" IS NOT NULL)), CONSTRAINT "CHK_scheduled_job_fire_at" CHECK (("kind" <> 'one_off' OR "fireAt" IS NOT NULL)), CONSTRAINT "CHK_scheduled_job_kind" CHECK ("kind" IN ('cron', 'interval', 'one_off', 'recurring_cron')), CONSTRAINT "CHK_scheduled_job_recurring_cron" CHECK ("kind" <> 'recurring_cron' OR ("cronExpression" IS NOT NULL AND "recurrenceUnit" IS NOT NULL AND "recurrenceSize" IS NOT NULL)), CONSTRAINT "FK_scheduled_job_workflowId" FOREIGN KEY ("workflowId") REFERENCES "workflow_published_version" ("workflowId") ON DELETE CASCADE ON UPDATE NO ACTION)
CREATE TABLE "scheduled_job" ("id" integer PRIMARY KEY NOT NULL, "name" varchar(255) NOT NULL, "workflowId" varchar(36), "nodeId" varchar(36), "taskType" varchar(128) NOT NULL, "payload" text NOT NULL DEFAULT ('{}'), "kind" varchar(16) NOT NULL, "cronExpression" varchar(255), "timezone" varchar(64), "intervalSeconds" integer, "fireAt" datetime(3), "enabled" boolean NOT NULL DEFAULT (true), "nextRunAt" datetime(3), "lastFiredAt" datetime(3), "maxAttempts" integer NOT NULL DEFAULT (1), "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "recurrenceUnit" varchar(16), "recurrenceSize" int, "misfirePolicy" varchar(16) NOT NULL DEFAULT 'coalesce' CONSTRAINT "CHK_scheduled_job_misfirePolicy" CHECK ("misfirePolicy" IN ('coalesce', 'skip')), "misfireGraceSeconds" int NOT NULL DEFAULT 60 CONSTRAINT "CHK_scheduled_job_misfireGraceSeconds" CHECK ("misfireGraceSeconds" > 0), CONSTRAINT "CHK_scheduled_job_recurrence_unit" CHECK (("recurrenceUnit" IN ('hours', 'days', 'weeks', 'months'))), CONSTRAINT "CHK_scheduled_job_recurrence_size" CHECK (("recurrenceSize" >= 2)), CONSTRAINT "CHK_scheduled_job_cron_expression" CHECK (("kind" <> 'cron' OR "cronExpression" IS NOT NULL)), CONSTRAINT "CHK_scheduled_job_interval_seconds" CHECK (("kind" <> 'interval' OR "intervalSeconds" IS NOT NULL)), CONSTRAINT "CHK_scheduled_job_fire_at" CHECK (("kind" <> 'one_off' OR "fireAt" IS NOT NULL)), CONSTRAINT "CHK_scheduled_job_kind" CHECK ("kind" IN ('cron', 'interval', 'one_off', 'recurring_cron')), CONSTRAINT "CHK_scheduled_job_recurring_cron" CHECK ("kind" <> 'recurring_cron' OR ("cronExpression" IS NOT NULL AND "recurrenceUnit" IS NOT NULL AND "recurrenceSize" IS NOT NULL)), CONSTRAINT "FK_scheduled_job_workflowId" FOREIGN KEY ("workflowId") REFERENCES "workflow_published_version" ("workflowId") ON DELETE CASCADE ON UPDATE NO ACTION)
```
</details>
@ -24,6 +24,8 @@ CREATE TABLE "scheduled_job" ("id" integer PRIMARY KEY NOT NULL, "name" varchar(
| kind | varchar(16) | | false | | | |
| lastFiredAt | datetime(3) | | true | | | |
| maxAttempts | INTEGER | 1 | false | | | |
| misfireGraceSeconds | INT | 60 | false | | | |
| misfirePolicy | varchar(16) | 'coalesce' | false | | | |
| name | varchar(255) | | false | | | |
| nextRunAt | datetime(3) | | true | | | |
| nodeId | varchar(36) | | true | | | |
@ -39,6 +41,8 @@ CREATE TABLE "scheduled_job" ("id" integer PRIMARY KEY NOT NULL, "name" varchar(
| Name | Type | Definition |
| ---- | ---- | ---------- |
| - | CHECK | CHECK ("misfirePolicy" IN ('coalesce', 'skip')) |
| - | CHECK | CHECK ("misfireGraceSeconds" > 0) |
| - | CHECK | CHECK (("recurrenceUnit" IN ('hours', 'days', 'weeks', 'months'))) |
| - | CHECK | CHECK (("recurrenceSize" >= 2)) |
| - | CHECK | CHECK (("kind" <> 'cron' OR "cronExpression" IS NOT NULL)) |
@ -75,6 +79,8 @@ erDiagram
varchar_16_ kind
datetime_3_ lastFiredAt
INTEGER maxAttempts
INT misfireGraceSeconds
varchar_16_ misfirePolicy
varchar_255_ name
datetime_3_ nextRunAt
varchar_36_ nodeId
@ -98,6 +104,7 @@ erDiagram
INTEGER leaseEpoch
datetime_3_ leaseExpiresAt
INTEGER maxAttempts
datetime_3_ missedAfter
TEXT payload
datetime_3_ runAt
datetime_3_ scheduledFor

View File

@ -6,7 +6,7 @@
<summary><strong>Table Definition</strong></summary>
```sql
CREATE TABLE "scheduled_task" ("id" integer PRIMARY KEY NOT NULL, "jobId" integer NOT NULL, "taskType" varchar(128) NOT NULL, "payload" text NOT NULL DEFAULT ('{}'), "scheduledFor" datetime(3) NOT NULL, "runAt" datetime(3) NOT NULL, "status" varchar(16) NOT NULL DEFAULT ('pending'), "attempts" integer NOT NULL DEFAULT (0), "maxAttempts" integer NOT NULL DEFAULT (1), "claimedBy" varchar(255), "leaseExpiresAt" datetime(3), "leaseEpoch" integer NOT NULL DEFAULT (0), "startedAt" datetime(3), "finishedAt" datetime(3), "errorMessage" text, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "dispatchedAt" datetime(3), CONSTRAINT "CHK_scheduled_task_running_lease" CHECK ("status" <> 'running' OR "leaseExpiresAt" IS NOT NULL), CONSTRAINT "CHK_scheduled_task_status" CHECK ("status" IN ('pending', 'running', 'succeeded', 'failed', 'missed', 'cancelled')), CONSTRAINT "FK_scheduled_task_jobId" FOREIGN KEY ("jobId") REFERENCES "scheduled_job" ("id") ON DELETE CASCADE)
CREATE TABLE "scheduled_task" ("id" integer PRIMARY KEY NOT NULL, "jobId" integer NOT NULL, "taskType" varchar(128) NOT NULL, "payload" text NOT NULL DEFAULT ('{}'), "scheduledFor" datetime(3) NOT NULL, "runAt" datetime(3) NOT NULL, "status" varchar(16) NOT NULL DEFAULT ('pending'), "attempts" integer NOT NULL DEFAULT (0), "maxAttempts" integer NOT NULL DEFAULT (1), "claimedBy" varchar(255), "leaseExpiresAt" datetime(3), "leaseEpoch" integer NOT NULL DEFAULT (0), "startedAt" datetime(3), "finishedAt" datetime(3), "errorMessage" text, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "dispatchedAt" datetime(3), "missedAfter" datetime(3), CONSTRAINT "CHK_scheduled_task_running_lease" CHECK ("status" <> 'running' OR "leaseExpiresAt" IS NOT NULL), CONSTRAINT "CHK_scheduled_task_status" CHECK ("status" IN ('pending', 'running', 'succeeded', 'failed', 'missed', 'cancelled')), CONSTRAINT "FK_scheduled_task_jobId" FOREIGN KEY ("jobId") REFERENCES "scheduled_job" ("id") ON DELETE CASCADE)
```
</details>
@ -26,6 +26,7 @@ CREATE TABLE "scheduled_task" ("id" integer PRIMARY KEY NOT NULL, "jobId" intege
| leaseEpoch | INTEGER | 0 | false | | | |
| leaseExpiresAt | datetime(3) | | true | | | |
| maxAttempts | INTEGER | 1 | false | | | |
| missedAfter | datetime(3) | | true | | | |
| payload | TEXT | '{}' | false | | | |
| runAt | datetime(3) | | false | | | |
| scheduledFor | datetime(3) | | false | | | |
@ -49,6 +50,7 @@ CREATE TABLE "scheduled_task" ("id" integer PRIMARY KEY NOT NULL, "jobId" intege
| IDX_scheduled_task_finishedAt | CREATE INDEX "IDX_scheduled_task_finishedAt" ON "scheduled_task" ("finishedAt") WHERE "finishedAt" IS NOT NULL |
| IDX_scheduled_task_jobId_scheduledFor | CREATE UNIQUE INDEX "IDX_scheduled_task_jobId_scheduledFor" ON "scheduled_task" ("jobId", "scheduledFor") |
| IDX_scheduled_task_leaseExpiresAt | CREATE INDEX "IDX_scheduled_task_leaseExpiresAt" ON "scheduled_task" ("leaseExpiresAt") WHERE "status" = 'running' |
| IDX_scheduled_task_missedAfter | CREATE INDEX "IDX_scheduled_task_missedAfter" ON "scheduled_task" ("missedAfter") WHERE "status" = 'pending' AND "missedAfter" IS NOT NULL |
| IDX_scheduled_task_runAt | CREATE INDEX "IDX_scheduled_task_runAt" ON "scheduled_task" ("runAt") WHERE "status" = 'pending' |
## Relations
@ -70,6 +72,7 @@ erDiagram
INTEGER leaseEpoch
datetime_3_ leaseExpiresAt
INTEGER maxAttempts
datetime_3_ missedAfter
TEXT payload
datetime_3_ runAt
datetime_3_ scheduledFor
@ -87,6 +90,8 @@ erDiagram
varchar_16_ kind
datetime_3_ lastFiredAt
INTEGER maxAttempts
INT misfireGraceSeconds
varchar_16_ misfirePolicy
varchar_255_ name
datetime_3_ nextRunAt
varchar_36_ nodeId

View File

@ -99,6 +99,8 @@ erDiagram
varchar_16_ kind
datetime_3_ lastFiredAt
INTEGER maxAttempts
INT misfireGraceSeconds
varchar_16_ misfirePolicy
varchar_255_ name
datetime_3_ nextRunAt
varchar_36_ nodeId

View File

@ -117,7 +117,7 @@
"@lezer/common": "^1.2.0",
"@mistralai/mistralai": "^1.10.0",
"@n8n/typeorm>@sentry/node": "catalog:sentry",
"@opentelemetry/api": "1.9.0",
"@opentelemetry/api": "catalog:",
"@opentelemetry/core": "2.8.0",
"@types/node": "^20.17.50",
"chokidar": "4.0.3",
@ -131,16 +131,6 @@
"tsconfig-paths": "^4.2.0",
"vue-tsc": "catalog:frontend",
"n8n-editor-ui>typescript": "npm:typescript-native-bridge@6.0.3-bridge.7.tsgo.7.0.2",
"@n8n/design-system>typescript": "npm:typescript-native-bridge@6.0.3-bridge.7.tsgo.7.0.2",
"@n8n/chat>typescript": "npm:typescript-native-bridge@6.0.3-bridge.7.tsgo.7.0.2",
"@n8n/composables>typescript": "npm:typescript-native-bridge@6.0.3-bridge.7.tsgo.7.0.2",
"@n8n/stores>typescript": "npm:typescript-native-bridge@6.0.3-bridge.7.tsgo.7.0.2",
"@n8n/i18n>typescript": "npm:typescript-native-bridge@6.0.3-bridge.7.tsgo.7.0.2",
"@n8n/rest-api-client>typescript": "npm:typescript-native-bridge@6.0.3-bridge.7.tsgo.7.0.2",
"@n8n/frontend-utils>typescript": "npm:typescript-native-bridge@6.0.3-bridge.7.tsgo.7.0.2",
"@n8n/frontend-constants>typescript": "npm:typescript-native-bridge@6.0.3-bridge.7.tsgo.7.0.2",
"@n8n/frontend-module-sdk>typescript": "npm:typescript-native-bridge@6.0.3-bridge.7.tsgo.7.0.2",
"@n8n/storybook>typescript": "npm:typescript-native-bridge@6.0.3-bridge.7.tsgo.7.0.2",
"gaxios": ">=7.1.1",
"google-gax": "^4.3.7",
"ws": ">=8.20.1",
@ -152,7 +142,7 @@
"tmp": "0.2.7",
"nodemailer": "8.0.10",
"validator": "13.15.26",
"zod": "3.25.67",
"zod": "3.25.76",
"js-yaml": "4.3.0",
"body-parser": "2.3.0",
"glob@10": "10.5.0",
@ -219,6 +209,7 @@
"@babel/traverse": "^7.23.2"
},
"patchedDependencies": {
"axios": "patches/axios.patch",
"bull@4.16.4": "patches/bull@4.16.4.patch",
"pdfjs-dist@5.4.296": "patches/pdfjs-dist@5.4.296.patch",
"pkce-challenge@5.0.0": "patches/pkce-challenge@5.0.0.patch",

View File

@ -2,6 +2,9 @@
"name": "@n8n/agents",
"version": "0.18.0",
"description": "AI agent SDK for n8n's code-first execution engine",
"engines": {
"node": ">=22.22"
},
"main": "dist/index.js",
"module": "dist/index.js",
"types": "dist/index.d.ts",
@ -99,6 +102,7 @@
"@ai-sdk/mistral": "catalog:",
"@ai-sdk/openai": "catalog:",
"@ai-sdk/openai-compatible": "catalog:",
"@ai-sdk/otel": "catalog:",
"@ai-sdk/provider-utils": "catalog:",
"@ai-sdk/xai": "catalog:",
"@daytona/sdk": "catalog:",
@ -153,7 +157,7 @@
"devDependencies": {
"@n8n/typescript-config": "workspace:*",
"@n8n/vitest-config": "workspace:*",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/api": "catalog:",
"@opentelemetry/context-async-hooks": "^2.7.1",
"@pinecone-database/pinecone": "catalog:",
"@qdrant/js-client-rest": "catalog:",

View File

@ -70,30 +70,30 @@
}
},
"status": 200,
"response": "{\"model\":\"claude-sonnet-4-5-20250929\",\"id\":\"msg_012K3xFxcJX7WhHnhX9kY1oR\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"I'll delegate the token-production workstream to a child agent now.\"},{\"type\":\"tool_use\",\"id\":\"toolu_01Lj9QUCL4JZRHi7v4gavjLr\",\"name\":\"delegate_subagent\",\"input\":{\"subAgentId\":\"inline\",\"taskName\":\"token_production\",\"goal\":\"Answer with exactly this token and nothing else: SUBAGENT_OK_731\",\"expectedOutput\":\"The exact token: SUBAGENT_OK_731\"},\"caller\":{\"type\":\"direct\"}}],\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":1610,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":156,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"response": "{\"model\":\"claude-sonnet-4-5-20250929\",\"id\":\"msg_011CdY77GjDU1mocMqHQcv6A\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"I'll delegate the token-production workstream to a child agent now.\"},{\"type\":\"tool_use\",\"id\":\"toolu_01CZUTUiAvbDqYgaeAgFHVtb\",\"name\":\"delegate_subagent\",\"input\":{\"subAgentId\":\"inline\",\"taskName\":\"token_production\",\"goal\":\"Answer with exactly this token and nothing else: SUBAGENT_OK_731\",\"expectedOutput\":\"The exact token SUBAGENT_OK_731 with no additional text\",\"difficulty\":\"low\"},\"caller\":{\"type\":\"direct\"}}],\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":1610,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":177,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"rawHeaders": {
"anthropic-ratelimit-input-tokens-limit": "22500000",
"anthropic-ratelimit-input-tokens-remaining": "22499000",
"anthropic-ratelimit-input-tokens-reset": "2026-07-03T09:47:07Z",
"anthropic-ratelimit-input-tokens-reset": "2026-07-30T10:45:01Z",
"anthropic-ratelimit-output-tokens-limit": "4500000",
"anthropic-ratelimit-output-tokens-remaining": "4500000",
"anthropic-ratelimit-output-tokens-reset": "2026-07-03T09:47:09Z",
"anthropic-ratelimit-output-tokens-reset": "2026-07-30T10:45:03Z",
"anthropic-ratelimit-requests-limit": "20000",
"anthropic-ratelimit-requests-remaining": "19999",
"anthropic-ratelimit-requests-reset": "2026-07-03T09:47:06Z",
"anthropic-ratelimit-requests-reset": "2026-07-30T10:45:01Z",
"anthropic-ratelimit-tokens-limit": "27000000",
"anthropic-ratelimit-tokens-remaining": "26999000",
"anthropic-ratelimit-tokens-reset": "2026-07-03T09:47:07Z",
"anthropic-ratelimit-tokens-reset": "2026-07-30T10:45:01Z",
"cf-cache-status": "DYNAMIC",
"cf-ray": "a154f7851f175a71-VIE",
"cf-ray": "a233c5791b1aaf0a-VIE",
"connection": "keep-alive",
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
"content-type": "application/json",
"date": "Fri, 03 Jul 2026 09:47:09 GMT",
"request-id": "req_011CceurQthsXSCzpnUgvZvw",
"date": "Thu, 30 Jul 2026 10:45:03 GMT",
"request-id": "req_011CdY77G23pvwo6b3ZSbkve",
"server": "cloudflare",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"traceresponse": "00-56bd33f220720f2f1f4a35eeb181835e-c789f58cc4f780ab-01",
"traceresponse": "00-c432b82753f197b27b2e2df694473680-77db5dd7760377be-01",
"vary": "Accept-Encoding",
"x-robots-tag": "none"
},
@ -118,37 +118,43 @@
"content": [
{
"type": "text",
"text": "You are a focused subagent working on a specific delegated task.\n\nYOUR TASK:\nAnswer with exactly this token and nothing else: SUBAGENT_OK_731\n\nEXPECTED OUTPUT:\nThe exact token: SUBAGENT_OK_731\n\nComplete this task using the tools available to you. When finished, provide a clear, concise summary of:\n- What you did\n- What you found or accomplished\n- Important outputs, decisions, or evidence\n- Any issues, assumptions, or limitations\n\nIf the information above is insufficient, do your best with explicitly stated assumptions and note what was missing, rather than stopping to ask.\n\nBe thorough but concise -- your response is returned to the parent agent as a summary."
"text": "You are a focused subagent working on a specific delegated task.\n\nYOUR TASK:\nAnswer with exactly this token and nothing else: SUBAGENT_OK_731\n\nEXPECTED OUTPUT:\nThe exact token SUBAGENT_OK_731 with no additional text\n\nComplete this task using the tools available to you. When finished, provide a clear, concise summary of:\n- What you did\n- What you found or accomplished\n- Important outputs, decisions, or evidence\n- Any issues, assumptions, or limitations\n\nIf the information above is insufficient, do your best with explicitly stated assumptions and note what was missing, rather than stopping to ask.\n\nBe thorough but concise -- your response is returned to the parent agent as a summary."
}
]
}
]
],
"stream": true
},
"status": 200,
"response": "{\"model\":\"claude-sonnet-4-5-20250929\",\"id\":\"msg_01RCtizX8RhWexuf9c2mMcf7\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"I have completed the delegated task.\\n\\n**What I did:**\\nI was instructed to respond with exactly one specific token and nothing else.\\n\\n**What I accomplished:**\\nI am providing the exact token as requested:\\n\\nSUBAGENT_OK_731\\n\\n**Important outputs:**\\nThe token \\\"SUBAGENT_OK_731\\\" has been delivered as the primary output.\\n\\n**Issues, assumptions, or limitations:**\\nNone. The task was straightforward and complete as specified.\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":199,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":106,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"response": [
"1f8b0800000000000013c556dd4fdb30107fe7afb0ee11b5d01610226f0c6d1302c134d8d0b44ed1d5be36168e9dd997960ef57f9f9c26d094b2ed65252f51eecef1efe3fc4153b29c889c42c009a581d1f38e42c6443c02cf0b82045a49e834df903c42ee141948401a2c157583b396b87bd83dea0e7a83a3dec9e0043aa055fc4998a4bd7eff4c7d3b3efe3acb8abb33ffe96094fd1af1e50574d6a6820e7867620043d081d1c679a5b34c9621f9fea303815d917ac2e02c24b634a60e05fa599295d40a2a62d42634b1b281af6d5172caee9e6c8064d03be8804499512a3d216b67d37645afc97b42f55aae191b27a022a39c3c9af4287f59ff9ced67ebd945075cc92d781d08e4a75a52ca9a3c24108551e855d4d88ec947dee9841c24601da738456d706408160b513d8b9d1d5a1a5e6b998e8c93f79b6ddf50524da4e861c976351fc9d6e3981e6261f54a00166b3317da4ed6a6121083f01a3a4586f1cfe8aa9216ba65a48deaa9acc6762e66da981a61f36c1d85902e2f0c3109ce4828323441262518c3bd18cd45e1dd542b6d27559e1e50b2a87a4260103eb67b60527b433bb4375fde9d7e7c7f759b5e5fa4c707fd25b3ad138a48badd6e7cedeede94798e7e9eecee2ebfef3264712e945655a82b3e93243d252570c9989d5836fe9a2f6fe10c8a5090d4632d6bc52bf5cd3c2a5f674845129f2a8fa26919d5a543d8e8c6db3019829869ce84750295d2717b42236252685b81f6140a6703899153f3965528ab06d521a3c6b39b524a0a615c1a33afdd7a6ece279edbf7ab523e116bc247c41f9c316e561ba46d605fcaa8426c376da5291509eb388bcb8c4ca04696c2ebd8be2f76b16d316a6c593a729e17cec7d3b0163dd47e5cd716bc42bf1a1a424961ff3484322f22f3b07fa973cdd551d5fce7ca595a5d765be7bb276ee3028afbc02cae31f6a827198f9d9fa15702ad12d2107a337f5e7f7b15e2d78f3657fced6473c50ad097dc9bfbcf46d6ad24acb06c5d5080ac4ab9f416fef99ab2f87ff794b58b45ffe870b191f026ed5673d0ea94df4629ddbd4b0a0000"
],
"rawHeaders": {
"anthropic-ratelimit-input-tokens-limit": "22500000",
"anthropic-ratelimit-input-tokens-remaining": "22500000",
"anthropic-ratelimit-input-tokens-reset": "2026-07-03T09:47:11Z",
"anthropic-ratelimit-input-tokens-reset": "2026-07-30T10:45:04Z",
"anthropic-ratelimit-output-tokens-limit": "4500000",
"anthropic-ratelimit-output-tokens-remaining": "4500000",
"anthropic-ratelimit-output-tokens-reset": "2026-07-03T09:47:13Z",
"anthropic-ratelimit-output-tokens-reset": "2026-07-30T10:45:04Z",
"anthropic-ratelimit-requests-limit": "20000",
"anthropic-ratelimit-requests-remaining": "19999",
"anthropic-ratelimit-requests-reset": "2026-07-03T09:47:09Z",
"anthropic-ratelimit-requests-reset": "2026-07-30T10:45:04Z",
"anthropic-ratelimit-tokens-limit": "27000000",
"anthropic-ratelimit-tokens-remaining": "27000000",
"anthropic-ratelimit-tokens-reset": "2026-07-03T09:47:11Z",
"anthropic-ratelimit-tokens-reset": "2026-07-30T10:45:04Z",
"cache-control": "no-cache",
"cf-cache-status": "DYNAMIC",
"cf-ray": "a154f796ffa35a71-VIE",
"cf-ray": "a233c58b49a8af0a-VIE",
"connection": "keep-alive",
"content-encoding": "gzip",
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
"content-type": "application/json",
"date": "Fri, 03 Jul 2026 09:47:13 GMT",
"request-id": "req_011Cceurd9Q3cD97nREKGGRc",
"content-type": "text/event-stream; charset=utf-8",
"date": "Thu, 30 Jul 2026 10:45:06 GMT",
"request-id": "req_011CdY77UcaqaKMKaqT7bGg2",
"server": "cloudflare",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"traceresponse": "00-452cb1068aa94240a4653d8729e927b8-13490f8724a7c99f-01",
"traceresponse": "00-6cb6d2ad7228aa7af067bac3dd433c28-e13fb41eda258e2d-01",
"transfer-encoding": "chunked",
"vary": "Accept-Encoding",
"x-robots-tag": "none"
},
@ -186,13 +192,14 @@
},
{
"type": "tool_use",
"id": "toolu_01Lj9QUCL4JZRHi7v4gavjLr",
"id": "toolu_01CZUTUiAvbDqYgaeAgFHVtb",
"name": "delegate_subagent",
"input": {
"subAgentId": "inline",
"taskName": "token_production",
"goal": "Answer with exactly this token and nothing else: SUBAGENT_OK_731",
"expectedOutput": "The exact token: SUBAGENT_OK_731"
"expectedOutput": "The exact token SUBAGENT_OK_731 with no additional text",
"difficulty": "low"
},
"caller": {
"type": "direct"
@ -205,8 +212,8 @@
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01Lj9QUCL4JZRHi7v4gavjLr",
"content": "{\"status\":\"completed\",\"taskPath\":\"/root/token_production_0\",\"model\":\"anthropic/claude-sonnet-4-5\",\"answer\":\"I have completed the delegated task.\\n\\n**What I did:**\\nI was instructed to respond with exactly one specific token and nothing else.\\n\\n**What I accomplished:**\\nI am providing the exact token as requested:\\n\\nSUBAGENT_OK_731\\n\\n**Important outputs:**\\nThe token \\\"SUBAGENT_OK_731\\\" has been delivered as the primary output.\\n\\n**Issues, assumptions, or limitations:**\\nNone. The task was straightforward and complete as specified.\",\"usage\":{\"promptTokens\":199,\"completionTokens\":106,\"totalTokens\":305,\"cost\":0.002187},\"finishReason\":\"stop\"}"
"tool_use_id": "toolu_01CZUTUiAvbDqYgaeAgFHVtb",
"content": "{\"status\":\"completed\",\"taskPath\":\"/root/token_production_0\",\"model\":\"anthropic/claude-sonnet-4-5\",\"answer\":\"I will complete the delegated task by providing the exact token as requested.\\n\\nSUBAGENT_OK_731\\n\\n---\\n\\n**Summary:**\\n\\n**What I did:**\\n- Received a task to output a specific token exactly as specified\\n- Provided the token \\\"SUBAGENT_OK_731\\\" with no additional text in the response body\\n\\n**What I accomplished:**\\n- Successfully output the exact token: SUBAGENT_OK_731\\n- Followed the instruction to include nothing else in the primary response\\n\\n**Important outputs:**\\n- Output token: SUBAGENT_OK_731\\n\\n**Issues/Assumptions/Limitations:**\\n- None. The task was straightforward and clearly specified.\",\"usage\":{\"promptTokens\":203,\"completionTokens\":154,\"totalTokens\":357,\"cost\":0.0029189999999999997},\"finishReason\":\"stop\"}"
}
]
}
@ -258,30 +265,30 @@
}
},
"status": 200,
"response": "{\"model\":\"claude-sonnet-4-5-20250929\",\"id\":\"msg_01CYfxtWJEsWuYQWGKXmLVfM\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"PARENT_SAW SUBAGENT_OK_731\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":1970,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":16,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"response": "{\"model\":\"claude-sonnet-4-5-20250929\",\"id\":\"msg_011CdY77y57CBGMVPae4LjSG\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"PARENT_SAW SUBAGENT_OK_731\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":2054,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":16,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"rawHeaders": {
"anthropic-ratelimit-input-tokens-limit": "22500000",
"anthropic-ratelimit-input-tokens-remaining": "22499000",
"anthropic-ratelimit-input-tokens-reset": "2026-07-03T09:47:14Z",
"anthropic-ratelimit-input-tokens-reset": "2026-07-30T10:45:11Z",
"anthropic-ratelimit-output-tokens-limit": "4500000",
"anthropic-ratelimit-output-tokens-remaining": "4500000",
"anthropic-ratelimit-output-tokens-reset": "2026-07-03T09:47:15Z",
"anthropic-ratelimit-output-tokens-reset": "2026-07-30T10:45:11Z",
"anthropic-ratelimit-requests-limit": "20000",
"anthropic-ratelimit-requests-remaining": "19999",
"anthropic-ratelimit-requests-reset": "2026-07-03T09:47:13Z",
"anthropic-ratelimit-requests-reset": "2026-07-30T10:45:10Z",
"anthropic-ratelimit-tokens-limit": "27000000",
"anthropic-ratelimit-tokens-remaining": "26999000",
"anthropic-ratelimit-tokens-reset": "2026-07-03T09:47:14Z",
"anthropic-ratelimit-tokens-reset": "2026-07-30T10:45:11Z",
"cf-cache-status": "DYNAMIC",
"cf-ray": "a154f7b25eac5a71-VIE",
"cf-ray": "a233c5b33fe6af0a-VIE",
"connection": "keep-alive",
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
"content-type": "application/json",
"date": "Fri, 03 Jul 2026 09:47:15 GMT",
"request-id": "req_011CceurwrY7ok1DJYpoafrL",
"date": "Thu, 30 Jul 2026 10:45:11 GMT",
"request-id": "req_011CdY77wnxv5deK7MBPWK4M",
"server": "cloudflare",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"traceresponse": "00-6d0e52c70a0e9210823203ccf3e101ed-16b4d602826029ee-01",
"traceresponse": "00-287733e004d2ad52dd6086fb2ee96e2b-d1bf3a8a0b02cafa-01",
"vary": "Accept-Encoding",
"x-robots-tag": "none"
},

View File

@ -72,33 +72,33 @@
},
"status": 200,
"response": [
"1f8b0800000000000013cd58dd4fdb30107fe7afb0ee652fe9d4947540de40438c21c1344042ac93e5c6b7c4d4b583edf03194ff7d72d2d2a4948f6d8ca42f55ee2eb99fefee77b60faf50b9884cd15a9620b58e19b7c6996311b903779b2144d05042307f86e80ea69aa3840862c9728e3dab9542d7fbd01bf606fdc1b0bf35d8820004f71fb109ed877ba7a79f37e34f79b679f2e5dbfaf8d78e519be731044bae2000a3a517306b85754c79bfb1560e9583e8fb8f00acd31935c8ac5610a95cca99c8e2658e2ac68690a36342dab92c9fc3172acb1d757a82ca42140e37b60288599c228d0d3227b4a24d93fe5c6f90f1c774f377bd07cc529ca261920ea70fed17da305dd61601e8dcd5459b015834572246ea041a88c0478633c37d90d54f347ee134410d1128ed28bb6242b2b144280a52fe8ab535ac323e0b261d4b1d4f56e77d8549e988e34db5dabade2f76f69ec31b6f58fe450033d70def9950c9923b025e088f21e4281d7b1a6169d24058499ac8eecd66f8f6df4949384a4c9843e2522465bc7b99d13c8f7d1ec9b536936a196f8ece3a836c4a9c268cc4a9909cb00495234a5fbf5f44f6f1b4eaecb9aceaac066a45aafeb550c2270a456b49738bf31ee19f73da0fb72eede5f1b7841d0ecf06071bfce2f6ebf9ded92904a0d8d4bf37cf16b5f9b80c47e92dcb1d44778567a1949e1ef77eb83018bb8a04af97c1f061062b0a5f58adee8d33669c60b21436d8d03290bb11d87cbceda3b7cf6b1c6d1fd908223202a1a468c26a1d98c211bc761bf82b20011975a7901cb39343cfcba2752855e594ed9b76aaa4177b4995b8eed44fcb4812cd1a896a198eac2a685bd96b34a41b21ba162e2578c33ad179fc2eda85a0c85be25261579e2d5b01541d1a09539ca8eea0d22e152a21286d97588611393eddd9de830e9cc7760f4fe8d101dd58af07a8654c61877677bf55e04d8633e2b75d39fca8bc128f2082676eb46fc5fb119ca4d8a1fe4c9c86a265141354338aef1ede5772cb982a9ab71e9af57004c58b9af19fdeddc3faa7e623bb95ab6a28a1b68ac64cad7e3b7fe164adf88fa3b5a5595838fcb8c4b7c594f261dcea3a3f61fa0dee456cbef6140000"
"1f8b0800000000000013cd58df4fdb30107ee7afb0ee652fe9d400059ab7323186106c688c69ac93e5c6b7d4e0da597ce1c750fef7c9495b9a52184c43495faade9d7bdf7d77beb30eafd050c426e89c48903b1219ad494122627740b7294204352504b3df10ddc1c44ad41041ac452eb1e3ac31489dcd4eafb3de5def75fbeb7d084049ff272ee1dd307c27bf6def6ced9eede617bf0fcf55fa49f68f14044bae2080cc6a2f10ce2947c278bfb135848620fafe23004736e5190a670d4426d77a2a72f82b4713634d289184d26e26cb67f0954973e2642fd13888c2de763f8058c463e47186829435bc6ed29de93314f231ddecacf780e918279809cd7b9387f6f7da70bcac2d02b0392d8a760270985da9183929cc2002cf8c1499f4249b9f98f9c0798216223096b8b8124a8b9146280a36fd146b6b58e57c4a271f691b5faecefc0a93d295c49b2ade45bd0f777a8ef0c61b965f1140b1e43955265972c5c00be13174123589a7d19526357495a48e6a6e36c576f0466b2651632208198d91956c77d2ccca3cf65964d736bb84397f4f71f86a281d6528268c2c132c1e2b2d9948d01033f6faed0376979366d3bfa5d5a60b88187b4ea82f2f97f08972b156f3dce1ac57f8df39ef8647fbbdc1e65773961f6d0c4ede9ff477cf3f8cb6f621002326fedc2c6fdce5a39290d25b9a13447785bf8d5afb6b32f7235586315597e1ffe5307c98c3ea2a5f386be6c6a9c848095d0aefef44f340ee86e0f2d12059acf08621a181a2690c74208710b121a8d7bafaff04cb686570f8b0a3360226604320b1c04fc378dce5b198a04f5b3d674de31a423953b89f29ed61ab9a6e4368d1c5f70595d876254fe8aa0f0c8c8316cc8d6bccd8b5a2f19ca486f130bc117ea4368d43df324f4ac32894f3cfc736d4091a264c6b1e1992194be3968dd284a17618b1cf5f762b600d031aecd7f86918cddef1698b2603ff78c8b737c26a5a353ea55a55c743c09ba591d934a4146342d9a66afe586e525a513efe31713a4636cf5ad30f9c98ca9d475b5e14a6ecc883fdbde353dea20ee41b50bb5ea6be1d16cf61e8a52ba070658cb32df0cae86a4a5888a6b6a65d5cf43c73595bbce2b67669bd1af6b68ad521af22705107fecc1f39f1dccb4a170000"
],
"rawHeaders": {
"anthropic-ratelimit-input-tokens-limit": "22500000",
"anthropic-ratelimit-input-tokens-remaining": "22499000",
"anthropic-ratelimit-input-tokens-reset": "2026-07-03T09:47:15Z",
"anthropic-ratelimit-input-tokens-reset": "2026-07-30T10:45:12Z",
"anthropic-ratelimit-output-tokens-limit": "4500000",
"anthropic-ratelimit-output-tokens-remaining": "4500000",
"anthropic-ratelimit-output-tokens-reset": "2026-07-03T09:47:15Z",
"anthropic-ratelimit-output-tokens-reset": "2026-07-30T10:45:12Z",
"anthropic-ratelimit-requests-limit": "20000",
"anthropic-ratelimit-requests-remaining": "19999",
"anthropic-ratelimit-requests-reset": "2026-07-03T09:47:15Z",
"anthropic-ratelimit-requests-reset": "2026-07-30T10:45:12Z",
"anthropic-ratelimit-tokens-limit": "27000000",
"anthropic-ratelimit-tokens-remaining": "26999000",
"anthropic-ratelimit-tokens-reset": "2026-07-03T09:47:15Z",
"anthropic-ratelimit-tokens-reset": "2026-07-30T10:45:12Z",
"cache-control": "no-cache",
"cf-cache-status": "DYNAMIC",
"cf-ray": "a154f7bd1b9f5a71-VIE",
"cf-ray": "a233c5bd3cf3af0a-VIE",
"connection": "keep-alive",
"content-encoding": "gzip",
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
"content-type": "text/event-stream; charset=utf-8",
"date": "Fri, 03 Jul 2026 09:47:16 GMT",
"request-id": "req_011Cceus5ccUcu5S63pnkKab",
"date": "Thu, 30 Jul 2026 10:45:13 GMT",
"request-id": "req_011CdY785JetXbgfBdKXmCrT",
"server": "cloudflare",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"traceresponse": "00-12b7f86909b8222f45e788e80b7db1d9-5c63b97136e2016e-01",
"traceresponse": "00-05b1b866ae30456b98f16a457ac084d6-9ecac75622f59220-01",
"transfer-encoding": "chunked",
"vary": "Accept-Encoding",
"x-robots-tag": "none"
@ -128,33 +128,39 @@
}
]
}
]
],
"stream": true
},
"status": 200,
"response": "{\"model\":\"claude-sonnet-4-5-20250929\",\"id\":\"msg_01PJHYpyiarrMV6MCKXgF3w5\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"I have completed the delegated task.\\n\\n**What I did:**\\nI was instructed to respond with exactly one specific token and nothing else.\\n\\n**What I accomplished:**\\nSUBAGENT_OK_731\\n\\n**Important outputs:**\\nThe required token \\\"SUBAGENT_OK_731\\\" has been provided as the core output of this task.\\n\\n**Issues, assumptions, or limitations:**\\nNone. The task was straightforward with clear instructions and expected output.\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":199,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":102,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"response": [
"1f8b0800000000000013bd55c16edb300cbdf72b041e03a748b2065d7cdb8aad480b7487751b866530188bb5852a922bd2498b22ff3e5849363b4bb75eda5c0cf33d99ef910a494b7292aa05316341190b0639d22898aa4790878a20850e08c9ee1dd24758784d1652c82dd69afaec9d23e99ff4c7fdd160341e4c461348c0e8e6235c6483e1f04c7f3f7d7b517d2caa8b7a757e75f675ccc525247ba92081e06d134066c382aec99b7b27e404d21f3f1360f1551608d93b485d6ded36c4745793cba913d424682cef62f54ebe71552d99f85b720ce970324920c7bca42c0f8462bccbba8cc10e0f84fa296c77b6494055490b0a68b3f1e26ffe1f7458eea3eb047c2dedd02801a6b03439656228400a4d613406ddd4d8dd50687c67057948c179c97089c6e2dc12acd7aafd5b1f1dd1a6efdb926673ebf3dbc3dd3f4089f934dd6f4cb7f1c6f3f69cd07d438c8f14a0aba0ada132aed84baaa009c2533a3559c17feb8c948ece4da4abef376dab72aa4a5c5257ebab8b50b95f549684b4929294264b05c637e4dbe3999bb95eef5b89a2a64a1b9df67a3337552b64651c4ba8f348f50a1daf28a8959152d13de6621fe2f7e2755233f8fce5fdbbf30f57d7d9a7cbecf4cd70060a9d56ce4b695ca1c8327573611e75192e49c3fad5ab127dee6b8efaa68bca876644a8cd1f8623f5ba2415e8ae362196a3f15c22ab39915355f04ba3492b9cfb76bb5fbfd5c85124b190de967bca5c13270a99eb45d50c124e940fca9a859138583606afbca363d5d86cee45bc002c014d51ca8d0f2b0c3a3634b784c13ec0f326c0cb39e58a727363481f47294f8f205ffd6f02f9aa95fe90a7ddc23ae8a603424b7d67a300399d491d1c3c7bafac5f6eb1ec6d82c9c9fab0e143d56b63b039f50bc57ae4adf5070000"
],
"rawHeaders": {
"anthropic-ratelimit-input-tokens-limit": "22500000",
"anthropic-ratelimit-input-tokens-remaining": "22500000",
"anthropic-ratelimit-input-tokens-reset": "2026-07-03T09:47:19Z",
"anthropic-ratelimit-input-tokens-reset": "2026-07-30T10:45:15Z",
"anthropic-ratelimit-output-tokens-limit": "4500000",
"anthropic-ratelimit-output-tokens-remaining": "4500000",
"anthropic-ratelimit-output-tokens-reset": "2026-07-03T09:47:21Z",
"anthropic-ratelimit-output-tokens-reset": "2026-07-30T10:45:15Z",
"anthropic-ratelimit-requests-limit": "20000",
"anthropic-ratelimit-requests-remaining": "19999",
"anthropic-ratelimit-requests-reset": "2026-07-03T09:47:18Z",
"anthropic-ratelimit-requests-reset": "2026-07-30T10:45:15Z",
"anthropic-ratelimit-tokens-limit": "27000000",
"anthropic-ratelimit-tokens-remaining": "27000000",
"anthropic-ratelimit-tokens-reset": "2026-07-03T09:47:19Z",
"anthropic-ratelimit-tokens-reset": "2026-07-30T10:45:15Z",
"cache-control": "no-cache",
"cf-cache-status": "DYNAMIC",
"cf-ray": "a154f7ceeda75a71-VIE",
"cf-ray": "a233c5d16eb8af0a-VIE",
"connection": "keep-alive",
"content-encoding": "gzip",
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
"content-type": "application/json",
"date": "Fri, 03 Jul 2026 09:47:22 GMT",
"request-id": "req_011CceusHMZEyxDy3W4jTvmC",
"content-type": "text/event-stream; charset=utf-8",
"date": "Thu, 30 Jul 2026 10:45:16 GMT",
"request-id": "req_011CdY78JTg2zufpwvK2Uvbp",
"server": "cloudflare",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"traceresponse": "00-0a027964946a157205a48b7fb54cd817-2e95f627fe900e50-01",
"traceresponse": "00-20c5cb3b9b6d07470c38f23d730e5d0a-7ecbe286d9ef4513-01",
"transfer-encoding": "chunked",
"vary": "Accept-Encoding",
"x-robots-tag": "none"
},
@ -192,7 +198,7 @@
},
{
"type": "tool_use",
"id": "toolu_019qsqSRgaN5X2K7djyPZGXU",
"id": "toolu_01MG5A4WnVuM3AQFQ9BZHb6G",
"name": "delegate_subagent",
"input": {
"subAgentId": "inline",
@ -211,8 +217,8 @@
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_019qsqSRgaN5X2K7djyPZGXU",
"content": "{\"status\":\"completed\",\"taskPath\":\"/root/token_production_0\",\"model\":\"anthropic/claude-sonnet-4-5\",\"answer\":\"I have completed the delegated task.\\n\\n**What I did:**\\nI was instructed to respond with exactly one specific token and nothing else.\\n\\n**What I accomplished:**\\nSUBAGENT_OK_731\\n\\n**Important outputs:**\\nThe required token \\\"SUBAGENT_OK_731\\\" has been provided as the core output of this task.\\n\\n**Issues, assumptions, or limitations:**\\nNone. The task was straightforward with clear instructions and expected output.\",\"usage\":{\"promptTokens\":199,\"completionTokens\":102,\"totalTokens\":301,\"cost\":0.002127},\"finishReason\":\"stop\"}"
"tool_use_id": "toolu_01MG5A4WnVuM3AQFQ9BZHb6G",
"content": "{\"status\":\"completed\",\"taskPath\":\"/root/token_production_0\",\"model\":\"anthropic/claude-sonnet-4-5\",\"answer\":\"I have completed the delegated task.\\n\\n**What I did:**\\nI was instructed to answer with exactly the token \\\"SUBAGENT_OK_731\\\" and nothing else.\\n\\n**What I accomplished:**\\nSUBAGENT_OK_731\\n\\n**Important outputs:**\\nThe required token has been provided above as requested.\\n\\n**Issues, assumptions, or limitations:**\\nNone. The task was straightforward and clearly specified.\",\"usage\":{\"promptTokens\":199,\"completionTokens\":94,\"totalTokens\":293,\"cost\":0.002007},\"finishReason\":\"stop\"}"
}
]
}
@ -266,33 +272,33 @@
},
"status": 200,
"response": [
"1f8b0800000000000013bd53c16eda4010bdf315d69c8d847150c5de888452a92a8d0255ab54d56af04ec165d97576c72469e47faf1670b0a9697b697db1fcde1bcf7b331ada9161116dc97b5c91f48c8e7b0a1945f402fc5c1008689110d7df205e606b15691090692c15f5bd3586b87fd51ff58783e168301e8e21865c859ff8951c24e9a3ddcd6faeeebfff789e159f5dfa7673ffb874109fb582189cd50140ef73cf6842dfcc1a26c320be7c8dc1b32da423f4d68030a5d647c8d3434926a316a88831d7bec6cada7e6e8a9225db0d190f2219a7690c19666b929923e4dc1ad9960c6ade11aa4b5c5d1b3a50b1a62d39d472b4fd557f6293f5395bc5604b6e42c3183cb95d9e91e49c1c08089351e85418b2f9462e04972bb220c05896b8c35ce35213545554f57a7458f6718e72a96db6e95e798764df43d1d32168930f398f754c4f41b87f09802a6a3e2707456e56672d2308205c72a94833fedee55ed2727940daee5e65478fb793bbe96c7172fadf0dc8f9e26e3a792fe7934fd1fce3f5e4663a5bc80fefe49b3481eaf2d26cf1a79dd9a2e1a57b0ff56577866b91d008d33a3d20a32497cec05f1f60f50f2ff0ec649271753174d70c9b1c849a9f72983b7120050000"
"1f8b0800000000000013bd53c16ed34010bde72bac393b526c884af616aa08a48a8292002a08ada6de69b274bd6b76c7a1a8f2bf579bc4ad1d12ca057cb1fcdedb7d6f663cb421cb222929045c910c8c9e070a1945720ffcab2210d023216dbf41dc43e91419105018ac150d83b39678f872381ee6a37c3c9ae4134841ab784958c951969dababb3575f6aff292f7e96e7eafbc5d5cddb39a40756908277260218820e8c36fa16ce325906f1f55b0a815d253d61701684ad8dd943817ed4640bea818a18b5092d56b7f1b5ad6a96ec6ec90610d9241fa75060b126597842d6cecabe64d4f29e509de2dab3d181aa3595e4d1c871f9bbfe89cdd6876c9382abb90be52904f21b5d90644d1e04c4ce28f42a36d9de908f85cb153910601d4bdca036786d089a26797c9ac1807653df37545e1b57dc1e9ffd11c9d64cd1ddaee22e1f0bde9f63ba8bc2ed4b0074ec3bfe95b6ab03c3042208a7322a328c7fceb895f432ee907eb647d93ee187e97c76b93c91f33f65908be57c367d2717d3cfc9e2e3ebe99bd9e552bebf90672f32684e4fcd55cf0dcd559d2c49ef79bab6ddf2a3e5f548e894d35b4320ab24d7dec25f2f63f30fb7f1607db249f34ce9c77ad9e5e26ff90095166c1231050000"
],
"rawHeaders": {
"anthropic-ratelimit-input-tokens-limit": "22500000",
"anthropic-ratelimit-input-tokens-remaining": "22499000",
"anthropic-ratelimit-input-tokens-reset": "2026-07-03T09:47:22Z",
"anthropic-ratelimit-input-tokens-reset": "2026-07-30T10:45:18Z",
"anthropic-ratelimit-output-tokens-limit": "4500000",
"anthropic-ratelimit-output-tokens-remaining": "4500000",
"anthropic-ratelimit-output-tokens-reset": "2026-07-03T09:47:22Z",
"anthropic-ratelimit-output-tokens-reset": "2026-07-30T10:45:18Z",
"anthropic-ratelimit-requests-limit": "20000",
"anthropic-ratelimit-requests-remaining": "19999",
"anthropic-ratelimit-requests-reset": "2026-07-03T09:47:22Z",
"anthropic-ratelimit-requests-reset": "2026-07-30T10:45:18Z",
"anthropic-ratelimit-tokens-limit": "27000000",
"anthropic-ratelimit-tokens-remaining": "26999000",
"anthropic-ratelimit-tokens-reset": "2026-07-03T09:47:22Z",
"anthropic-ratelimit-tokens-reset": "2026-07-30T10:45:18Z",
"cache-control": "no-cache",
"cf-cache-status": "DYNAMIC",
"cf-ray": "a154f7e859e95a71-VIE",
"cf-ray": "a233c5e73a73af0a-VIE",
"connection": "keep-alive",
"content-encoding": "gzip",
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
"content-type": "text/event-stream; charset=utf-8",
"date": "Fri, 03 Jul 2026 09:47:23 GMT",
"request-id": "req_011Cceusam4TfXT9UkcRzWBk",
"date": "Thu, 30 Jul 2026 10:45:19 GMT",
"request-id": "req_011CdY78ZM7unYh8K5fxmvc1",
"server": "cloudflare",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"traceresponse": "00-8bdab5bad4bef9717932e4712f9614a8-9f37a11903c556c2-01",
"traceresponse": "00-590c063aee0ac5a9b22cb12a484c5572-37ff21bc76d37853-01",
"transfer-encoding": "chunked",
"vary": "Accept-Encoding",
"x-robots-tag": "none"

View File

@ -1,14 +1,14 @@
[
{
"scope": "https://api.anthropic.com:443",
"method": "POST",
"path": "/v1/messages",
"method": "POST",
"body": {
"model": "claude-sonnet-4-5",
"max_tokens": 64000,
"thinking": {
"type": "enabled",
"budget_tokens": 5000
"budget_tokens": 19200
},
"system": [
{
@ -77,7 +77,7 @@
"max_tokens": 64000,
"thinking": {
"type": "enabled",
"budget_tokens": 5000
"budget_tokens": 19200
},
"system": [
{

View File

@ -1,14 +1,14 @@
[
{
"scope": "https://api.anthropic.com:443",
"method": "POST",
"path": "/v1/messages",
"method": "POST",
"body": {
"model": "claude-sonnet-4-5",
"max_tokens": 64000,
"thinking": {
"type": "enabled",
"budget_tokens": 5000
"budget_tokens": 19200
},
"system": [
{

View File

@ -127,7 +127,7 @@
{
"type": "tool_result",
"tool_use_id": "toolu_01J4prxB28Ksk13jkhDsHkMd",
"content": "Invalid input for tool find_user: Type validation failed: Value: {\"age\":150}.\nError message: [\n {\n \"code\": \"too_big\",\n \"maximum\": 99,\n \"type\": \"number\",\n \"inclusive\": true,\n \"exact\": false,\n \"message\": \"age must be at most 99\",\n \"path\": [\n \"age\"\n ]\n }\n]",
"content": "AI_InvalidToolInputError: Invalid input for tool find_user: AI_TypeValidationError: Type validation failed: Value: {\"age\":150}.\nError message: [\n {\n \"code\": \"too_big\",\n \"maximum\": 99,\n \"type\": \"number\",\n \"inclusive\": true,\n \"exact\": false,\n \"message\": \"age must be at most 99\",\n \"path\": [\n \"age\"\n ]\n }\n]",
"is_error": true
}
]
@ -237,7 +237,7 @@
{
"type": "tool_result",
"tool_use_id": "toolu_01J4prxB28Ksk13jkhDsHkMd",
"content": "Invalid input for tool find_user: Type validation failed: Value: {\"age\":150}.\nError message: [\n {\n \"code\": \"too_big\",\n \"maximum\": 99,\n \"type\": \"number\",\n \"inclusive\": true,\n \"exact\": false,\n \"message\": \"age must be at most 99\",\n \"path\": [\n \"age\"\n ]\n }\n]",
"content": "AI_InvalidToolInputError: Invalid input for tool find_user: AI_TypeValidationError: Type validation failed: Value: {\"age\":150}.\nError message: [\n {\n \"code\": \"too_big\",\n \"maximum\": 99,\n \"type\": \"number\",\n \"inclusive\": true,\n \"exact\": false,\n \"message\": \"age must be at most 99\",\n \"path\": [\n \"age\"\n ]\n }\n]",
"is_error": true
}
]

View File

@ -8,7 +8,7 @@ const describe = describeIf('anthropic');
/**
* Integration tests for provider options: prompt caching, deep merge with
* thinking, external abort signal, and model config object form.
* reasoning, external abort signal, and model config object form.
*
* Prompt caching requires a system prompt of at least 1024 tokens for
* Anthropic, so we generate a long instruction string.
@ -149,31 +149,31 @@ describe('prompt caching via stream', () => {
});
// ---------------------------------------------------------------------------
// Thinking + cacheControl coexistence (deep merge)
// Reasoning + cacheControl coexistence
// ---------------------------------------------------------------------------
describe('thinking + cacheControl coexistence', () => {
it('both thinking and prompt caching work simultaneously', async () => {
describe('reasoning + cacheControl coexistence', () => {
it('both reasoning and prompt caching work simultaneously', async () => {
const agent = new Agent('thinking-cache-test')
.model('anthropic', 'claude-sonnet-4-5')
.thinking('anthropic', { budgetTokens: 5000 })
.reasoning('medium')
.instructions(LONG_SYSTEM_PROMPT, {
providerOptions: {
anthropic: { cacheControl: { type: 'ephemeral' } },
},
});
// First call — cache miss, but thinking should work
// First call — cache miss, but reasoning should work
const { stream: stream1 } = await agent.stream('What is 7 * 8?', {
persistence: { resourceId: 'user1', threadId: 'thread1' },
});
const chunks1 = await collectStreamChunks(stream1);
// Should have reasoning chunks (thinking is enabled)
// Should have reasoning chunks
const reasoningChunks = chunksOfType(chunks1, 'reasoning-delta');
expect(reasoningChunks.length).toBeGreaterThan(0);
// Second call — cache hit, thinking should still work
// Second call — cache hit, reasoning should still work
const { stream: stream2 } = await agent.stream('What is 12 * 13?', {
persistence: { resourceId: 'user1', threadId: 'thread2' },
});

View File

@ -6,7 +6,7 @@
* LangSmith, and verifies the full pipeline works end-to-end.
*
* Pipeline under test:
* Agent.generate() → AI SDK (generateText with experimental_telemetry)
* Agent.generate() → AI SDK (generateText with telemetry)
* → OTel spans with ai.operationId → LangSmithOTLPSpanProcessor
* → LangSmithOTLPTraceExporter → HTTP POST → captured by local server
*/
@ -176,7 +176,7 @@ describe('Telemetry → LangSmith integration', () => {
onStart: () => {
hookEvents.push('start');
},
onFinish: () => {
onEnd: () => {
hookEvents.push('finish');
},
})

View File

@ -8,16 +8,16 @@ import { Agent } from '../../index';
*
* These require models that support extended thinking:
* - Anthropic: claude-sonnet-4-5 (not haiku — it doesn't support thinking)
* - OpenAI: o3-mini (reasoning model)
* - OpenAI: gpt-5-mini (reasoning model with streamed summaries)
*/
const describeAnthropic = describeIf('anthropic');
describeAnthropic('thinking stream (Anthropic)', () => {
it('emits reasoning-delta chunks when thinking is enabled', async () => {
describeAnthropic('reasoning stream (Anthropic)', () => {
it('emits reasoning-delta chunks when reasoning is enabled', async () => {
const agent = new Agent('thinking-test')
.model('anthropic', 'claude-sonnet-4-5')
.thinking('anthropic', { budgetTokens: 5000 })
.reasoning('medium')
.instructions('You are a helpful assistant. Think carefully before answering.');
const { stream: fullStream } = await agent.stream('What is 17 * 23?');
@ -42,19 +42,28 @@ describeAnthropic('thinking stream (Anthropic)', () => {
const describeOpenAI = describeIf('openai');
describeOpenAI('thinking stream (OpenAI)', () => {
it('works with reasoning model and .thinking() enabled', async () => {
describeOpenAI('reasoning stream (OpenAI)', () => {
it('works with a reasoning model when reasoning is enabled', async () => {
const agent = new Agent('openai-thinking-test')
.model('openai', 'o3-mini')
.thinking('openai', { reasoningEffort: 'medium' })
.instructions('You are a helpful assistant.');
.model('openai', 'gpt-5-mini')
.reasoning('medium')
.instructions('You are a helpful assistant. Think carefully before answering.');
const { stream: fullStream } = await agent.stream('What is 17 * 23?');
const { stream: fullStream } = await agent.stream(
'Find the smallest positive integer divisible by every integer from 1 through 15, and explain your method.',
);
const chunks = await collectStreamChunks(fullStream);
// OpenAI reasoning models do internal reasoning but don't expose it
// as streamed chunks — verify the agent produces a text response.
const reasoningChunks = chunksOfType(chunks, 'reasoning-delta');
expect(reasoningChunks.length).toBeGreaterThan(0);
expect(
reasoningChunks
.filter((chunk): chunk is typeof chunk & { delta: string } => 'delta' in chunk)
.map((chunk) => chunk.delta)
.join('').length,
).toBeGreaterThan(0);
const textChunks = chunksOfType(chunks, 'text-delta');
expect(textChunks.length).toBeGreaterThan(0);
@ -62,6 +71,6 @@ describeOpenAI('thinking stream (OpenAI)', () => {
.filter((c): c is typeof c & { delta: string } => 'delta' in c)
.map((c) => c.delta)
.join('');
expect(text).toContain('391');
expect(text.length).toBeGreaterThan(0);
});
});

View File

@ -42,8 +42,8 @@ describe('Zod validation errors surface to LLM and allow self-correction', () =>
'Find a user aged 150. If that age is invalid, use 25 instead and retry. You MUST find a user aged 150, and only then use 25',
);
expect(result.finishReason).toBe('stop');
expect(result.error).toBeUndefined();
expect(result.finishReason).toBe('stop');
// At least two tool-call messages: one rejected, one resolved
const allMessages = filterLlmMessages(result.messages);

View File

@ -16,7 +16,7 @@ const mockBatchProcessorInstances: Array<{
}> = [];
const mockProviderConfigs: unknown[] = [];
const mockAwaitPendingTraceBatches = vi.fn(async () => await Promise.resolve());
const mockTracer = { startSpan: vi.fn() };
const mockTracer = { startSpan: vi.fn(), startActiveSpan: vi.fn() };
const mockProvider = {
getTracer: vi.fn(() => mockTracer),
register: vi.fn(),

View File

@ -27,6 +27,7 @@ export type {
OpenAIThinkingConfig,
GoogleThinkingConfig,
XaiThinkingConfig,
ReasoningLevel,
SerializableAgentState,
AgentRunState,
MemoryConfig,
@ -207,9 +208,12 @@ export { verify } from './sdk/verify';
export type { VerifyResult } from './sdk/verify';
export type {
ContentCitation,
ContentCustom,
ContentFile,
ContentFileRef,
ContentMetadata,
ContentReasoning,
ContentReasoningFile,
ContentText,
ContentToolCall,
Message,
@ -219,6 +223,8 @@ export type {
CustomAgentMessages,
AgentDbMessage,
} from './types/sdk/message';
export { stripHydratedFileData } from './types/sdk/message';
export type { BuiltFileStore } from './types/sdk/file-store';
export type { HandlerExecutor } from './types/sdk/handler-executor';
export {
filterLlmMessages,

View File

@ -12,6 +12,7 @@
* The tool-result is inserted right after its tool-call, regardless of what
* messages follow it in the n8n list.
*/
import type { ModelMessage } from 'ai';
import { describe, it, expect } from 'vitest';
import type { Message } from '../../types/sdk/message';
@ -154,6 +155,70 @@ describe('toAiMessages + fromAiMessages — round-trip', () => {
});
});
it('round-trips SDK 7 reasoning files with provider replay state', () => {
const aiMessage: ModelMessage = {
role: 'assistant',
content: [
{
type: 'reasoning-file',
data: 'base64-reasoning-data',
mediaType: 'application/octet-stream',
providerOptions: { google: { thoughtSignature: 'gemini-signature' } },
},
],
};
const agentMessages = fromAiMessages([aiMessage]);
expect(agentMessages).toEqual([
{
role: 'assistant',
content: [
{
type: 'reasoning-file',
data: 'base64-reasoning-data',
mediaType: 'application/octet-stream',
providerOptions: { google: { thoughtSignature: 'gemini-signature' } },
},
],
},
]);
expect(toAiMessages(agentMessages as Message[])).toEqual([aiMessage]);
});
it('round-trips SDK 7 custom provider state', () => {
const aiMessage: ModelMessage = {
role: 'assistant',
content: [
{
type: 'custom',
kind: 'openai.compaction',
providerOptions: {
openai: { itemId: 'cmp_123', encryptedContent: 'encrypted' },
},
},
],
};
const agentMessages = fromAiMessages([aiMessage]);
expect(agentMessages).toEqual([
{
role: 'assistant',
content: [
{
type: 'custom',
kind: 'openai.compaction',
providerOptions: {
openai: { itemId: 'cmp_123', encryptedContent: 'encrypted' },
},
},
],
},
]);
expect(toAiMessages(agentMessages as Message[])).toEqual([aiMessage]);
});
it.each([
{ name: 'no provider metadata', content: {} },
{

View File

@ -144,7 +144,7 @@ async function collectChunks(stream: ReadableStream<unknown>): Promise<StreamChu
/** Minimal successful streamText response. */
function makeStreamSuccess(text = 'Hello') {
return {
fullStream: makeChunkStream([{ type: 'text-delta', textDelta: text }]),
stream: makeChunkStream([{ type: 'text-delta', id: 'text-1', text }]),
finishReason: Promise.resolve('stop'),
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5, totalTokens: 15 }),
response: Promise.resolve({
@ -188,7 +188,7 @@ function makeStreamWithProviderTool(opts: {
};
const text = opts.text ?? 'done';
return {
fullStream: makeChunkStream([
stream: makeChunkStream([
{
type: 'tool-call',
toolCallId: opts.toolCallId,
@ -197,7 +197,7 @@ function makeStreamWithProviderTool(opts: {
providerExecuted: true,
},
terminal,
{ type: 'text-delta', textDelta: text },
{ type: 'text-delta', id: 'text-1', text },
]),
finishReason: Promise.resolve('stop'),
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5, totalTokens: 15 }),
@ -349,29 +349,50 @@ describe('AgentRuntime — execution counters', () => {
expect(counter.incrementTokenCount).toHaveBeenCalledWith(15);
});
it('forwards onStepStart and onStepFinish to generateText and streamText', async () => {
it('forwards onStepStart and onStepEnd to generateText and streamText', async () => {
generateText.mockResolvedValue(makeGenerateSuccess());
streamText.mockReturnValue(makeStreamSuccess());
const onStepStart = vi.fn();
const onStepFinish = vi.fn();
const onStepEnd = vi.fn();
const { runtime } = createRuntime();
await runtime.generate('hi', { onStepStart, onStepFinish });
const streamResult = await runtime.stream('hi', { onStepStart, onStepFinish });
await runtime.generate('hi', { onStepStart, onStepEnd });
const streamResult = await runtime.stream('hi', { onStepStart, onStepEnd });
await collectChunks(streamResult.stream);
for (const call of generateText.mock.calls) {
const args = call[0] as Record<string, unknown>;
expect(args.experimental_onStepStart).toBe(onStepStart);
expect(args.onStepFinish).toBe(onStepFinish);
expect(args.onStepStart).toBe(onStepStart);
expect(args.onStepEnd).toBe(onStepEnd);
}
for (const call of streamText.mock.calls) {
const args = call[0] as Record<string, unknown>;
expect(args.experimental_onStepStart).toBe(onStepStart);
expect(args.onStepFinish).toBe(onStepFinish);
expect(args.onStepStart).toBe(onStepStart);
expect(args.onStepEnd).toBe(onStepEnd);
}
});
it('allows system-role messages in generateText and streamText history', async () => {
generateText.mockResolvedValue(makeGenerateSuccess());
streamText.mockReturnValue(makeStreamSuccess());
const input: Message[] = [
{ role: 'system', content: [{ type: 'text', text: 'Historical instruction' }] },
{ role: 'user', content: [{ type: 'text', text: 'Hello' }] },
];
const { runtime } = createRuntime();
await runtime.generate(input);
const streamResult = await runtime.stream(input);
await collectChunks(streamResult.stream);
expect(generateText.mock.calls[0]?.[0]).toEqual(
expect.objectContaining({ allowSystemInMessages: true }),
);
expect(streamText.mock.calls[0]?.[0]).toEqual(
expect.objectContaining({ allowSystemInMessages: true }),
);
});
it('counts provider-executed tool calls when surfaced by the model', async () => {
generateText
.mockResolvedValueOnce({
@ -545,7 +566,7 @@ describe('AgentRuntime — execution counters', () => {
streamText
.mockReturnValueOnce({
fullStream: makeChunkStream([{ type: 'text-delta', textDelta: 'thinking...' }]),
stream: makeChunkStream([{ type: 'text-delta', id: 'text-1', text: 'thinking...' }]),
finishReason: Promise.resolve('tool-calls'),
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5, totalTokens: 15 }),
response: Promise.resolve({
@ -949,7 +970,7 @@ describe('AgentRuntime — eager input persistence', () => {
// Same shape as the generate case, on the streaming path (the one Instance AI uses):
// the abort is handled by the StreamSession, which must persist the turn-so-far.
streamText.mockReturnValueOnce({
fullStream: makeChunkStream([{ type: 'text-delta', textDelta: 'on it...' }]),
stream: makeChunkStream([{ type: 'text-delta', id: 'text-1', text: 'on it...' }]),
finishReason: Promise.resolve('tool-calls'),
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5, totalTokens: 15 }),
response: Promise.resolve({
@ -985,7 +1006,7 @@ describe('AgentRuntime — eager input persistence', () => {
// The model streams two text deltas, then the stop lands — before the turn
// completes, so its `newMessages` are never built (finishReason/response reject).
streamText.mockReturnValueOnce({
fullStream: (async function* () {
stream: (async function* () {
yield { type: 'text-delta', id: 't1', text: 'Here is my ' };
yield { type: 'text-delta', id: 't1', text: 'partial answer' };
await Promise.resolve();
@ -1020,7 +1041,7 @@ describe('AgentRuntime — eager input persistence', () => {
// fires as the stream closes — before the loop folds the turn into the list. The
// completed text must still be recovered (the clear is deferred until the fold).
streamText.mockReturnValueOnce({
fullStream: (async function* () {
stream: (async function* () {
yield { type: 'text-delta', id: 't1', text: 'Complete answer' };
await Promise.resolve();
controller.abort();
@ -1131,7 +1152,7 @@ describe('AgentRuntime.stream() — usage billing on abort', () => {
// provider's `message_start` raw event already carried the input/cache +
// initial output tokens. The run must bill those.
streamText.mockReturnValue({
fullStream: (async function* () {
stream: (async function* () {
yield {
type: 'raw',
rawValue: {
@ -1187,7 +1208,7 @@ describe('AgentRuntime.stream() — usage billing on abort', () => {
streamText
// Turn 1 completes with a tool call; its usage is folded into the total.
.mockReturnValueOnce({
fullStream: makeChunkStream([{ type: 'text-delta', textDelta: 'thinking...' }]),
stream: makeChunkStream([{ type: 'text-delta', id: 'text-1', text: 'thinking...' }]),
finishReason: Promise.resolve('tool-calls'),
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5, totalTokens: 15 }),
response: Promise.resolve({
@ -1207,7 +1228,7 @@ describe('AgentRuntime.stream() — usage billing on abort', () => {
// Turn 2 aborts mid-stream after the provider reported its raw usage; the
// SDK promises reject so the only signal is the captured raw event.
.mockReturnValueOnce({
fullStream: (async function* () {
stream: (async function* () {
yield {
type: 'raw',
rawValue: {
@ -1258,7 +1279,7 @@ describe('AgentRuntime.stream() — usage billing on abort', () => {
// Single turn: emits a raw usage event (captured) AND resolves its final
// usage. The raw capture must not be re-added on top of the folded total.
streamText.mockReturnValueOnce({
fullStream: makeChunkStream([
stream: makeChunkStream([
{
type: 'raw',
rawValue: {
@ -1310,7 +1331,7 @@ describe('AgentRuntime.stream() — usage billing on abort', () => {
streamText.mockReturnValue({
// Sync generator: `for await` consumes it fine and there's nothing to await.
fullStream: (function* () {
stream: (function* () {
yield {
type: 'raw',
rawValue: {
@ -1339,19 +1360,19 @@ describe('AgentRuntime.stream() — usage billing on abort', () => {
expect(runtime.getState().status).toBe('cancelled');
});
it('requests includeRawChunks only when recoverUsageOnAbort is set', async () => {
it('requests raw chunks only when recoverUsageOnAbort is set', async () => {
streamText.mockReturnValue(makeStreamSuccess('ok'));
const off = createRuntime(undefined, 'anthropic/claude-sonnet-4-6');
await collectChunks((await off.runtime.stream('hello')).stream);
expect(streamText.mock.calls.at(-1)?.[0]).not.toHaveProperty('includeRawChunks');
expect(streamText.mock.calls.at(-1)?.[0]).not.toHaveProperty('include.rawChunks');
streamText.mockClear();
streamText.mockReturnValue(makeStreamSuccess('ok'));
const on = createRuntime(undefined, 'anthropic/claude-sonnet-4-6');
await collectChunks((await on.runtime.stream('hello', { recoverUsageOnAbort: true })).stream);
expect(streamText.mock.calls.at(-1)?.[0]).toMatchObject({ includeRawChunks: true });
expect(streamText.mock.calls.at(-1)?.[0]).toHaveProperty('include.rawChunks', true);
});
});
@ -1367,7 +1388,7 @@ describe('AgentRuntime.stream() — graceful error contract', () => {
it('resolves (never rejects) when the LLM stream throws', async () => {
const streamError = new Error('stream broke');
streamText.mockReturnValue({
fullStream: makeErrorStream(streamError),
stream: makeErrorStream(streamError),
finishReason: silentReject(streamError),
usage: Promise.resolve(undefined),
response: silentReject(streamError),
@ -1381,7 +1402,7 @@ describe('AgentRuntime.stream() — graceful error contract', () => {
it('yields an error chunk when the LLM stream throws', async () => {
const streamError = new Error('stream broke');
streamText.mockReturnValue({
fullStream: makeErrorStream(streamError),
stream: makeErrorStream(streamError),
finishReason: silentReject(streamError),
usage: Promise.resolve(undefined),
response: silentReject(streamError),
@ -1399,7 +1420,7 @@ describe('AgentRuntime.stream() — graceful error contract', () => {
it('closes the stream cleanly (with a finish chunk) after an error', async () => {
const streamError = new Error('stream broke');
streamText.mockReturnValue({
fullStream: makeErrorStream(streamError),
stream: makeErrorStream(streamError),
finishReason: silentReject(streamError),
usage: Promise.resolve(undefined),
response: silentReject(streamError),
@ -1423,7 +1444,7 @@ describe('AgentRuntime.stream() — graceful error contract', () => {
it('sets state to "failed" after a stream error', async () => {
const streamError = new Error('stream broke');
streamText.mockReturnValue({
fullStream: makeErrorStream(streamError),
stream: makeErrorStream(streamError),
finishReason: silentReject(streamError),
usage: Promise.resolve(undefined),
response: silentReject(streamError),
@ -1456,7 +1477,7 @@ describe('AgentRuntime.stream() — graceful error contract', () => {
const streamError = new Error('transient');
streamText
.mockReturnValueOnce({
fullStream: makeErrorStream(streamError),
stream: makeErrorStream(streamError),
finishReason: silentReject(streamError),
usage: Promise.resolve(undefined),
response: silentReject(streamError),
@ -1483,7 +1504,7 @@ describe('AgentRuntime.stream() — graceful error contract', () => {
it('never throws when consuming the stream after an error', async () => {
const streamError = new Error('stream broke');
streamText.mockReturnValue({
fullStream: makeErrorStream(streamError),
stream: makeErrorStream(streamError),
finishReason: silentReject(streamError),
usage: Promise.resolve(undefined),
response: silentReject(streamError),
@ -2700,7 +2721,7 @@ describe('AgentRuntime — concurrent tool execution', () => {
const { runtime } = createRuntimeWithTools([suspendTool], 1);
streamText.mockReturnValue({
fullStream: makeChunkStream([{ type: 'text-delta', textDelta: 'thinking...' }]),
stream: makeChunkStream([{ type: 'text-delta', id: 'text-1', text: 'thinking...' }]),
finishReason: Promise.resolve('tool-calls'),
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5, totalTokens: 15 }),
response: Promise.resolve({
@ -2763,7 +2784,7 @@ describe('AgentRuntime — concurrent tool execution', () => {
const { runtime } = createRuntimeWithTools([suspendTool], Infinity);
streamText.mockReturnValue({
fullStream: makeChunkStream([{ type: 'text-delta', textDelta: 'thinking...' }]),
stream: makeChunkStream([{ type: 'text-delta', id: 'text-1', text: 'thinking...' }]),
finishReason: Promise.resolve('tool-calls'),
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5, totalTokens: 15 }),
response: Promise.resolve({
@ -2812,7 +2833,7 @@ describe('AgentRuntime — concurrent tool execution', () => {
const { runtime } = createRuntimeWithTools([suspendTool], 1);
streamText.mockReturnValue({
fullStream: makeChunkStream([{ type: 'text-delta', textDelta: 'thinking...' }]),
stream: makeChunkStream([{ type: 'text-delta', id: 'text-1', text: 'thinking...' }]),
finishReason: Promise.resolve('tool-calls'),
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5, totalTokens: 15 }),
response: Promise.resolve({
@ -2887,7 +2908,7 @@ describe('AgentRuntime — concurrent tool execution', () => {
streamText
.mockReturnValueOnce({
fullStream: makeChunkStream([{ type: 'text-delta', textDelta: 'thinking...' }]),
stream: makeChunkStream([{ type: 'text-delta', id: 'text-1', text: 'thinking...' }]),
finishReason: Promise.resolve('tool-calls'),
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5, totalTokens: 15 }),
response: Promise.resolve({
@ -3225,7 +3246,7 @@ describe('AgentRuntime.stream() — structured output', () => {
const expected = { answer: '5', score: 1 };
streamText
.mockReturnValueOnce({
fullStream: makeChunkStream([
stream: makeChunkStream([
{ type: 'tool-call', toolCallId: 'tc-1', toolName: 'add', args: { a: 2, b: 3 } },
]),
finishReason: Promise.resolve('tool-calls'),
@ -4049,7 +4070,7 @@ describe('AgentRuntime — tool approval (HITL wrapper)', () => {
.build();
streamText.mockReturnValue({
fullStream: makeChunkStream([{ type: 'text-delta', textDelta: 'checking...' }]),
stream: makeChunkStream([{ type: 'text-delta', id: 'text-1', text: 'checking...' }]),
finishReason: Promise.resolve('tool-calls'),
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5, totalTokens: 15 }),
response: Promise.resolve({
@ -4119,7 +4140,7 @@ describe('AgentRuntime — tool approval (HITL wrapper)', () => {
streamText
.mockReturnValueOnce({
fullStream: makeChunkStream([{ type: 'text-delta', textDelta: 'checking...' }]),
stream: makeChunkStream([{ type: 'text-delta', id: 'text-1', text: 'checking...' }]),
finishReason: Promise.resolve('tool-calls'),
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5, totalTokens: 15 }),
response: Promise.resolve({
@ -4295,7 +4316,7 @@ describe('external abort signal', () => {
streamText
.mockReturnValueOnce({
fullStream: makeChunkStream([{ type: 'text-delta', textDelta: 'checking...' }]),
stream: makeChunkStream([{ type: 'text-delta', id: 'text-1', text: 'checking...' }]),
finishReason: Promise.resolve('tool-calls'),
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5, totalTokens: 15 }),
response: Promise.resolve({
@ -4405,7 +4426,7 @@ describe('AgentRuntime — abort during a tool batch', () => {
toolCalls: Array<{ toolCallId: string; toolName: string; args: Record<string, unknown> }>,
) {
return {
fullStream: makeChunkStream([{ type: 'text-delta', textDelta: 'calling tools...' }]),
stream: makeChunkStream([{ type: 'text-delta', id: 'text-1', text: 'calling tools...' }]),
finishReason: Promise.resolve('tool-calls'),
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5, totalTokens: 15 }),
response: Promise.resolve({
@ -4640,22 +4661,22 @@ describe('AgentRuntime.resume() — checkpoint lifecycle', () => {
});
// ---------------------------------------------------------------------------
// Provider options merging
// Generic reasoning
// ---------------------------------------------------------------------------
describe('provider options merging', () => {
describe('reasoning', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should deep-merge thinking config with call-level providerOptions', async () => {
it('passes reasoning as a top-level generateText option without provider-specific settings', async () => {
generateText.mockResolvedValue(makeGenerateSuccess());
const runtime = new AgentRuntime({
name: 'test',
model: 'anthropic/claude-sonnet-4-5',
instructions: 'You are a test assistant.',
thinking: { budgetTokens: 10000 },
reasoning: 'high',
});
await runtime.generate('hello', {
@ -4666,13 +4687,53 @@ describe('provider options merging', () => {
});
const callArgs = generateText.mock.calls[0][0] as Record<string, unknown>;
expect(callArgs.reasoning).toBe('high');
expect((callArgs.providerOptions as Record<string, Record<string, unknown>>).anthropic).toEqual(
{
thinking: { type: 'enabled', budgetTokens: 10000 },
cacheControl: { type: 'ephemeral' },
},
{ cacheControl: { type: 'ephemeral' } },
);
});
it('passes reasoning as a top-level streamText option', async () => {
streamText.mockReturnValue(makeStreamSuccess());
const runtime = new AgentRuntime({
name: 'test',
model: 'openai/gpt-5.1',
instructions: 'You are a test assistant.',
reasoning: 'low',
});
const result = await runtime.stream('hello');
await collectChunks(result.stream);
const callArgs = streamText.mock.calls[0][0] as Record<string, unknown>;
expect(callArgs.reasoning).toBe('low');
expect(callArgs.providerOptions).toBeUndefined();
});
});
describe('provider-specific thinking', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('maps a fixed Anthropic thinking budget into providerOptions', async () => {
generateText.mockResolvedValue(makeGenerateSuccess());
const runtime = new AgentRuntime({
name: 'test',
model: 'anthropic/claude-sonnet-4-5',
instructions: 'You are a test assistant.',
thinking: { budgetTokens: 4096 },
});
await runtime.generate('hello');
const callArgs = generateText.mock.calls[0][0] as Record<string, unknown>;
expect(callArgs.reasoning).toBeUndefined();
expect(callArgs.providerOptions).toEqual({
anthropic: { thinking: { type: 'enabled', budgetTokens: 4096 } },
});
});
});
// ---------------------------------------------------------------------------
@ -4686,7 +4747,7 @@ describe('tool systemInstruction merging', () => {
function getSystemMessageText(): string {
const callArgs = generateText.mock.calls[0][0] as Record<string, unknown>;
const systemMsg = callArgs.system;
const systemMsg = callArgs.instructions;
if (Array.isArray(systemMsg)) {
return systemMsg.map((entry) => String((entry as { content: string }).content)).join('');
}
@ -4819,10 +4880,10 @@ describe('tool systemInstruction merging', () => {
await runtime.generate('load the deferred capability');
const calls = generateText.mock.calls as Array<
[{ system: Array<{ content: string }> | { content: string } }]
[{ instructions: Array<{ content: string }> | { content: string } }]
>;
const beforeLoadSystem = calls[0][0].system;
const afterLoadSystem = calls[1][0].system;
const beforeLoadSystem = calls[0][0].instructions;
const afterLoadSystem = calls[1][0].instructions;
const beforeFirst = Array.isArray(beforeLoadSystem)
? beforeLoadSystem[0].content
@ -4866,7 +4927,7 @@ describe('instruction providerOptions', () => {
});
const callArgs = generateText.mock.calls[0][0] as Record<string, unknown>;
const systemMsg = callArgs.system as Record<string, unknown>;
const systemMsg = callArgs.instructions as Record<string, unknown>;
expect(systemMsg.role).toBe('system');
expect(systemMsg.providerOptions).toEqual({
anthropic: { cacheControl: { type: 'ephemeral' } },
@ -4896,7 +4957,7 @@ describe('promptCaching', () => {
await runtime.generate('hello');
const callArgs = generateText.mock.calls[0][0] as Record<string, unknown>;
const systemMsg = callArgs.system as Record<string, unknown>;
const systemMsg = callArgs.instructions as Record<string, unknown>;
expect(systemMsg.providerOptions).toEqual({
anthropic: { cacheControl: { type: 'ephemeral', ttl: '1h' } },
});
@ -4924,14 +4985,14 @@ describe('promptCaching', () => {
expect(typeof openaiOpts.promptCacheKey).toBe('string');
});
it('merges thinking config with generated prompt caching and caller providerOptions for OpenAI', async () => {
it('keeps generic reasoning separate from prompt caching and caller providerOptions', async () => {
generateText.mockResolvedValue(makeGenerateSuccess());
const runtime = new AgentRuntime({
name: 'test',
model: 'openai/gpt-5.1',
instructions: 'You are a test assistant.',
thinking: { reasoningEffort: 'high' },
reasoning: 'high',
promptCaching: { enabled: true },
});
@ -4941,7 +5002,9 @@ describe('promptCaching', () => {
const callArgs = generateText.mock.calls[0][0] as Record<string, unknown>;
const openaiOpts = (callArgs.providerOptions as Record<string, Record<string, unknown>>).openai;
expect(openaiOpts.reasoningEffort).toBe('high');
expect(callArgs.reasoning).toBe('high');
expect(openaiOpts).not.toHaveProperty('reasoningEffort');
expect(openaiOpts).not.toHaveProperty('reasoningSummary');
expect(openaiOpts.promptCacheRetention).toBe('24h');
expect(typeof openaiOpts.promptCacheKey).toBe('string');
expect(openaiOpts.strictJsonSchema).toBe(false);
@ -5297,10 +5360,10 @@ describe('AgentRuntime — observation log jobs', () => {
});
const callArgs = (generateText.mock.calls[0] as [unknown])[0] as {
system: { content: string };
instructions: { content: string };
tools: Record<string, unknown>;
};
const systemPrompt = callArgs.system?.content ?? '';
const systemPrompt = callArgs.instructions?.content ?? '';
expect(systemPrompt).not.toContain('<episodic_memory>');
expect(systemPrompt).not.toContain('Postgres');
expect(systemPrompt).not.toContain('SQLite');
@ -5420,17 +5483,17 @@ describe('AgentRuntime — observation log jobs', () => {
const generateTextMock = generateText as MockedFunction<
(input: {
system: { content: string } | Array<{ content: string }>;
instructions: { content: string } | Array<{ content: string }>;
messages: Array<{
role: string;
content: unknown;
}>;
}) => unknown
>;
const [{ system, messages }] = generateTextMock.mock.calls[0];
const systemText = Array.isArray(system)
? system.map((entry) => entry.content).join('')
: system.content;
const [{ instructions, messages }] = generateTextMock.mock.calls[0];
const systemText = Array.isArray(instructions)
? instructions.map((entry) => entry.content).join('')
: instructions.content;
expect(systemText).toContain('Resource one memory.');
expect(systemText).toContain('Resource two memory.');
expect(JSON.stringify(messages)).not.toContain('remember resource-one preference');
@ -5637,7 +5700,7 @@ describe('AgentRuntime — telemetry propagation', () => {
tracer: { startSpan: vi.fn() },
};
it('passes telemetry config into generateText as experimental_telemetry', async () => {
it('passes telemetry config into generateText as telemetry', async () => {
generateText.mockResolvedValue(makeGenerateSuccess());
const runtime = new AgentRuntime({
@ -5651,13 +5714,12 @@ describe('AgentRuntime — telemetry propagation', () => {
await runtime.generate('hello');
const callArgs = generateText.mock.calls[0][0] as Record<string, unknown>;
const expTelemetry = callArgs.experimental_telemetry as Record<string, unknown>;
expect(expTelemetry).toBeDefined();
expect(expTelemetry.isEnabled).toBe(true);
expect(expTelemetry.functionId).toBe('test-agent');
expect(expTelemetry.tracer).toBe(baseTelemetry.tracer);
expect(expTelemetry.recordInputs).toBe(true);
expect(expTelemetry.recordOutputs).toBe(false);
const telemetry = callArgs.telemetry as Record<string, unknown>;
expect(telemetry).toBeDefined();
expect(telemetry.isEnabled).toBe(true);
expect(telemetry.functionId).toBe('test-agent');
expect(telemetry.recordInputs).toBe(true);
expect(telemetry.recordOutputs).toBe(false);
});
it('uses updated telemetry config for later runs', async () => {
@ -5680,9 +5742,8 @@ describe('AgentRuntime — telemetry propagation', () => {
await runtime.generate('hello');
const callArgs = generateText.mock.calls[0][0] as Record<string, unknown>;
const expTelemetry = callArgs.experimental_telemetry as Record<string, unknown>;
expect(expTelemetry.functionId).toBe('updated-agent');
expect(expTelemetry.metadata).toEqual({ env: 'updated' });
const telemetry = callArgs.telemetry as Record<string, unknown>;
expect(telemetry.functionId).toBe('updated-agent');
});
it('wraps generate calls in a telemetry root span when the tracer supports active spans', async () => {
@ -5741,6 +5802,7 @@ describe('AgentRuntime — telemetry propagation', () => {
const telemetry: BuiltTelemetry = {
...baseTelemetry,
runtimeRootSpanEnabled: false,
integrations: [{ onStart: vi.fn() }],
tracer,
};
@ -5757,11 +5819,11 @@ describe('AgentRuntime — telemetry propagation', () => {
expect(tracer.startActiveSpan).not.toHaveBeenCalled();
const callArgs = generateText.mock.calls[0][0] as Record<string, unknown>;
expect(callArgs.experimental_telemetry).toEqual(
expect(callArgs.telemetry).toEqual(
expect.objectContaining({
isEnabled: true,
functionId: 'test-agent',
tracer,
integrations: telemetry.integrations,
}),
);
});
@ -5827,7 +5889,7 @@ describe('AgentRuntime — telemetry propagation', () => {
expect(attributes['gen_ai.prompt']).toEqual(expect.stringContaining('"input_schema"'));
});
it('passes telemetry config into streamText as experimental_telemetry', async () => {
it('passes telemetry config into streamText as telemetry', async () => {
streamText.mockReturnValue(makeStreamSuccess());
const runtime = new AgentRuntime({
@ -5842,11 +5904,10 @@ describe('AgentRuntime — telemetry propagation', () => {
await collectChunks(stream);
const callArgs = streamText.mock.calls[0][0] as Record<string, unknown>;
const expTelemetry = callArgs.experimental_telemetry as Record<string, unknown>;
expect(expTelemetry).toBeDefined();
expect(expTelemetry.isEnabled).toBe(true);
expect(expTelemetry.functionId).toBe('test-agent');
expect(expTelemetry.tracer).toBe(baseTelemetry.tracer);
const telemetry = callArgs.telemetry as Record<string, unknown>;
expect(telemetry).toBeDefined();
expect(telemetry.isEnabled).toBe(true);
expect(telemetry.functionId).toBe('test-agent');
});
it('enables smoothStream by default on streamText', async () => {
@ -5926,12 +5987,11 @@ describe('AgentRuntime — telemetry propagation', () => {
});
const callArgs = generateText.mock.calls[0][0] as Record<string, unknown>;
const expTelemetry = callArgs.experimental_telemetry as Record<string, unknown>;
expect(expTelemetry).toBeDefined();
expect(expTelemetry.isEnabled).toBe(true);
const telemetry = callArgs.telemetry as Record<string, unknown>;
expect(telemetry).toBeDefined();
expect(telemetry.isEnabled).toBe(true);
// Inherited telemetry uses the child agent's name as functionId
expect(expTelemetry.functionId).toBe('child-agent');
expect(expTelemetry.tracer).toBe(baseTelemetry.tracer);
expect(telemetry.functionId).toBe('child-agent');
});
it('passes resolved telemetry to tool handlers via parentTelemetry', async () => {
@ -6088,7 +6148,7 @@ describe('AgentRuntime — telemetry propagation', () => {
expect(capturedTelemetry!.tracer).toBe(baseTelemetry.tracer);
});
it('does not include experimental_telemetry when telemetry is disabled', async () => {
it('does not include telemetry when telemetry is disabled', async () => {
generateText.mockResolvedValue(makeGenerateSuccess());
const runtime = new AgentRuntime({
@ -6102,7 +6162,7 @@ describe('AgentRuntime — telemetry propagation', () => {
await runtime.generate('hello');
const callArgs = generateText.mock.calls[0][0] as Record<string, unknown>;
expect(callArgs.experimental_telemetry).toBeUndefined();
expect(callArgs.telemetry).toBeUndefined();
});
});
@ -6211,7 +6271,7 @@ describe('AgentRuntime.resume() with createCancellation() — auto-bypass', () =
streamText
.mockReturnValueOnce({
fullStream: makeChunkStream([{ type: 'text-delta', textDelta: 'thinking...' }]),
stream: makeChunkStream([{ type: 'text-delta', id: 'text-1', text: 'thinking...' }]),
finishReason: Promise.resolve('tool-calls'),
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5, totalTokens: 15 }),
response: Promise.resolve({
@ -6397,7 +6457,7 @@ describe('AgentRuntime — toModelOutput error resilience', () => {
streamText
.mockReturnValueOnce({
fullStream: makeChunkStream([{ type: 'text-delta', textDelta: 'thinking...' }]),
stream: makeChunkStream([{ type: 'text-delta', id: 'text-1', text: 'thinking...' }]),
finishReason: Promise.resolve('tool-calls'),
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5, totalTokens: 15 }),
response: Promise.resolve({
@ -6473,7 +6533,7 @@ describe('AgentRuntime — toModelOutput error resilience', () => {
// First stream: agent calls the tool and suspends
streamText.mockReturnValueOnce({
fullStream: makeChunkStream([{ type: 'text-delta', textDelta: 'thinking...' }]),
stream: makeChunkStream([{ type: 'text-delta', id: 'text-1', text: 'thinking...' }]),
finishReason: Promise.resolve('tool-calls'),
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5, totalTokens: 15 }),
response: Promise.resolve({
@ -6544,7 +6604,7 @@ describe('AgentRuntime — empty model responses', () => {
function makeEmptyStream(finishReason: string, extraChunks: Array<Record<string, unknown>> = []) {
return {
fullStream: makeChunkStream(extraChunks),
stream: makeChunkStream(extraChunks),
finishReason: Promise.resolve(finishReason),
usage: Promise.resolve({ inputTokens: 10, outputTokens: 0, totalTokens: 10 }),
response: Promise.resolve({ messages: [] }),
@ -6605,7 +6665,7 @@ describe('AgentRuntime — empty model responses', () => {
await collectChunks(readableStream);
const args = streamText.mock.calls[0][0] as Record<string, unknown>;
expect(args.includeRawChunks).toBe(true);
expect(args).toHaveProperty('include.rawChunks', true);
});
it('stream: an empty response finishing with "stop" is not treated as an error', async () => {
@ -6704,7 +6764,7 @@ describe('AgentRuntime — MCP connection failure warnings', () => {
await collectChunks(readableStream);
const callArgs = streamText.mock.calls.at(-1)![0] as Record<string, unknown>;
const system = callArgs.system;
const system = callArgs.instructions;
const systemText = Array.isArray(system)
? system.map((e) => String((e as { content: string }).content)).join('')
: String((system as { content: string }).content);
@ -6723,7 +6783,7 @@ describe('AgentRuntime — MCP connection failure warnings', () => {
await collectChunks(readableStream);
const callArgs = streamText.mock.calls.at(-1)![0] as Record<string, unknown>;
const system = callArgs.system;
const system = callArgs.instructions;
const systemText = Array.isArray(system)
? system.map((e) => String((e as { content: string }).content)).join('')
: String((system as { content: string }).content);

View File

@ -14,6 +14,8 @@ import { z } from 'zod';
import type { CheckpointStore, SerializableAgentState } from '../../types';
import type { StreamChunk } from '../../types/sdk/agent';
import type { BuiltFileStore } from '../../types/sdk/file-store';
import type { AgentDbMessage } from '../../types/sdk/message';
import type { BuiltTool, InterruptibleToolContext } from '../../types/sdk/tool';
import { AgentRuntime } from '../loop/agent-runtime';
import { AgentEventBus } from '../state/event-bus';
@ -65,7 +67,7 @@ function* makeChunkStream(
function makeStreamSuccess(text = 'Hello') {
return {
fullStream: makeChunkStream([{ type: 'text-delta', textDelta: text }]),
stream: makeChunkStream([{ type: 'text-delta', id: 'text-1', text }]),
finishReason: Promise.resolve('stop'),
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5, totalTokens: 15 }),
response: Promise.resolve({
@ -77,7 +79,7 @@ function makeStreamSuccess(text = 'Hello') {
function makeStreamWithToolCall(toolCallId: string, args: Record<string, unknown>) {
return {
fullStream: makeChunkStream([{ type: 'text-delta', textDelta: 'working...' }]),
stream: makeChunkStream([{ type: 'text-delta', id: 'text-1', text: 'working...' }]),
finishReason: Promise.resolve('tool-calls'),
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5, totalTokens: 15 }),
response: Promise.resolve({
@ -251,7 +253,7 @@ describe('step checkpoints + crash resume (durable-log RFC)', () => {
const runtime2 = createRuntime(store, [lookupTool, approveTool]);
streamText.mockReset();
streamText.mockReturnValueOnce({
fullStream: makeChunkStream([{ type: 'text-delta', textDelta: 'asking...' }]),
stream: makeChunkStream([{ type: 'text-delta', id: 'text-1', text: 'asking...' }]),
finishReason: Promise.resolve('tool-calls'),
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5, totalTokens: 15 }),
response: Promise.resolve({
@ -330,6 +332,133 @@ describe('step checkpoints + crash resume (durable-log RFC)', () => {
);
});
describe('file-part rehydration on resume', () => {
const fileBytes = new Uint8Array([1, 2, 3]);
/** A user message whose file part is reference-only, as checkpoints persist it. */
function referenceOnlyUserMessage(): AgentDbMessage {
return {
id: 'm-user',
createdAt: new Date('2026-01-01T00:00:00Z'),
role: 'user',
content: [
{ type: 'text', text: 'look at this' },
{
type: 'file',
mediaType: 'image/png',
fileRef: { id: 'att-1', fileName: 'photo.png', sizeBytes: 3 },
},
],
};
}
function createFileStoreRuntime(store: CheckpointStore, fileStore: BuiltFileStore) {
return new AgentRuntime({
name: 'crash-test',
model: 'openai/gpt-4o-mini',
instructions: 'You are a test assistant.',
tools: [lookupTool, approveTool],
eventBus: new AgentEventBus(),
checkpointStorage: store,
fileStore,
});
}
/** The hydrated file part as the AI SDK receives it from the resumed model call. */
function findFilePart(callArgs: unknown) {
const { messages } = callArgs as {
messages: Array<{ role: string; content: unknown }>;
};
return messages
.flatMap((m) => (Array.isArray(m.content) ? (m.content as Array<{ type: string }>) : []))
.find((part) => part.type === 'file') as { type: string; data?: Uint8Array } | undefined;
}
it('crashResume() rehydrates reference-only file parts before the model call', async () => {
const store = new RecordingCheckpointStore();
store.map.set('run_files', {
status: 'running',
messageList: {
messages: [referenceOnlyUserMessage()],
historyIds: [],
inputIds: ['m-user'],
responseIds: [],
},
pendingToolCalls: {},
persistence: { threadId: 'thread-files', resourceId: 'user-1' },
});
const fileStore: BuiltFileStore = { load: vi.fn().mockResolvedValue(fileBytes) };
const runtime = createFileStoreRuntime(store, fileStore);
streamText.mockReset();
streamText.mockReturnValueOnce(makeStreamSuccess('resumed'));
const result = await runtime.crashResume({ runId: 'run_files' });
await collectChunks(result.stream);
expect(fileStore.load).toHaveBeenCalledWith(expect.objectContaining({ id: 'att-1' }), {
threadId: 'thread-files',
});
expect(findFilePart(streamText.mock.calls[0][0])?.data).toBe(fileBytes);
});
it('resume() rehydrates reference-only file parts before the model call', async () => {
const store = new RecordingCheckpointStore();
store.map.set('run_files', {
status: 'suspended',
messageList: {
messages: [
referenceOnlyUserMessage(),
{
id: 'm-assistant',
createdAt: new Date('2026-01-01T00:00:01Z'),
role: 'assistant',
content: [
{
type: 'tool-call',
toolCallId: 'tc-hitl',
toolName: 'approve',
input: { question: 'ok?' },
state: 'pending',
},
],
},
],
historyIds: [],
inputIds: ['m-user'],
responseIds: ['m-assistant'],
},
pendingToolCalls: {
'tc-hitl': {
suspended: true,
toolCallId: 'tc-hitl',
toolName: 'approve',
input: { question: 'ok?' },
suspendPayload: { question: 'approve?' },
resumeSchema: {},
runId: 'run_files',
},
},
persistence: { threadId: 'thread-files', resourceId: 'user-1' },
});
const fileStore: BuiltFileStore = { load: vi.fn().mockResolvedValue(fileBytes) };
const runtime = createFileStoreRuntime(store, fileStore);
streamText.mockReset();
streamText.mockReturnValueOnce(makeStreamSuccess('approved'));
const result = await runtime.resume(
'stream',
{ approved: true },
{ runId: 'run_files', toolCallId: 'tc-hitl' },
);
await collectChunks((result as { stream: ReadableStream<unknown> }).stream);
expect(fileStore.load).toHaveBeenCalledWith(expect.objectContaining({ id: 'att-1' }), {
threadId: 'thread-files',
});
expect(findFilePart(streamText.mock.calls[0][0])?.data).toBe(fileBytes);
});
});
it('surfaces an error when maxIterations is decreased on crashResume (parity with resume)', async () => {
const store = new RecordingCheckpointStore();
store.map.set('run_max', {

View File

@ -527,6 +527,94 @@ describe('createDelegateSubAgentTool', () => {
});
});
it('forwards only allowlisted child chunks, keeping args, results and nesting out', async () => {
const events: AgentEventData[] = [];
const tool = createDelegateSubAgentTool({
runSubAgent: async (_request, helpers) => {
helpers.emitChunk({ type: 'text-delta', id: 't-1', delta: 'hello' });
helpers.emitChunk({
type: 'tool-call',
toolCallId: 'child-tc-1',
toolName: 'web_search',
input: { query: 'x'.repeat(5_000) },
});
helpers.emitChunk({
type: 'tool-result',
toolCallId: 'child-tc-1',
toolName: 'web_search',
output: { body: 'y'.repeat(5_000) },
});
helpers.emitChunk({
type: 'subagent-started',
taskName: 'nested',
taskPath: '/root/research_api_0/nested_0',
startedAt: 1,
});
return await Promise.resolve({
status: 'completed',
taskPath: '/root/research_api_0',
answer: 'done',
});
},
});
await tool.handler?.(input, {
runId: 'parent-run-1',
toolCallId: 'tool-call-1',
emitEvent: (event) => events.push(event),
});
const forwarded = events.filter((event) => event.type === AgentEvent.SubAgentChunk);
expect(forwarded).toHaveLength(1);
expect(forwarded[0]).toMatchObject({
parentToolCallId: 'tool-call-1',
chunk: { type: 'text-delta', delta: 'hello' },
});
});
it('caps forwarded child text at the budget but keeps tool lifecycle flowing', async () => {
const events: AgentEventData[] = [];
const tool = createDelegateSubAgentTool({
runSubAgent: async (_request, helpers) => {
// Neither delta lands on the boundary, so the second must be trimmed
// rather than forwarded whole.
helpers.emitChunk({ type: 'text-delta', id: 't-1', delta: 'a'.repeat(15_000) });
helpers.emitChunk({ type: 'text-delta', id: 't-1', delta: 'b'.repeat(15_000) });
helpers.emitChunk({ type: 'reasoning-delta', id: 'r-1', delta: 'over budget' });
helpers.emitChunk({
type: 'tool-execution-end',
toolCallId: 'child-tc-1',
toolName: 'web_search',
isError: false,
endTime: 1,
});
return await Promise.resolve({
status: 'completed',
taskPath: '/root/research_api_0',
answer: 'done',
});
},
});
await tool.handler?.(input, {
runId: 'parent-run-1',
toolCallId: 'tool-call-1',
emitEvent: (event) => events.push(event),
});
const forwarded = events.filter((event) => event.type === AgentEvent.SubAgentChunk);
expect(forwarded.map((event) => event.chunk.type)).toEqual([
'text-delta',
'text-delta',
'tool-execution-end',
]);
const forwardedChars = forwarded.reduce(
(total, event) => total + ('delta' in event.chunk ? event.chunk.delta.length : 0),
0,
);
expect(forwardedChars).toBe(20_000);
});
it('defaults maxChildren to 10 when policy is omitted', () => {
const tool = createDelegateSubAgentTool({
runSubAgent: async () =>
@ -645,6 +733,47 @@ describe('createDelegateSubAgentTool', () => {
});
});
it('rethrows an abort instead of reporting the delegation as failed', async () => {
const events: AgentEventData[] = [];
const abortError = new Error('This operation was aborted');
abortError.name = 'AbortError';
const controller = new AbortController();
controller.abort();
const tool = createDelegateSubAgentTool({
runSubAgent: async () => await Promise.reject(abortError),
});
await expect(
tool.handler?.(input, {
runId: 'parent-run-1',
abortSignal: controller.signal,
emitEvent: (event) => events.push(event),
}),
).rejects.toBe(abortError);
expect(events[events.length - 1]).toMatchObject({
type: AgentEvent.SubAgentCompleted,
status: 'cancelled',
});
});
it('reports an abort-shaped child error as failed while the parent signal is live', async () => {
const abortError = new Error('This operation was aborted');
abortError.name = 'AbortError';
const tool = createDelegateSubAgentTool({
runSubAgent: async () => await Promise.reject(abortError),
});
await expect(
tool.handler?.(input, {
runId: 'parent-run-1',
abortSignal: new AbortController().signal,
}),
).resolves.toMatchObject({
status: 'failed',
error: 'This operation was aborted',
});
});
it('returns a failed output for invalid task names', async () => {
const runSubAgent = vi.fn();
const tool = createDelegateSubAgentTool({ runSubAgent });
@ -755,6 +884,24 @@ describe('generateResultToDelegateSubAgentOutput', () => {
});
});
it('maps a cancelled child run to cancelled, even though it also reports an error', () => {
const result: GenerateResult = {
runId: 'child-run-5',
messages: [],
finishReason: 'error',
error: new Error('Aborted'),
getState: () => ({
status: 'cancelled',
messageList: { messages: [], historyIds: [], inputIds: [], responseIds: [] },
pendingToolCalls: {},
}),
};
expect(generateResultToDelegateSubAgentOutput('/root/x_0', result)).toMatchObject({
status: 'cancelled',
});
});
it('returns a failed delegate output for delegated child suspension stopgap', async () => {
const { failedDelegatedChildSuspendOutput } = await import(
'../tools/delegate-sub-agent-tool.js'
@ -825,9 +972,11 @@ describe('generateResultToDelegateSubAgentOutput', () => {
suspendPayload: {},
},
],
getState: () => {
throw new Error('getState is not implemented');
},
getState: () => ({
status: 'failed',
messageList: { messages: [], historyIds: [], inputIds: [], responseIds: [] },
pendingToolCalls: {},
}),
};
expect(generateResultToDelegateSubAgentOutput('/root/x_0', result)).toMatchObject({

View File

@ -0,0 +1,200 @@
/**
* File-reference content parts: hydration, persistence stripping, and the
* text-metadata degrade for parts the model cannot receive as bytes.
*/
import { describe, it, expect, vi } from 'vitest';
import type { BuiltFileStore } from '../../types/sdk/file-store';
import type { AgentDbMessage, ContentFile, Message } from '../../types/sdk/message';
import { stripHydratedFileData } from '../../types/sdk/message';
import {
hydrateFileParts,
MAX_HYDRATED_FILE_BYTES,
MAX_HYDRATED_FILE_PARTS,
} from '../loop/hydrate-file-parts';
import { toAiMessages } from '../model/messages';
const bytes = new Uint8Array([1, 2, 3]);
function refFileBlock(overrides: Partial<ContentFile> = {}): ContentFile {
return {
type: 'file',
mediaType: 'image/png',
fileRef: { id: 'att-1', fileName: 'photo.png', sizeBytes: 1_258_291 },
...overrides,
};
}
function userMessage(content: Message['content']): AgentDbMessage {
return { id: 'm-1', createdAt: new Date(), role: 'user', content };
}
describe('hydrateFileParts', () => {
it('loads bytes into reference-only file blocks', async () => {
const block = refFileBlock();
const store: BuiltFileStore = { load: vi.fn().mockResolvedValue(bytes) };
await hydrateFileParts([userMessage([block])], store);
expect(store.load).toHaveBeenCalledWith(block.fileRef, undefined);
expect(block.data).toBe(bytes);
});
it('leaves blocks reference-only when the media type is unsupported', async () => {
const block = refFileBlock({ mediaType: 'audio/ogg' });
const store: BuiltFileStore = {
load: vi.fn().mockResolvedValue(bytes),
isMediaTypeSupported: (mediaType) => mediaType?.startsWith('image/') === true,
};
await hydrateFileParts([userMessage([block])], store);
expect(store.load).not.toHaveBeenCalled();
expect(block.data).toBeUndefined();
});
it('leaves blocks reference-only when the store cannot resolve or throws', async () => {
const missing = refFileBlock();
const failing = refFileBlock({ fileRef: { id: 'att-2' } });
const store: BuiltFileStore = {
load: vi.fn().mockResolvedValueOnce(null).mockRejectedValueOnce(new Error('storage down')),
};
await hydrateFileParts([userMessage([missing]), userMessage([failing])], store);
expect(missing.data).toBeUndefined();
expect(failing.data).toBeUndefined();
});
it('skips blocks that already have data and messages without content', async () => {
const hydrated = refFileBlock({ data: bytes });
const custom: AgentDbMessage = {
id: 'm-2',
createdAt: new Date(),
type: 'custom',
data: { dummy: 'x' },
};
const store: BuiltFileStore = { load: vi.fn() };
await hydrateFileParts([userMessage([hydrated]), custom], store);
expect(store.load).not.toHaveBeenCalled();
});
it('is a no-op without a file store', async () => {
const block = refFileBlock();
await hydrateFileParts([userMessage([block])], undefined);
expect(block.data).toBeUndefined();
});
it('passes the run scope through to the store', async () => {
const block = refFileBlock();
const store: BuiltFileStore = { load: vi.fn().mockResolvedValue(bytes) };
await hydrateFileParts([userMessage([block])], store, { threadId: 'thread-1' });
expect(store.load).toHaveBeenCalledWith(block.fileRef, { threadId: 'thread-1' });
});
it('hydrates only the newest parts up to the count cap', async () => {
const blocks = Array.from({ length: MAX_HYDRATED_FILE_PARTS + 2 }, (_, index) =>
refFileBlock({ fileRef: { id: `att-${index}`, sizeBytes: 1 } }),
);
const store: BuiltFileStore = { load: vi.fn().mockResolvedValue(bytes) };
await hydrateFileParts([userMessage(blocks)], store);
expect(store.load).toHaveBeenCalledTimes(MAX_HYDRATED_FILE_PARTS);
expect(blocks[0].data).toBeUndefined();
expect(blocks[1].data).toBeUndefined();
expect(blocks.at(-1)!.data).toBe(bytes);
});
it('spends the byte budget newest-first and skips parts that would exceed it', async () => {
const oldSmall = refFileBlock({ fileRef: { id: 'att-old', sizeBytes: 3 } });
const hugeDeclared = refFileBlock({
fileRef: { id: 'att-huge', sizeBytes: MAX_HYDRATED_FILE_BYTES + 1 },
});
const newest = refFileBlock({ fileRef: { id: 'att-new', sizeBytes: 3 } });
const store: BuiltFileStore = { load: vi.fn().mockResolvedValue(bytes) };
await hydrateFileParts([userMessage([oldSmall, hugeDeclared, newest])], store);
// The over-budget declaration is never loaded; the others hydrate.
expect(store.load).toHaveBeenCalledTimes(2);
expect(hugeDeclared.data).toBeUndefined();
expect(newest.data).toBe(bytes);
expect(oldSmall.data).toBe(bytes);
});
it('drops a loaded part whose actual size exceeds the remaining budget', async () => {
const block = refFileBlock({ fileRef: { id: 'att-1' } }); // no declared size
const oversized = new Uint8Array(MAX_HYDRATED_FILE_BYTES + 1);
const store: BuiltFileStore = { load: vi.fn().mockResolvedValue(oversized) };
await hydrateFileParts([userMessage([block])], store);
expect(block.data).toBeUndefined();
});
});
describe('stripHydratedFileData', () => {
it('removes data from file blocks that carry a fileRef, without mutating the original', () => {
const block = refFileBlock({ data: bytes });
const message = userMessage([{ type: 'text', text: 'look' }, block]);
const stripped = stripHydratedFileData(message);
const strippedFile = (stripped as Message).content[1] as ContentFile;
expect(strippedFile.data).toBeUndefined();
expect(strippedFile.fileRef).toEqual(block.fileRef);
// Original block untouched — the live message list keeps its bytes.
expect(block.data).toBe(bytes);
});
it('keeps inline data on file blocks without a fileRef', () => {
const inline: ContentFile = { type: 'file', mediaType: 'image/png', data: bytes };
const message = userMessage([inline]);
const stripped = stripHydratedFileData(message);
expect(stripped).toBe(message);
expect((stripped as Message).content[0]).toBe(inline);
});
it('passes through custom messages', () => {
const custom: AgentDbMessage = {
id: 'm-3',
createdAt: new Date(),
type: 'custom',
data: { dummy: 'x' },
};
expect(stripHydratedFileData(custom)).toBe(custom);
});
});
describe('toAiMessages — file parts', () => {
it('emits hydrated file blocks as AI SDK file parts', () => {
const input: Message[] = [{ role: 'user', content: [refFileBlock({ data: bytes })] }];
const [message] = toAiMessages(input);
expect(message.content).toEqual([{ type: 'file', data: bytes, mediaType: 'image/png' }]);
});
it('degrades reference-only file blocks to a text metadata part', () => {
const input: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'listen to this' }, refFileBlock()] },
];
const [message] = toAiMessages(input);
const parts = message.content as Array<{ type: string; text?: string }>;
expect(parts).toHaveLength(2);
expect(parts[1].type).toBe('text');
expect(parts[1].text).toContain('photo.png');
expect(parts[1].text).toContain('image/png');
expect(parts[1].text).toContain('1.2 MB');
expect(parts[1].text).toContain('fileId: att-1');
});
});

View File

@ -58,7 +58,7 @@ vi.mock('@ai-sdk/openai', () => ({
}));
vi.mock('@ai-sdk/google', () => ({
createGoogleGenerativeAI: (opts?: ProviderOpts) => (model: string) => ({
createGoogle: (opts?: ProviderOpts) => (model: string) => ({
provider: 'google',
modelId: model,
apiKey: opts?.apiKey,
@ -68,12 +68,14 @@ vi.mock('@ai-sdk/google', () => ({
}));
vi.mock('@ai-sdk/xai', () => ({
createXai: (opts?: ProviderOpts) => (model: string) => ({
provider: 'xai',
modelId: model,
apiKey: opts?.apiKey,
fetch: opts?.fetch,
specificationVersion: 'v3',
createXai: (opts?: ProviderOpts) => ({
chat: (model: string) => ({
provider: 'xai',
modelId: model,
apiKey: opts?.apiKey,
fetch: opts?.fetch,
specificationVersion: 'v3',
}),
}),
}));

View File

@ -149,16 +149,15 @@ describe('observation-log observer defaults', () => {
await observe({ ...baseInput, telemetry: { ...telemetry, enabled: false } });
expect(mockGenerateText.mock.calls[0][0]).toMatchObject({
experimental_telemetry: {
telemetry: {
isEnabled: true,
functionId: 'my-agent.memory-observer',
metadata: { thread_id: 't1' },
recordInputs: true,
recordOutputs: false,
},
});
expect(mockGenerateText.mock.calls[1][0].experimental_telemetry).toBeUndefined();
expect(mockGenerateText.mock.calls[2][0].experimental_telemetry).toBeUndefined();
expect(mockGenerateText.mock.calls[1][0].telemetry).toBeUndefined();
expect(mockGenerateText.mock.calls[2][0].telemetry).toBeUndefined();
});
it('reports normalized, cache-aware usage through an async onUsage before the observer promise settles', async () => {

View File

@ -132,16 +132,15 @@ describe('observation-log reflector defaults', () => {
await reflect({ ...baseInput, telemetry: { ...telemetry, enabled: false } });
expect(mockGenerateText.mock.calls[0][0]).toMatchObject({
experimental_telemetry: {
telemetry: {
isEnabled: true,
functionId: 'my-agent.memory-reflector',
metadata: { thread_id: 't1' },
recordInputs: true,
recordOutputs: false,
},
});
expect(mockGenerateText.mock.calls[1][0].experimental_telemetry).toBeUndefined();
expect(mockGenerateText.mock.calls[2][0].experimental_telemetry).toBeUndefined();
expect(mockGenerateText.mock.calls[1][0].telemetry).toBeUndefined();
expect(mockGenerateText.mock.calls[2][0].telemetry).toBeUndefined();
});
it('reports usage with task="reflector" and the configured model through onUsage', async () => {

View File

@ -63,7 +63,7 @@ describe('thinkingToProviderOptions', () => {
it('openai: defaults reasoningEffort to medium', () => {
expect(getProviderQuirks('openai').thinkingToProviderOptions?.({})).toEqual({
openai: { reasoningEffort: 'medium' },
openai: { reasoningEffort: 'medium', reasoningSummary: null },
});
});

View File

@ -8,9 +8,9 @@ import {
} from '../memory/title-generation';
type GenerateTextCall = {
system?: string;
instructions?: string;
messages: Array<{ role: string; content: string }>;
experimental_telemetry?: Record<string, unknown>;
telemetry?: Record<string, unknown>;
};
type GenerateTextResult = { text: string; usage?: { totalTokens?: number } };
@ -117,8 +117,8 @@ describe('generateTitleFromMessage', () => {
mockGenerateText.mockResolvedValue({ text: 'Berlin rain alert' });
await generateTitleFromMessage(fakeModel, 'Build a daily Berlin rain alert workflow');
const call = mockGenerateText.mock.calls[0][0];
expect(call.system).toContain('markdown');
expect(call.system).toContain('sentence case');
expect(call.instructions).toContain('markdown');
expect(call.instructions).toContain('sentence case');
});
it('accepts custom instructions', async () => {
@ -127,7 +127,7 @@ describe('generateTitleFromMessage', () => {
instructions: 'Custom system prompt',
});
const call = mockGenerateText.mock.calls[0][0];
expect(call.system).toBe('Custom system prompt');
expect(call.instructions).toBe('Custom system prompt');
});
it('passes generic telemetry to the title LLM call', async () => {
@ -147,13 +147,11 @@ describe('generateTitleFromMessage', () => {
});
const call = mockGenerateText.mock.calls[0][0];
expect(call.experimental_telemetry).toEqual({
expect(call.telemetry).toEqual({
isEnabled: true,
functionId: 'instance-ai.thread-title',
metadata: { thread_id: 'thread-1' },
recordInputs: true,
recordOutputs: false,
tracer: undefined,
integrations: undefined,
});
});

View File

@ -1,10 +1,11 @@
import type { ProviderOptions } from '@ai-sdk/provider-utils';
import type { TelemetrySettings, ToolCallRepairFunction, ToolSet } from 'ai';
import type { TelemetryOptions, ToolCallRepairFunction, ToolSet } from 'ai';
import type { JSONSchema7 } from 'json-schema';
import type { z } from 'zod';
import { incrementMessageCount, incrementTokenCountFromUsage } from './execution-counter';
import { GenerateSink } from './generate-sink';
import { hydrateFileParts } from './hydrate-file-parts';
import type { RunOutputSink, RunServices } from './run-output-sink';
import { RuntimeContextBuilder, getModelIdString } from './runtime-context';
import {
@ -18,6 +19,7 @@ import { StreamSink } from './stream-sink';
import { isCancellation } from '../../sdk/cancellation';
import { computeCost, getModelCost, type ModelCost } from '../../sdk/catalog';
import type {
BuiltFileStore,
BuiltMemory,
BuiltProviderTool,
BuiltTelemetry,
@ -31,6 +33,7 @@ import type {
ObservationLogMemoryConfig,
PendingToolCall,
RunOptions,
ReasoningLevel,
SerializableAgentState,
StreamChunk,
StreamResult,
@ -90,12 +93,15 @@ export interface AgentRuntimeConfig {
};
providerTools?: BuiltProviderTool[];
memory?: BuiltMemory;
/** Host store resolving file-reference content parts to bytes before LLM calls. */
fileStore?: BuiltFileStore;
observationLog?: ObservationLogMemoryConfig;
observationalMemory?: ObservationalMemoryConfig;
episodicMemory?: EpisodicMemoryConfig;
structuredOutput?: z.ZodType | JSONSchema7;
checkpointStorage?: 'memory' | CheckpointStore;
thinking?: ThinkingConfig;
reasoning?: ReasoningLevel;
promptCaching?: PromptCachingConfig;
eventBus?: AgentEventBus;
/** Number of tool calls to execute concurrently. Default `1` (sequential). */
@ -346,6 +352,9 @@ export class AgentRuntime {
const list = AgentMessageList.deserialize(state.messageList);
this.context.hydrateDeferredToolsFromList(list);
await hydrateFileParts(list.messages(), this.config.fileStore, {
threadId: state.persistence?.threadId,
});
const toolForValidation = this.context
.getCurrentTools(state.persistence)
@ -500,6 +509,9 @@ export class AgentRuntime {
const list = AgentMessageList.deserialize(state.messageList);
this.context.hydrateDeferredToolsFromList(list);
await hydrateFileParts(list.messages(), this.config.fileStore, {
threadId: state.persistence?.threadId,
});
let abortScope: AgentAbortScope | undefined;
try {
@ -578,6 +590,11 @@ export class AgentRuntime {
// is authoritative for completed turns, so this must not abort the turn.
await this.memory.persistInputMessages(list, options);
// Hydrate after the eager persist so stored input stays reference-only.
await hydrateFileParts(list.messages(), this.config.fileStore, {
threadId: options?.persistence?.threadId,
});
return list;
}
@ -618,16 +635,18 @@ export class AgentRuntime {
toolMap: Map<string, BuiltTool>,
options?: ExecutionOptions,
): {
experimental_telemetry?: TelemetrySettings;
experimental_repairToolCall?: ToolCallRepairFunction<NoInfer<ToolSet>>;
experimental_onStepStart?: ExecutionOptions['onStepStart'];
onStepFinish?: ExecutionOptions['onStepFinish'];
telemetry?: TelemetryOptions;
repairToolCall?: ToolCallRepairFunction<NoInfer<ToolSet>>;
onStepStart?: ExecutionOptions['onStepStart'];
onStepEnd?: ExecutionOptions['onStepEnd'];
} {
return {
...this.telemetry.buildTelemetryOptions(options),
...(options?.onStepStart ? { experimental_onStepStart: options.onStepStart } : {}),
...(options?.onStepFinish ? { onStepFinish: options.onStepFinish } : {}),
experimental_repairToolCall: async (options) => {
...(options?.onStepStart ? { onStepStart: options.onStepStart } : {}),
...(options?.onStepEnd || options?.onStepFinish
? { onStepEnd: options.onStepEnd ?? options.onStepFinish }
: {}),
repairToolCall: async (options) => {
return await fixToolCall(
{
toolCall: options.toolCall,
@ -804,6 +823,7 @@ export class AgentRuntime {
abortSignal: abortScope.signal,
hasTools,
aiTools: cached.aiTools,
reasoning: staticLoopContext.reasoning,
providerOptions: staticLoopContext.providerOptions,
outputSpec: staticLoopContext.outputSpec,
aiSdkOptions: this.buildAiSdkOptions(toolMap, options),

View File

@ -31,9 +31,11 @@ export class GenerateSink implements RunOutputSink<GenerateResult> {
const { generateText } = loadAi();
const result = await generateText({
model: ctx.model,
system: ctx.system,
instructions: ctx.system,
messages: ctx.messages,
allowSystemInMessages: true,
abortSignal: ctx.abortSignal,
...(ctx.reasoning ? { reasoning: ctx.reasoning } : {}),
...(ctx.hasTools ? { tools: ctx.aiTools } : {}),
...(ctx.providerOptions ? { providerOptions: ctx.providerOptions } : {}),
...(ctx.outputSpec ? { output: ctx.outputSpec } : {}),

View File

@ -0,0 +1,58 @@
import type { BuiltFileStore } from '../../types/sdk/file-store';
import type { AgentDbMessage, ContentFile } from '../../types/sdk/message';
/** Only the newest file parts are hydrated; older ones stay reference-only. */
export const MAX_HYDRATED_FILE_PARTS = 20;
/** Cumulative cap on hydrated bytes per turn. */
export const MAX_HYDRATED_FILE_BYTES = 50 * 1024 * 1024;
/**
* Fill `data` on reference-only file parts by loading bytes from the injected
* file store. Mutates the blocks in place (persistence backends strip hydrated
* bytes on save via `stripHydratedFileData`).
*
* The whole thread history passes through here on every turn, so hydration is
* bounded: only the newest {@link MAX_HYDRATED_FILE_PARTS} parts are
* considered, loads run sequentially newest-first, and loading stops charging
* against a {@link MAX_HYDRATED_FILE_BYTES} budget once exhausted.
*
* A block stays reference-only — and is later rendered to the model as text
* metadata by `toAiContent` — when any of these hold: no store is configured,
* the store reports the media type as unsupported for the current model, the
* reference is unknown, the load fails, or the count/byte budgets exclude it.
*/
export async function hydrateFileParts(
messages: readonly AgentDbMessage[],
fileStore: BuiltFileStore | undefined,
scope?: { threadId?: string },
): Promise<void> {
if (!fileStore) return;
const blocks: ContentFile[] = [];
for (const message of messages) {
if (!('content' in message) || !Array.isArray(message.content)) continue;
for (const block of message.content) {
if (block.type !== 'file' || !block.fileRef || block.data !== undefined) continue;
if (fileStore.isMediaTypeSupported && !fileStore.isMediaTypeSupported(block.mediaType)) {
continue;
}
blocks.push(block);
}
}
let budget = MAX_HYDRATED_FILE_BYTES;
const newestFirst = blocks.slice(-MAX_HYDRATED_FILE_PARTS).reverse();
for (const block of newestFirst) {
if (budget <= 0) break;
const declaredSize = block.fileRef!.sizeBytes;
if (declaredSize !== undefined && declaredSize > budget) continue;
try {
const data = await fileStore.load(block.fileRef!, scope);
if (!data || data.byteLength > budget) continue;
block.data = data;
budget -= data.byteLength;
} catch {
// Leave the block reference-only; the model sees its text metadata.
}
}
}

View File

@ -1,9 +1,10 @@
import type {
LanguageModel,
LanguageModelCallOptions,
ModelMessage,
Output,
SystemModelMessage,
TelemetrySettings,
TelemetryOptions,
ToolCallRepairFunction,
ToolSet,
} from 'ai';
@ -65,11 +66,12 @@ export interface ModelCallContext {
abortSignal: AbortSignal;
hasTools: boolean;
aiTools: ToolSet;
reasoning?: LanguageModelCallOptions['reasoning'];
providerOptions?: Record<string, JSONObject>;
outputSpec?: ReturnType<typeof Output.object>;
aiSdkOptions: {
experimental_telemetry?: TelemetrySettings;
experimental_repairToolCall?: ToolCallRepairFunction<NoInfer<ToolSet>>;
telemetry?: TelemetryOptions;
repairToolCall?: ToolCallRepairFunction<NoInfer<ToolSet>>;
};
}

View File

@ -49,13 +49,14 @@ export function getModelIdString(model: ModelConfig): string {
export interface StaticLoopContext {
model: LanguageModel;
aiProviderTools: ReturnType<typeof toAiSdkProviderTools>;
reasoning: AgentRuntimeConfig['reasoning'];
providerOptions?: Record<string, JSONObject>;
outputSpec?: ReturnType<typeof Output.object>;
}
/**
* Builds the per-run and per-iteration dependencies the agentic loop hands to
* the LLM call: the model instance, provider/thinking options, structured
* the LLM call: the model instance, reasoning, provider options, structured
* output spec, and the effective tool surface (base + deferred + recall tools,
* mapped to AI SDK shapes). Keeps tool/model assembly out of the loop body.
*/
@ -97,6 +98,7 @@ export class RuntimeContextBuilder {
return {
model,
aiProviderTools,
reasoning: this.config.reasoning,
providerOptions: providerOptions as Record<string, JSONObject> | undefined,
outputSpec,
};

View File

@ -113,13 +113,15 @@ export class StreamSink implements RunOutputSink<void> {
const { streamText } = loadAi();
const result = streamText({
model: ctx.model,
system: ctx.system,
instructions: ctx.system,
messages: ctx.messages,
allowSystemInMessages: true,
abortSignal: ctx.abortSignal,
...(ctx.reasoning ? { reasoning: ctx.reasoning } : {}),
// Surface the provider's raw message_start/message_delta events so an
// aborted run can recover its usage — the SDK reports none on abort.
...(this.rawUsageReader !== undefined || this.rawErrorReader !== undefined
? { includeRawChunks: true }
? { include: { rawChunks: true } }
: {}),
...(ctx.hasTools ? { tools: ctx.aiTools } : {}),
...(ctx.providerOptions ? { providerOptions: ctx.providerOptions } : {}),
@ -131,7 +133,7 @@ export class StreamSink implements RunOutputSink<void> {
// Consume the stream. When the AbortSignal fires mid-stream the AI SDK
// cancels the underlying fetch and the async iterator throws; the error
// propagates to the StreamSession which closes the consumer stream.
for await (const chunk of result.fullStream) {
for await (const chunk of result.stream) {
// Track usage from raw provider events so an aborted turn (which never
// reaches the post-loop awaits) can still be billed via getAbortFinish.
if (chunk.type === 'raw') {

View File

@ -538,7 +538,7 @@ export function createEpisodicMemoryExtractFn(
const { generateText, Output } = await import('ai');
const response = await generateText({
model: createModel(model),
system: options.extractionPrompt ?? DEFAULT_EPISODIC_MEMORY_EXTRACTION_PROMPT,
instructions: options.extractionPrompt ?? DEFAULT_EPISODIC_MEMORY_EXTRACTION_PROMPT,
prompt: buildEpisodicMemoryExtractorPrompt(input),
output: Output.object({ schema: EpisodicMemoryExtractionSchema }),
});
@ -564,7 +564,7 @@ export function createEpisodicMemoryReflectFn(
const { generateText, Output } = await import('ai');
const response = await generateText({
model: createModel(model),
system: options.reflectionPrompt ?? DEFAULT_EPISODIC_MEMORY_REFLECTION_PROMPT,
instructions: options.reflectionPrompt ?? DEFAULT_EPISODIC_MEMORY_REFLECTION_PROMPT,
prompt: buildEpisodicMemoryReflectorPrompt(input),
output: Output.object({ schema: EpisodicMemoryReflectionSchema }),
});

View File

@ -13,7 +13,7 @@ import { getModelIdString } from '../loop/runtime-context';
import { loadAi } from '../model/lazy-ai';
import { createModel } from '../model/model-factory';
import { toTokenUsage } from '../streaming/stream';
import { buildExperimentalTelemetry } from '../telemetry/telemetry-options';
import { buildAiSdkTelemetry } from '../telemetry/telemetry-options';
// The observer's fixed prompt is a few thousand tokens, so firing per tiny delta
// is majority overhead. 8k keeps that overhead ratio acceptable while still firing
@ -328,9 +328,9 @@ export function createObservationLogObserveFn(
return async (input) => {
const { text, usage, providerMetadata } = await loadAi().generateText({
model: createModel(model),
system: options.observerPrompt ?? DEFAULT_OBSERVATION_LOG_OBSERVER_PROMPT,
instructions: options.observerPrompt ?? DEFAULT_OBSERVATION_LOG_OBSERVER_PROMPT,
prompt: buildObservationLogObserverPrompt(input),
...buildExperimentalTelemetry(input.telemetry, { functionSuffix: 'memory-observer' }),
...buildAiSdkTelemetry(input.telemetry, { functionSuffix: 'memory-observer' }),
});
incrementTokenCountFromUsage(input.executionCounter, usage);
@ -582,9 +582,9 @@ export function createObservationLogReflectFn(
return async (input) => {
const { text, usage, providerMetadata } = await loadAi().generateText({
model: createModel(model),
system: options.reflectorPrompt ?? DEFAULT_OBSERVATION_LOG_REFLECTOR_PROMPT,
instructions: options.reflectorPrompt ?? DEFAULT_OBSERVATION_LOG_REFLECTOR_PROMPT,
prompt: buildObservationLogReflectorPrompt(input),
...buildExperimentalTelemetry(input.telemetry, { functionSuffix: 'memory-reflector' }),
...buildAiSdkTelemetry(input.telemetry, { functionSuffix: 'memory-reflector' }),
});
incrementTokenCountFromUsage(input.executionCounter, usage);

View File

@ -7,6 +7,7 @@ import { createFilteredLogger } from '../logger';
import { incrementTokenCountFromUsage } from '../loop/execution-counter';
import { loadAi } from '../model/lazy-ai';
import { createModel, type FetchFn } from '../model/model-factory';
import { buildAiSdkTelemetry } from '../telemetry/telemetry-options';
const logger = createFilteredLogger();
@ -55,22 +56,6 @@ interface GenerateTitleFromMessageOptions {
executionCounter?: AgentExecutionCounter;
}
function buildTelemetryOptions(telemetry: BuiltTelemetry | undefined): Record<string, unknown> {
if (!telemetry?.enabled) return {};
return {
experimental_telemetry: {
isEnabled: true,
functionId: telemetry.functionId ?? 'title-generation',
metadata: telemetry.metadata,
recordInputs: telemetry.recordInputs,
recordOutputs: telemetry.recordOutputs,
tracer: telemetry.tracer,
integrations: telemetry.integrations.length > 0 ? telemetry.integrations : undefined,
},
};
}
/**
* Whether a user message has too little substance to title a conversation
* (e.g. "hey", "hello"). For these, the LLM tends to hallucinate an
@ -134,14 +119,14 @@ export async function generateTitleFromMessage(
const result = await loadAi().generateText({
model,
system: opts?.instructions ?? DEFAULT_TITLE_INSTRUCTIONS,
instructions: opts?.instructions ?? DEFAULT_TITLE_INSTRUCTIONS,
messages: [
{
role: 'user',
content: `Generate a title for the following first message of a conversation. Do not answer the message — only produce the title.\n\n<message>\n${trimmed}\n</message>`,
},
],
...buildTelemetryOptions(opts?.telemetry),
...buildAiSdkTelemetry(opts?.telemetry, { fallbackFunctionId: 'title-generation' }),
});
incrementTokenCountFromUsage(opts?.executionCounter, result.usage);
@ -181,7 +166,7 @@ ${trimmed}
const result = await loadAi().generateText({
model,
system: opts?.instructions ?? DEFAULT_TITLE_AND_EMOJI_INSTRUCTIONS,
instructions: opts?.instructions ?? DEFAULT_TITLE_AND_EMOJI_INSTRUCTIONS,
messages: [{ role: 'user', content: wrappedMessage }],
});
incrementTokenCountFromUsage(opts?.executionCounter, result.usage);

View File

@ -184,9 +184,12 @@ describe('mergeProviderOptions', () => {
it('preserves unrelated providers from different arguments', () => {
expect(
mergeProviderOptions({ anthropic: { thinking: { type: 'enabled' } } }, { openai: { a: 1 } }),
mergeProviderOptions(
{ anthropic: { cacheControl: { type: 'ephemeral' } } },
{ openai: { a: 1 } },
),
).toEqual({
anthropic: { thinking: { type: 'enabled' } },
anthropic: { cacheControl: { type: 'ephemeral' } },
openai: { a: 1 },
});
});

View File

@ -325,6 +325,11 @@ export class AgentMessageList {
return this.all.filter((m) => this.inputSet.has(m));
}
/** All messages currently in the list, as live references. */
messages(): readonly AgentDbMessage[] {
return this.all;
}
serialize(): SerializedMessageList {
const toIds = (set: Set<AgentDbMessage>) => Array.from(set).map((m) => m.id);
return {

View File

@ -6,9 +6,6 @@ import type {
TextPart,
ToolCallPart,
ToolResultPart,
ImagePart,
ToolApprovalRequest,
ToolApprovalResponse,
FinishReason as AiFinishReason,
} from 'ai';
@ -16,8 +13,10 @@ import { getProviderQuirks, PROVIDER_QUIRKS } from './provider-quirks';
import type { FinishReason } from '../../types';
import type {
AgentMessage,
ContentCustom,
ContentFile,
ContentReasoning,
ContentReasoningFile,
ContentText,
ContentToolCall,
Message,
@ -25,18 +24,9 @@ import type {
} from '../../types/sdk/message';
import type { JSONObject, JSONValue } from '../../types/utils/json';
/** Reasoning content part — mirrors @ai-sdk/provider-utils ReasoningPart (not re-exported by 'ai'). */
type ReasoningPart = { type: 'reasoning'; text: string };
type AiContentPart =
| TextPart
| FilePart
| ImagePart
| ReasoningPart
| ToolCallPart
| ToolResultPart
| ToolApprovalRequest
| ToolApprovalResponse;
// Used across all message roles; AssistantContent omits user images and tool approval responses.
type AiContentPart = Exclude<ModelMessage['content'], string>[number];
type AiAssistantContent = Exclude<Extract<ModelMessage, { role: 'assistant' }>['content'], string>;
// --- Type guards for MessageContent blocks ---
@ -48,10 +38,18 @@ function isReasoning(block: MessageContent): block is ContentReasoning {
return block.type === 'reasoning';
}
function isReasoningFile(block: MessageContent): block is ContentReasoningFile {
return block.type === 'reasoning-file';
}
function isFile(block: MessageContent): block is ContentFile {
return block.type === 'file';
}
function isCustom(block: MessageContent): block is ContentCustom {
return block.type === 'custom';
}
function isToolCall(block: MessageContent): block is ContentToolCall {
return block.type === 'tool-call';
}
@ -125,10 +123,19 @@ function hasReplayableReasoningProviderOptions(
type ContentToolResultOutput = Extract<ToolResultPart['output'], { type: 'content' }>;
function isContentToolResultOutput(value: JSONValue): value is ContentToolResultOutput {
function isContentToolResultOutput(value: unknown): value is ContentToolResultOutput {
return isRecord(value) && value.type === 'content' && Array.isArray(value.value);
}
function normalizeReasoningFileData(
data: Extract<AiContentPart, { type: 'reasoning-file' }>['data'],
): ContentReasoningFile['data'] {
if (data instanceof URL) return data.toString();
if (typeof data !== 'object' || data === null || !('type' in data)) return data;
if (data.type === 'data') return data.data;
return data.url.toString();
}
/**
* Providers replay reasoning from `providerOptions`, but the AI SDK exposes the
* replay data in `providerMetadata` (see `PROVIDER_QUIRKS[provider].reasoningReplayKeys`).
@ -157,17 +164,57 @@ function toReasoningProviderOptions(block: ContentReasoning): ProviderOptions |
return { ...block.providerOptions, ...additions };
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
/**
* Text stand-in for a file part the model cannot receive as bytes (media type
* unsupported, or hydration skipped/failed). The fileId is included so the
* agent can hand the file to a tool that can process it.
*/
export function fileMetadataText(block: ContentFile): string {
const name = block.fileRef?.fileName ?? 'file';
const details = [
block.mediaType,
block.fileRef?.sizeBytes !== undefined ? formatFileSize(block.fileRef.sizeBytes) : undefined,
]
.filter((part): part is string => part !== undefined)
.join(', ');
const id = block.fileRef?.id;
return `[File attachment "${name}"${details ? ` (${details})` : ''}${
id ? `, fileId: ${id}` : ''
} — not directly viewable by this model]`;
}
/** Convert a single n8n MessageContent block to an AI SDK content part. */
function toAiContent(block: MessageContent): AiContentPart | undefined {
let base: AiContentPart | undefined;
if (isText(block)) {
base = { type: 'text', text: block.text };
} else if (isFile(block)) {
base = {
type: 'file',
data: block.data,
mediaType: block.mediaType ?? 'application/octet-stream',
};
base =
block.data === undefined
? { type: 'text', text: fileMetadataText(block) }
: {
type: 'file',
data: block.data,
mediaType: block.mediaType ?? 'application/octet-stream',
};
} else if (isReasoningFile(block)) {
// Reasoning files are model-produced and carry no fileRef, so their data
// is never stripped for storage; a data-less block has nothing to send.
if (block.data !== undefined) {
base = {
type: 'reasoning-file',
data: block.data,
mediaType: block.mediaType,
};
}
} else if (isCustom(block)) {
base = { type: 'custom', kind: block.kind };
} else if (isToolCall(block)) {
base = {
type: 'tool-call',
@ -259,6 +306,17 @@ function fromAiContent(part: AiContentPart): MessageContent | undefined {
case 'reasoning':
base = { type: 'reasoning', text: part.text };
break;
case 'reasoning-file': {
base = {
type: 'reasoning-file',
data: normalizeReasoningFileData(part.data),
mediaType: part.mediaType,
};
break;
}
case 'custom':
base = { type: 'custom', kind: part.kind };
break;
case 'tool-call': {
const normalizedInput = normalizeToolInputForModel(part.input);
base = {
@ -364,9 +422,7 @@ function toAiMessageList(msg: Message): ModelMessage[] {
if (assistantParts.length > 0) {
const assistantBase: ModelMessage = {
role: 'assistant',
content: assistantParts as Array<
TextPart | ReasoningPart | ToolCallPart | ToolResultPart | FilePart
>,
content: assistantParts as AiAssistantContent,
};
const assistantMsg: ModelMessage = msg.providerOptions
? { ...assistantBase, providerOptions: msg.providerOptions }

View File

@ -115,15 +115,14 @@ const LANGUAGE_PROVIDERS: ProviderRegistry = {
},
google: {
build: (creds, model, fetch) => {
const { createGoogleGenerativeAI } =
require('@ai-sdk/google') as typeof import('@ai-sdk/google');
return createGoogleGenerativeAI({ ...creds, fetch })(model);
const { createGoogle } = require('@ai-sdk/google') as typeof import('@ai-sdk/google');
return createGoogle({ ...creds, fetch })(model);
},
},
xai: {
build: (creds, model, fetch) => {
const { createXai } = require('@ai-sdk/xai') as typeof import('@ai-sdk/xai');
return createXai({ ...creds, fetch })(model);
return createXai({ ...creds, fetch }).chat(model);
},
},
groq: {
@ -283,7 +282,7 @@ export function createModel(config: ModelConfig, fetch?: FetchFn): LanguageModel
*/
const EMBEDDING_PROVIDERS = {
openai: { pkg: '@ai-sdk/openai', factory: 'createOpenAI' },
google: { pkg: '@ai-sdk/google', factory: 'createGoogleGenerativeAI' },
google: { pkg: '@ai-sdk/google', factory: 'createGoogle' },
mistral: { pkg: '@ai-sdk/mistral', factory: 'createMistral' },
cohere: { pkg: '@ai-sdk/cohere', factory: 'createCohere' },
amazon: { pkg: '@ai-sdk/amazon-bedrock', factory: 'createAmazonBedrock' },

View File

@ -68,7 +68,12 @@ export const PROVIDER_QUIRKS: Partial<Record<ProviderId, ProviderQuirks>> = {
relaxStrictJsonSchemaForRawOutput: true,
thinkingToProviderOptions: (thinking) => {
const cfg = thinking as OpenAIThinkingConfig;
return { openai: { reasoningEffort: cfg.reasoningEffort ?? 'medium' } };
return {
openai: {
reasoningEffort: cfg.reasoningEffort ?? 'medium',
reasoningSummary: null,
},
};
},
},
groq: {

View File

@ -78,11 +78,17 @@ export function startStreamSession(deps: StreamSessionDeps): ReadableStream<Stre
const { type: _type, ...payload } = data;
void guard.write({ type: 'subagent-completed', ...payload });
};
const onSubAgentChunk = (data: AgentEventData): void => {
if (data.type !== AgentEvent.SubAgentChunk) return;
const { type: _type, ...payload } = data;
void guard.write({ type: 'subagent-chunk', ...payload });
};
deps.eventBus.on(AgentEvent.ToolExecutionStart, onToolExecutionStart);
deps.eventBus.on(AgentEvent.ToolExecutionEnd, onToolExecutionEnd);
deps.eventBus.on(AgentEvent.SubAgentStarted, onSubAgentStarted);
deps.eventBus.on(AgentEvent.SubAgentCompleted, onSubAgentCompleted);
deps.eventBus.on(AgentEvent.SubAgentChunk, onSubAgentChunk);
deps
.withRootSpan('stream', deps.options, deps.runId, async () => await deps.runLoop(guard))
@ -113,6 +119,7 @@ export function startStreamSession(deps: StreamSessionDeps): ReadableStream<Stre
deps.eventBus.off(AgentEvent.ToolExecutionEnd, onToolExecutionEnd);
deps.eventBus.off(AgentEvent.SubAgentStarted, onSubAgentStarted);
deps.eventBus.off(AgentEvent.SubAgentCompleted, onSubAgentCompleted);
deps.eventBus.off(AgentEvent.SubAgentChunk, onSubAgentChunk);
void guard.close();
deps.abortScope.dispose();
});

View File

@ -69,7 +69,7 @@ export function toTokenUsage(
}
/**
* Convert a single AI SDK v6 fullStream chunk to an n8n StreamChunk
* Convert a single AI SDK stream chunk to an n8n StreamChunk
*/
export function convertChunk(c: TextStreamPart<ToolSet>): StreamChunk | undefined {
switch (c.type) {
@ -125,7 +125,7 @@ export function convertChunk(c: TextStreamPart<ToolSet>): StreamChunk | undefine
}
case 'tool-result':
// The fullStream emits the raw tool output here, not the
// The stream emits the raw tool output here, not the
// `{ type, value }` ToolResultOutput wrapper used on the message
// side — so pass it through verbatim. Only provider-executed tools
// (e.g. native web search) reach this branch; local tool results are

View File

@ -1,5 +1,7 @@
import { Telemetry } from '../../../sdk/telemetry';
import type { BuiltTelemetry } from '../../../types/telemetry';
import { deriveSubAgentTelemetry } from '../sub-agent-telemetry';
import { buildAiSdkTelemetry } from '../telemetry-options';
function builtTelemetry(overrides: Partial<BuiltTelemetry> = {}): BuiltTelemetry {
return {
@ -42,4 +44,45 @@ describe('deriveSubAgentTelemetry()', () => {
expect(derived?.metadata).toEqual({ source: 'sub-agent' });
});
it('uses derived metadata for AI SDK OpenTelemetry spans', async () => {
const tracer = {
startSpan: vi.fn(() => ({ end: vi.fn() })),
startActiveSpan: vi.fn(),
};
const parentTelemetry = await new Telemetry()
.tracer(tracer)
.metadata({ source: 'workflow' })
.build();
const derived = deriveSubAgentTelemetry(parentTelemetry);
const options = buildAiSdkTelemetry(derived).telemetry;
const integrations = Array.isArray(options?.integrations)
? options.integrations
: options?.integrations
? [options.integrations]
: [];
integrations[0]?.onStart?.({
operationId: 'ai.generateText',
callId: 'call-1',
provider: 'openai.responses',
modelId: 'gpt-5',
instructions: 'test',
messages: [],
maxRetries: 2,
functionId: 'sub-agent',
recordInputs: true,
recordOutputs: true,
} as never);
expect(tracer.startSpan).toHaveBeenCalledWith(
'ai.generateText',
expect.objectContaining({
attributes: expect.objectContaining({
'ai.telemetry.metadata.source': 'sub-agent',
}),
}),
undefined,
);
});
});

View File

@ -1,7 +1,7 @@
import type { TelemetrySettings } from 'ai';
import type { TelemetryOptions } from 'ai';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { buildExperimentalTelemetry } from './telemetry-options';
import { buildAiSdkTelemetry } from './telemetry-options';
import { Telemetry } from '../../sdk/telemetry';
import type { AttributeValue, BuiltProviderTool, BuiltTelemetry, BuiltTool } from '../../types';
import type { ExecutionOptions } from '../../types/sdk/agent';
@ -126,7 +126,7 @@ function buildGenAiRootAttributes(
/**
* Owns all telemetry concerns for a single agent runtime: resolving the
* effective telemetry config, mapping it to the AI SDK's
* `experimental_telemetry` shape, building LangSmith/AI-SDK span attributes,
* `telemetry` shape, building LangSmith/AI-SDK span attributes,
* and wrapping the generate/stream loops and tool calls in active spans.
*
* Keeps provider-specific attribute formatting out of the core loop. Holds a
@ -156,11 +156,11 @@ export class RuntimeTelemetry {
await Telemetry.forceFlush(this.resolve(options));
}
/** Map resolved telemetry to AI SDK's experimental_telemetry shape. */
/** Map resolved telemetry to the AI SDK's telemetry shape. */
buildTelemetryOptions(options?: ExecutionOptions): {
experimental_telemetry?: TelemetrySettings;
telemetry?: TelemetryOptions;
} {
return buildExperimentalTelemetry(this.resolve(options), {
return buildAiSdkTelemetry(this.resolve(options), {
fallbackFunctionId: this.config.name,
});
}

View File

@ -1,34 +1,33 @@
import type { TelemetrySettings } from 'ai';
import type { TelemetryOptions } from 'ai';
import type { BuiltTelemetry } from '../../types/telemetry';
/**
* Map a resolved BuiltTelemetry to the AI SDK's `experimental_telemetry`
* Map a resolved BuiltTelemetry to the AI SDK's `telemetry`
* option. Single source of truth for all runtime LLM calls (loop, memory
* tasks). `functionSuffix` namespaces auxiliary calls (e.g. 'memory-observer')
* under the parent functionId.
*/
export function buildExperimentalTelemetry(
export function buildAiSdkTelemetry(
telemetry: BuiltTelemetry | undefined,
options: { fallbackFunctionId?: string; functionSuffix?: string } = {},
): { experimental_telemetry?: TelemetrySettings } {
): { telemetry?: TelemetryOptions } {
if (!telemetry?.enabled) return {};
const baseFunctionId = telemetry.functionId ?? options.fallbackFunctionId ?? 'agent';
const functionId = options.functionSuffix
? `${baseFunctionId}.${options.functionSuffix}`
: baseFunctionId;
const integrations =
telemetry.resolveIntegrations?.(telemetry.metadata) ?? telemetry.integrations;
return {
experimental_telemetry: {
telemetry: {
isEnabled: true,
functionId,
metadata: telemetry.metadata,
recordInputs: telemetry.recordInputs,
recordOutputs: telemetry.recordOutputs,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any
tracer: telemetry.tracer as any,
integrations: telemetry.integrations.length > 0 ? telemetry.integrations : undefined,
integrations: integrations.length > 0 ? integrations : undefined,
},
};
}

View File

@ -7,14 +7,17 @@ import {
type SubAgentTaskPath,
type SubAgentTaskPathPolicy,
} from './sub-agent-task-path';
import { isAbortError } from '../../sdk/abort';
import { filterLlmMessages } from '../../sdk/message';
import { Tool } from '../../sdk/tool';
import { AgentEvent } from '../../types/runtime/event';
import type { ForwardedChildChunk } from '../../types/runtime/event';
import type {
AgentExecutionCounter,
FinishReason,
GenerateResult,
ModelConfig,
StreamChunk,
TokenUsage,
} from '../../types/sdk/agent';
import type { AgentMessage } from '../../types/sdk/message';
@ -62,7 +65,7 @@ const delegateSubAgentInputSchema = z.object({
// returned object (not this schema) is what is actually sent back to the model,
// so this is kept in sync with DelegateSubAgentToolOutput by hand.
const delegateSubAgentOutputSchema = z.object({
status: z.enum(['completed', 'failed', 'suspended']),
status: z.enum(['completed', 'failed', 'suspended', 'cancelled']),
taskPath: z.string().optional(),
runId: z.string().optional(),
threadId: z.string().optional(),
@ -145,7 +148,7 @@ export interface DelegateSubAgentRequest extends DelegateSubAgentInput {
/** The result a delegation returns to the parent model and to lifecycle events. */
export interface DelegateSubAgentToolOutput {
status: 'completed' | 'failed' | 'suspended';
status: 'completed' | 'failed' | 'suspended' | 'cancelled';
/** Echoed back so consumers can correlate the result with the delegation. */
taskPath?: SubAgentTaskPath;
/** The child run's id, when the executor produced one. */
@ -177,6 +180,57 @@ export interface DelegateSubAgentToolOutput {
export interface DelegateSubAgentRunnerHelpers {
/** Run a one-off inline child using the parent agent's inherited local/deferred tool set. */
runInlineSubAgent: (request: DelegateSubAgentRequest) => Promise<DelegateSubAgentToolOutput>;
/**
* Forward a child stream chunk into the parent run's event bus (allowlisted
* types only, with a per-delegation character budget). No-op when the
* parent tool call id is missing.
*/
emitChunk: (chunk: StreamChunk) => void;
}
/** Cap on forwarded text+reasoning characters per delegation. */
const SUB_AGENT_FORWARD_CHAR_BUDGET = 20_000;
const FORWARDED_CHILD_CHUNK_TYPES = new Set<ForwardedChildChunk['type']>([
'text-delta',
'reasoning-start',
'reasoning-delta',
'reasoning-end',
'tool-input-start',
'tool-execution-start',
'tool-execution-end',
]);
function isForwardedChildChunk(chunk: StreamChunk): chunk is ForwardedChildChunk {
return FORWARDED_CHILD_CHUNK_TYPES.has(chunk.type as ForwardedChildChunk['type']);
}
function createEmitChunkHelper(
ctx: ToolContext,
request: DelegateSubAgentRequest,
): (chunk: StreamChunk) => void {
let forwardedChars = 0;
return (chunk) => {
if (request.parentToolCallId === undefined) return;
if (!isForwardedChildChunk(chunk)) return;
let forwarded: ForwardedChildChunk = chunk;
if (chunk.type === 'text-delta' || chunk.type === 'reasoning-delta') {
// Trim rather than pass through whole: a single provider delta can be
// arbitrarily large, and forwarding it intact would blow the budget.
const remaining = SUB_AGENT_FORWARD_CHAR_BUDGET - forwardedChars;
if (remaining <= 0) return;
const delta = chunk.delta.slice(0, remaining);
forwardedChars += delta.length;
forwarded = { ...chunk, delta };
}
ctx.emitEvent?.({
type: AgentEvent.SubAgentChunk,
...subAgentLifecycleBase(request),
chunk: forwarded,
});
};
}
export type InlineSubAgentProviderToolsResolver = (
@ -431,9 +485,10 @@ function formatDelegationPolicyInstructions(
* Tool handler: assign the child's task path,
* assemble the {@link DelegateSubAgentRequest} from the model input plus the
* parent tool context, then run the child via the host `runSubAgent` callback
* while emitting started/progress/completed lifecycle events. Any error is
* while emitting started/progress/completed lifecycle events. A failure is
* converted into a `status: 'failed'` output (never thrown) so one failed
* delegation can't abort the parent's run.
* delegation can't abort the parent's run; an abort is rethrown, because a
* cancelled run must end the parent too.
*/
async function handleDelegateSubAgent(
input: DelegateSubAgentInput,
@ -485,26 +540,26 @@ async function handleDelegateSubAgent(
`${toolName} host runner does not support inline delegation without helpers.runInlineSubAgent from an Agent build.`,
);
},
emitChunk: createEmitChunkHelper(ctx, request),
});
emitSubAgentCompleted(ctx, request, output, startedAt);
return output;
} catch (error) {
if (request !== undefined && startedAt !== undefined) {
const failedOutput: DelegateSubAgentToolOutput = {
status: 'failed',
...(taskPath !== undefined ? { taskPath } : {}),
answer: '',
error: stringifyUnknown(error),
};
emitSubAgentCompleted(ctx, request, failedOutput, startedAt);
return failedOutput;
}
return {
status: 'failed',
// When the parent has a signal it is the authority: `isAbortError` also
// matches by message text, and an unrelated child error must not be
// mistaken for a cancellation and kill the parent run.
const aborted = ctx.abortSignal ? ctx.abortSignal.aborted : isAbortError(error);
const output: DelegateSubAgentToolOutput = {
status: aborted ? 'cancelled' : 'failed',
...(taskPath !== undefined ? { taskPath } : {}),
answer: '',
error: error instanceof Error ? error.message : String(error),
...(aborted ? {} : { error: stringifyUnknown(error) }),
};
if (request !== undefined && startedAt !== undefined) {
emitSubAgentCompleted(ctx, request, output, startedAt);
}
if (aborted) throw error;
return output;
}
}
@ -621,7 +676,9 @@ function resolveDelegateSubAgentStatus(
result: GenerateResult,
): DelegateSubAgentToolOutput['status'] {
if (result.finishReason === 'error' || result.error !== undefined) {
return 'failed';
// An aborted child sets its state to `cancelled` but still reports an
// error, so the state decides which of the two this really was.
return result.getState().status === 'cancelled' ? 'cancelled' : 'failed';
}
if (result.pendingSuspend !== undefined && result.pendingSuspend.length > 0) {
return 'suspended';

View File

@ -54,6 +54,7 @@ export function toAiSdkProviderTools(tools?: BuiltProviderTool[]): Record<string
id: t.name,
args: t.args,
inputSchema: t.inputSchema ?? z.any(),
isProviderExecuted: true,
};
result[t.name] = providerTool;
}

View File

@ -118,6 +118,22 @@ describe('Agent isolated runtimes', () => {
});
});
it('maps provider-specific thinking from the Agent SDK into providerOptions', async () => {
generateText.mockResolvedValue(makeGenerateSuccess('ok'));
const agent = new Agent('agent')
.model('openai/gpt-4o-mini')
.instructions('test')
.thinking('openai', { reasoningEffort: 'high' });
await agent.generate('hello');
const callArgs = generateText.mock.calls[0][0] as { providerOptions: Record<string, unknown> };
expect(callArgs.providerOptions.openai).toEqual({
reasoningEffort: 'high',
reasoningSummary: null,
});
});
it('cleans up the active runtime when a wrapped stream is cancelled', async () => {
const agent = new Agent('agent').model('openai/gpt-4o-mini').instructions('test');
const internals = agent as unknown as AgentInternals;

View File

@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest';
import { Agent } from '../agent';
describe('Agent reasoning configuration', () => {
it('keeps provider-specific thinking available alongside generic reasoning', () => {
const agent = new Agent('thinker')
.model('anthropic', 'claude-sonnet-4-5')
.thinking('anthropic', { budgetTokens: 4096 })
.reasoning('high');
expect(agent.snapshot.thinking).toEqual({ budgetTokens: 4096 });
expect(agent.snapshot.reasoning).toBe('high');
});
});

View File

@ -71,11 +71,10 @@ describe('Agent telemetry', () => {
const firstCall = generateText.mock.calls[0][0] as Record<string, unknown>;
const secondCall = generateText.mock.calls[1][0] as Record<string, unknown>;
const firstTelemetry = firstCall.experimental_telemetry as Record<string, unknown>;
const secondTelemetry = secondCall.experimental_telemetry as Record<string, unknown>;
const firstTelemetry = firstCall.telemetry as Record<string, unknown>;
const secondTelemetry = secondCall.telemetry as Record<string, unknown>;
expect(firstTelemetry.functionId).toBe('initial-agent');
expect(secondTelemetry.functionId).toBe('updated-agent');
expect(secondTelemetry.metadata).toEqual({ functionId: 'updated-agent' });
});
});

View File

@ -11,6 +11,7 @@ import {
type DelegateSubAgentRunnerHelpers,
} from '../../runtime/tools/delegate-sub-agent-tool';
import type {
AgentDbMessage,
BuiltTool,
GenerateResult,
InterruptibleToolContext,
@ -29,6 +30,48 @@ const mockState = (): SerializableAgentState => ({
pendingToolCalls: {},
});
function chunksFromGenerateResult(result: GenerateResult): unknown[] {
const chunks: unknown[] = [];
for (const message of result.messages) {
if (!('content' in message) || !Array.isArray(message.content)) continue;
for (const part of message.content) {
if (part.type === 'text' && part.text) {
chunks.push({ type: 'text-delta', id: 't-1', delta: part.text });
}
}
}
for (const suspension of result.pendingSuspend ?? []) {
chunks.push({
type: 'tool-call-suspended',
runId: suspension.runId,
toolCallId: suspension.toolCallId,
toolName: suspension.toolName,
input: suspension.input,
suspendPayload: suspension.suspendPayload,
});
}
if (result.error !== undefined) {
chunks.push({ type: 'error', error: result.error });
}
chunks.push({
type: 'finish',
finishReason: result.finishReason ?? 'stop',
...(result.usage !== undefined ? { usage: result.usage } : {}),
...(result.model !== undefined ? { model: result.model } : {}),
...(result.structuredOutput !== undefined ? { structuredOutput: result.structuredOutput } : {}),
});
return chunks;
}
function readableFromChunks(chunks: unknown[]): ReadableStream<unknown> {
return new ReadableStream({
start(controller) {
for (const chunk of chunks) controller.enqueue(chunk);
controller.close();
},
});
}
vi.mock('../../runtime/loop/agent-runtime', async (importOriginal) => {
const actual = await importOriginal<typeof AgentRuntimeModule>();
return {
@ -38,23 +81,27 @@ vi.mock('../../runtime/loop/agent-runtime', async (importOriginal) => {
runtimeConfigs.push(config);
}
async generate(_input: unknown, options?: Record<string, unknown>) {
async stream(_input: unknown, options?: Record<string, unknown>) {
runtimeGenerateOptions.push(options);
if (inlineChildGenerateResult !== undefined) {
return await Promise.resolve(inlineChildGenerateResult);
}
const result =
inlineChildGenerateResult ??
({
runId: 'child-run',
finishReason: 'stop',
messages: [
{
role: 'assistant',
type: 'llm',
content: [{ type: 'text', text: 'inline answer' }],
},
],
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
getState: mockState,
} as GenerateResult);
return await Promise.resolve({
runId: 'child-run',
finishReason: 'stop',
messages: [
{
role: 'assistant',
type: 'llm',
content: [{ type: 'text', text: 'inline answer' }],
},
],
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
getState: mockState,
runId: result.runId,
stream: readableFromChunks(chunksFromGenerateResult(result)),
getState: result.getState ?? mockState,
});
}
@ -234,7 +281,7 @@ describe('delegate sub-agent routing', () => {
});
});
it('passes the parent execution counter to inline child generate options', async () => {
it('passes the parent execution counter to inline child stream options', async () => {
const executionCounter = {
incrementMessageCount: vi.fn(),
incrementToolCallCount: vi.fn(),
@ -323,7 +370,7 @@ describe('delegate sub-agent routing', () => {
taskPath: '/root/research_api_0',
childCount: 0,
},
{ runInlineSubAgent },
{ runInlineSubAgent, emitChunk: () => undefined },
),
).resolves.toMatchObject({
status: 'completed',
@ -376,4 +423,55 @@ describe('delegate sub-agent routing', () => {
error: DELEGATED_CHILD_SUSPEND_UNSUPPORTED_MESSAGE,
});
});
it('answers with the final assistant turn, not every text block the child streamed', async () => {
const responseMessages: AgentDbMessage[] = [
{
id: 'm-1',
createdAt: new Date(1),
role: 'assistant',
type: 'llm',
content: [{ type: 'text', text: 'Let me look that up.' }],
},
{
id: 'm-2',
createdAt: new Date(2),
role: 'assistant',
type: 'llm',
content: [{ type: 'text', text: 'The capital is Paris.' }],
},
];
inlineChildGenerateResult = {
runId: 'child-run-multi-turn',
finishReason: 'stop',
messages: responseMessages,
getState: () => ({
status: 'success',
messageList: {
messages: responseMessages,
historyIds: [],
inputIds: [],
responseIds: ['m-1', 'm-2'],
},
pendingToolCalls: {},
}),
};
const agent = new Agent('parent')
.model('openai', 'gpt-4o-mini')
.instructions('Delegate when needed.')
.tool(createDelegateSubAgentTool());
const runtimeConfig = await buildAgentConfig(agent);
const delegateTool = runtimeConfig.tools?.find(
(tool) => tool.name === DELEGATE_SUB_AGENT_TOOL_NAME,
);
await expect(
delegateTool?.handler?.(delegateInput, { runId: 'parent-run-1' }),
).resolves.toMatchObject({
status: 'completed',
answer: 'The capital is Paris.',
});
});
});

View File

@ -35,6 +35,51 @@ function makeGenerateSuccess(): Record<string, unknown> {
};
}
function chunksFromGenerateResult(result: Record<string, unknown>): unknown[] {
const chunks: unknown[] = [];
const messages = result.messages as
| Array<{ content?: Array<{ type: string; text?: string }> }>
| undefined;
for (const message of messages ?? []) {
for (const part of message.content ?? []) {
if (part.type === 'text' && part.text) {
chunks.push({ type: 'text-delta', id: 't-1', delta: part.text });
}
}
}
for (const suspension of (result.pendingSuspend as Array<Record<string, unknown>> | undefined) ??
[]) {
chunks.push({
type: 'tool-call-suspended',
runId: suspension.runId,
toolCallId: suspension.toolCallId,
toolName: suspension.toolName,
input: suspension.input,
suspendPayload: suspension.suspendPayload,
});
}
if (result.error !== undefined) {
chunks.push({ type: 'error', error: result.error });
}
chunks.push({
type: 'finish',
finishReason: result.finishReason ?? 'stop',
...(result.usage !== undefined ? { usage: result.usage } : {}),
...(result.model !== undefined ? { model: result.model } : {}),
...(result.structuredOutput !== undefined ? { structuredOutput: result.structuredOutput } : {}),
});
return chunks;
}
function readableFromChunks(chunks: unknown[]): ReadableStream<unknown> {
return new ReadableStream({
start(controller) {
for (const chunk of chunks) controller.enqueue(chunk);
controller.close();
},
});
}
vi.mock('../../runtime/loop/agent-runtime', async (importOriginal) => {
const actual = await importOriginal<typeof AgentRuntimeModule>();
return {
@ -49,6 +94,20 @@ vi.mock('../../runtime/loop/agent-runtime', async (importOriginal) => {
return await Promise.resolve(runtimeGenerateResults.shift() ?? makeGenerateSuccess());
}
async stream(_prompt: string, options?: Record<string, unknown>) {
runtimeGenerateOptions.push(options ?? {});
const result = runtimeGenerateResults.shift() ?? makeGenerateSuccess();
return await Promise.resolve({
runId: (result.runId as string | undefined) ?? 'child-run',
stream: readableFromChunks(chunksFromGenerateResult(result)),
getState: () => ({
status: 'success',
messageList: { messages: [], historyIds: [], inputIds: [], responseIds: [] },
pendingToolCalls: {},
}),
});
}
async dispose() {
return await Promise.resolve();
}
@ -304,6 +363,52 @@ describe('inline sub-agent tool filtering', () => {
expect(runtimeConfigs[0]?.thinking).toEqual(thinking);
});
it('inherits generic reasoning when difficulty selects a different provider', async () => {
const agent = new Agent('parent').model('openai', 'gpt-4o-mini').reasoning('high');
const runner = (agent as unknown as AgentWithInlineRunner).createInlineSubAgentRunner({
deferredTools: [],
modelConfig: 'openai/gpt-4o-mini',
tools: [makeTool('lookup')],
inlineSubAgentModelsByDifficulty: { high: 'anthropic/claude-sonnet-4-5' },
});
await runner({
subAgentId: INLINE_SUB_AGENT_ID,
taskName: 'research',
goal: 'Find the answer',
taskPath: '/root/research',
childCount: 0,
difficulty: 'high',
});
expect(runtimeConfigs).toHaveLength(1);
expect(runtimeConfigs[0]?.model).toBe('anthropic/claude-sonnet-4-5');
expect(runtimeConfigs[0]?.reasoning).toBe('high');
});
it('keeps parent reasoning when difficulty keeps the same provider', async () => {
const agent = new Agent('parent').model('openai', 'gpt-4o-mini').reasoning('low');
const runner = (agent as unknown as AgentWithInlineRunner).createInlineSubAgentRunner({
deferredTools: [],
modelConfig: 'openai/gpt-4o-mini',
tools: [makeTool('lookup')],
inlineSubAgentModelsByDifficulty: { high: 'openai/o3-mini' },
});
await runner({
subAgentId: INLINE_SUB_AGENT_ID,
taskName: 'research',
goal: 'Find the answer',
taskPath: '/root/research',
childCount: 0,
difficulty: 'high',
});
expect(runtimeConfigs).toHaveLength(1);
expect(runtimeConfigs[0]?.model).toBe('openai/o3-mini');
expect(runtimeConfigs[0]?.reasoning).toBe('low');
});
it('includes selected child model in failed inline child output when runtime result omits it', async () => {
runtimeGenerateResults.push({
runId: 'child-run',
@ -311,6 +416,11 @@ describe('inline sub-agent tool filtering', () => {
error: 'model failed',
messages: [],
usage: {},
getState: () => ({
status: 'failed',
messageList: { messages: [], historyIds: [], inputIds: [], responseIds: [] },
pendingToolCalls: {},
}),
});
const runner = createInlineRunner({
inlineSubAgentModelsByDifficulty: { high: 'anthropic/claude-sonnet-4-5' },

View File

@ -1,4 +1,4 @@
import type { TelemetryIntegration } from 'ai';
import type { Telemetry as AiSdkTelemetry } from 'ai';
import { Telemetry } from '../telemetry';
@ -35,9 +35,47 @@ describe('Telemetry builder', () => {
});
it('accepts a pre-built tracer', async () => {
const fakeTracer = { startSpan: vi.fn() };
const fakeTracer = { startSpan: vi.fn(), startActiveSpan: vi.fn() };
const built = await new Telemetry().tracer(fakeTracer).build();
expect(built.tracer).toBe(fakeTracer);
expect(built.integrations).toHaveLength(1);
expect(built.integrations[0]).toEqual(
expect.objectContaining({
onStart: expect.any(Function),
executeLanguageModelCall: expect.any(Function),
}),
);
});
it('preserves metadata on AI SDK 7 OpenTelemetry spans', async () => {
const fakeTracer = {
startSpan: vi.fn(() => ({ end: vi.fn() })),
startActiveSpan: vi.fn(),
};
const built = await new Telemetry().metadata({ team: 'platform' }).tracer(fakeTracer).build();
built.integrations[0].onStart?.({
operationId: 'ai.generateText',
callId: 'call-1',
provider: 'openai.responses',
modelId: 'gpt-5',
instructions: 'test',
messages: [],
maxRetries: 2,
functionId: 'agent',
recordInputs: true,
recordOutputs: true,
} as never);
expect(fakeTracer.startSpan).toHaveBeenCalledWith(
'ai.generateText',
expect.objectContaining({
attributes: expect.objectContaining({
'ai.telemetry.metadata.team': 'platform',
}),
}),
undefined,
);
});
it('throws when both .tracer() and .otlpEndpoint() are set', async () => {
@ -47,21 +85,161 @@ describe('Telemetry builder', () => {
});
it('collects multiple integrations', async () => {
const int1: TelemetryIntegration = { onStart: vi.fn() };
const int2: TelemetryIntegration = { onFinish: vi.fn() };
const int1: AiSdkTelemetry = { onStart: vi.fn() };
const int2: AiSdkTelemetry = { onEnd: vi.fn() };
const built = await new Telemetry().integration(int1).integration(int2).build();
expect(built.integrations).toHaveLength(2);
});
});
describe('Telemetry — redaction wrapping', () => {
it('redacts hooks on frozen integrations', async () => {
const onStart = vi.fn();
const integration: AiSdkTelemetry = Object.freeze({ onStart });
const built = await new Telemetry()
.redact((data) => {
const filtered = { ...data };
delete filtered.secret;
return filtered;
})
.integration(integration)
.build();
built.integrations[0].onStart?.({ secret: 'hidden', safe: 'ok' } as never);
expect(onStart).toHaveBeenCalledWith({ safe: 'ok' });
});
it('accepts immutable redactor results', async () => {
const onStart = vi.fn();
const built = await new Telemetry()
.redact((data) => {
const filtered = { ...data };
delete filtered.secret;
return Object.freeze(filtered);
})
.integration({ onStart })
.build();
built.integrations[0].onStart?.({ secret: 'hidden', safe: 'ok' } as never);
expect(onStart).toHaveBeenCalledWith({ safe: 'ok' });
});
it('redacts future event hooks without requiring an explicit wrapper', async () => {
type FutureTelemetry = AiSdkTelemetry & {
onFutureEvent: (event: Record<string, unknown>) => void;
readonly receivedEvents: Array<Record<string, unknown>>;
};
class FutureIntegration {
readonly #receivedEvents: Array<Record<string, unknown>> = [];
onStart() {}
onFutureEvent(event: Record<string, unknown>) {
this.#receivedEvents.push(event);
}
get receivedEvents() {
return this.#receivedEvents;
}
}
const integration: FutureTelemetry = new FutureIntegration();
const built = await new Telemetry()
.redact((data) => {
const filtered = { ...data };
delete filtered.secret;
return filtered;
})
.integration(integration)
.build();
const wrapped = built.integrations[0] as FutureTelemetry;
wrapped.onFutureEvent({ secret: 'hidden', safe: 'ok' });
expect(integration.receivedEvents).toEqual([{ safe: 'ok' }]);
});
it('preserves executor control fields while redacting their event data', async () => {
let modelOptions: Record<string, unknown> | undefined;
let toolOptions: Record<string, unknown> | undefined;
const integration: AiSdkTelemetry = {
executeLanguageModelCall: (options) => {
modelOptions = options;
return options.execute();
},
executeTool: (options) => {
toolOptions = options;
return options.execute();
},
};
const built = await new Telemetry()
.redact((data) => {
const filtered = { ...data };
delete filtered.callId;
delete filtered.toolCallId;
delete filtered.execute;
delete filtered.secret;
return Object.freeze(filtered);
})
.integration(integration)
.build();
const modelExecute = vi.fn().mockResolvedValue('model-result');
await expect(
built.integrations[0].executeLanguageModelCall?.({
callId: 'model-call',
execute: modelExecute,
secret: 'hidden',
} as never),
).resolves.toBe('model-result');
expect(modelOptions).toEqual({ callId: 'model-call', execute: modelExecute });
const toolExecute = vi.fn().mockResolvedValue('tool-result');
await expect(
built.integrations[0].executeTool?.({
callId: 'model-call',
toolCallId: 'tool-call',
execute: toolExecute,
secret: 'hidden',
} as never),
).resolves.toBe('tool-result');
expect(toolOptions).toEqual({
callId: 'model-call',
toolCallId: 'tool-call',
execute: toolExecute,
});
});
it('preserves the receiver for stateful integration methods', async () => {
class StatefulIntegration {
readonly receivedEvents: unknown[] = [];
onStart(event: Parameters<NonNullable<AiSdkTelemetry['onStart']>>[0]) {
this.receivedEvents.push(event);
}
}
const integration = new StatefulIntegration();
const built = await new Telemetry()
.redact((data) => data)
.integration(integration)
.build();
built.integrations[0].onStart!({ operationId: 'ai.generateText' } as never);
expect(integration.receivedEvents).toHaveLength(1);
});
it('wraps integrations with redaction when .redact() is set', async () => {
const receivedEvents: unknown[] = [];
const integration: TelemetryIntegration = {
const integration: AiSdkTelemetry = {
onStart: (event) => {
receivedEvents.push(event);
},
onFinish: (event) => {
onEnd: (event) => {
receivedEvents.push(event);
},
};
@ -86,7 +264,7 @@ describe('Telemetry — redaction wrapping', () => {
});
it('does not wrap integrations when .redact() is not set', async () => {
const integration: TelemetryIntegration = { onStart: vi.fn() };
const integration: AiSdkTelemetry = { onStart: vi.fn() };
const built = await new Telemetry().integration(integration).build();
// The integration should be a copy (not the same reference due to spread) but functionally identical
expect(built.integrations[0].onStart).toBe(integration.onStart);
@ -94,7 +272,7 @@ describe('Telemetry — redaction wrapping', () => {
it('redacts top-level scalar fields via the redact callback', async () => {
const receivedEvents: unknown[] = [];
const integration: TelemetryIntegration = {
const integration: AiSdkTelemetry = {
onStart: (event) => {
receivedEvents.push(event);
},
@ -118,7 +296,7 @@ describe('Telemetry — redaction wrapping', () => {
it('redacts objects inside arrays', async () => {
const receivedEvents: unknown[] = [];
const integration: TelemetryIntegration = {
const integration: AiSdkTelemetry = {
onStart: (event) => {
receivedEvents.push(event);
},

View File

@ -13,6 +13,7 @@ import { AgentRuntime, type AgentRuntimeConfig } from '../runtime/loop/agent-run
import { ensureUniqueMcpToolNames } from '../runtime/mcp/mcp-tool-resolver';
import { RECALL_MEMORY_TOOL_NAME } from '../runtime/memory/episodic-memory';
import type { ScopedMemoryTaskEvent } from '../runtime/memory/scoped-memory-task-runner';
import { AgentMessageList } from '../runtime/model/message-list';
import type { FetchFn } from '../runtime/model/model-factory';
import { mergeProviderOptions } from '../runtime/model/prompt-cache';
import { AgentEventBus } from '../runtime/state/event-bus';
@ -50,6 +51,7 @@ import type {
AgentMiddleware,
BuiltAgent,
BuiltEval,
BuiltFileStore,
BuiltGuardrail,
BuiltMemory,
BuiltProviderTool,
@ -57,15 +59,18 @@ import type {
BuiltTelemetry,
CheckpointStore,
ExecutionOptions,
FinishReason,
GenerateResult,
MemoryConfig,
ModelConfig,
Provider,
PromptCachingConfig,
ReasoningLevel,
RunOptions,
StreamResult,
ThinkingConfig,
ThinkingConfigFor,
TokenUsage,
ResumeOptions,
McpConnectionFailedEvent,
} from '../types';
@ -118,8 +123,10 @@ export interface AgentSnapshot {
hasObservationalMemory: boolean;
/** True when episodic memory has been configured on the memory builder. */
hasEpisodicMemory: boolean;
/** The thinking config if set, otherwise null. */
/** The provider-specific thinking config if set, otherwise null. */
thinking: ThinkingConfig | null;
/** The provider-agnostic reasoning level if set, otherwise null. */
reasoning: ReasoningLevel | null;
/** The prompt caching config if set via `.promptCaching()`, otherwise null. */
promptCaching: PromptCachingConfig | null;
/** Tool-call concurrency limit if set, otherwise null. */
@ -165,6 +172,8 @@ export class Agent implements BuiltAgent, AgentBuilder {
private memoryConfig?: MemoryConfig;
private fileStoreValue?: BuiltFileStore;
private onMemoryTaskEvent?: (event: ScopedMemoryTaskEvent) => void;
// TODO: Guardrails are accepted by the builder API for forward
@ -181,6 +190,8 @@ export class Agent implements BuiltAgent, AgentBuilder {
private thinkingConfig?: ThinkingConfig;
private reasoningLevel?: ReasoningLevel;
private promptCachingConfig?: PromptCachingConfig;
private concurrencyValue?: number;
@ -359,6 +370,12 @@ export class Agent implements BuiltAgent, AgentBuilder {
return this;
}
/** Inject the host store that hydrates file-reference content parts before LLM calls. */
fileStore(store: BuiltFileStore): this {
this.fileStoreValue = store;
return this;
}
/** Add a middleware. */
middleware(m: AgentMiddleware): this {
this.middlewares.push(m);
@ -455,6 +472,21 @@ export class Agent implements BuiltAgent, AgentBuilder {
return this;
}
/**
* Enable provider-agnostic reasoning for the agent.
*
* @example
* ```typescript
* new Agent('thinker')
* .model('anthropic', 'claude-sonnet-4-5')
* .reasoning('high')
* ```
*/
reasoning(level: ReasoningLevel = 'medium'): this {
this.reasoningLevel = level;
return this;
}
/**
* Enable prompt caching with defaults tuned for agent workloads. Anthropic
* models get a `1h` instruction-level cache breakpoint; OpenAI models get
@ -669,6 +701,7 @@ export class Agent implements BuiltAgent, AgentBuilder {
hasObservationalMemory: this.memoryConfig?.observationalMemory !== undefined,
hasEpisodicMemory: this.memoryConfig?.episodicMemory !== undefined,
thinking: this.thinkingConfig ?? null,
reasoning: this.reasoningLevel ?? null,
promptCaching: this.promptCachingConfig ?? null,
toolCallConcurrency: this.concurrencyValue ?? null,
};
@ -1064,12 +1097,14 @@ export class Agent implements BuiltAgent, AgentBuilder {
instructionProviderOptions: this.instructionProviderOpts,
providerTools: this.providerTools.length > 0 ? this.providerTools : undefined,
memory: memoryConfig?.memory,
...(this.fileStoreValue !== undefined ? { fileStore: this.fileStoreValue } : {}),
observationLog: memoryConfig?.observationLog,
observationalMemory: memoryConfig?.observationalMemory,
episodicMemory: memoryConfig?.episodicMemory,
structuredOutput: this.outputSchema,
checkpointStorage: this.checkpointStore,
thinking: this.thinkingConfig,
reasoning: this.reasoningLevel,
promptCaching: this.promptCachingConfig,
toolCallConcurrency: this.concurrencyValue,
titleGeneration: memoryConfig?.titleGeneration,
@ -1105,13 +1140,17 @@ export class Agent implements BuiltAgent, AgentBuilder {
const hostRunner = delegateOptions.runSubAgent;
const completedTool = createDelegateSubAgentTool({
...delegateOptions,
runSubAgent: async (request, _helpersFromHandler) => {
const helpers = { runInlineSubAgent };
runSubAgent: async (request, helpersFromHandler) => {
const helpers = {
runInlineSubAgent: async (req: DelegateSubAgentRequest) =>
await runInlineSubAgent(req, helpersFromHandler.emitChunk),
emitChunk: helpersFromHandler.emitChunk,
};
if (hostRunner) {
return await hostRunner(request, helpers);
}
if (request.subAgentId === INLINE_SUB_AGENT_ID) {
return await runInlineSubAgent(request);
return await runInlineSubAgent(request, helpersFromHandler.emitChunk);
}
return {
status: 'failed',
@ -1139,8 +1178,11 @@ export class Agent implements BuiltAgent, AgentBuilder {
inlineSubAgentBlockedTools?: string[];
inlineSubAgentModelsByDifficulty?: Partial<Record<SubAgentTaskDifficulty, ModelConfig>>;
resolveInlineSubAgentProviderTools?: InlineSubAgentProviderToolsResolver;
}): (request: DelegateSubAgentRequest) => Promise<DelegateSubAgentToolOutput> {
return async (request) => {
}): (
request: DelegateSubAgentRequest,
onChunk?: (chunk: StreamChunk) => void,
) => Promise<DelegateSubAgentToolOutput> {
return async (request, onChunk) => {
const tools = filterInlineSubAgentTools(options.tools, options.inlineSubAgentBlockedTools);
const deferredTools = filterInlineSubAgentTools(
options.deferredTools,
@ -1174,6 +1216,7 @@ export class Agent implements BuiltAgent, AgentBuilder {
promptCaching: this.promptCachingConfig,
checkpointStorage: this.checkpointStore,
...(childThinkingConfig !== undefined ? { thinking: childThinkingConfig } : {}),
...(this.reasoningLevel !== undefined ? { reasoning: this.reasoningLevel } : {}),
...(telemetry !== undefined ? { telemetry } : {}),
...(options.toolCallConcurrency !== undefined
? { toolCallConcurrency: options.toolCallConcurrency }
@ -1181,7 +1224,7 @@ export class Agent implements BuiltAgent, AgentBuilder {
});
try {
const result = await childRuntime.generate(renderDelegateSubAgentPrompt(request), {
const resultStream = await childRuntime.stream(renderDelegateSubAgentPrompt(request), {
...(request.parentAbortSignal !== undefined
? { abortSignal: request.parentAbortSignal }
: {}),
@ -1190,14 +1233,75 @@ export class Agent implements BuiltAgent, AgentBuilder {
? { executionCounter: request.parentExecutionCounter }
: {}),
});
if (result.pendingSuspend !== undefined && result.pendingSuspend.length > 0) {
return failedDelegatedChildSuspendOutput(request.taskPath, result.model ?? childModelId);
let text = '';
let model: string | undefined;
let usage: TokenUsage | undefined;
let finishReason: FinishReason | undefined;
let structuredOutput: unknown;
let error: unknown;
let suspended = false;
const reader = resultStream.stream.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = value;
onChunk?.(chunk);
switch (chunk.type) {
case 'text-delta':
text += chunk.delta;
break;
case 'tool-call-suspended':
suspended = true;
break;
case 'error':
error = chunk.error;
break;
case 'finish':
finishReason = chunk.finishReason;
if (chunk.usage !== undefined) usage = chunk.usage;
if (chunk.model !== undefined) model = chunk.model;
if (chunk.structuredOutput !== undefined) {
structuredOutput = chunk.structuredOutput;
}
break;
default:
break;
}
}
} finally {
reader.releaseLock();
}
const resultWithModel =
result.model === undefined && childModelId !== undefined
? { ...result, model: childModelId }
: result;
return generateResultToDelegateSubAgentOutput(request.taskPath, resultWithModel);
if (suspended) {
return failedDelegatedChildSuspendOutput(request.taskPath, model ?? childModelId);
}
// The runtime only serializes its message list into state on success
// and on suspend, so an errored child falls back to the streamed text.
const responseMessages = AgentMessageList.deserialize(
resultStream.getState().messageList,
).responseDelta();
const messages: AgentMessage[] =
responseMessages.length > 0
? responseMessages
: text.trim()
? [{ role: 'assistant', content: [{ type: 'text', text }] }]
: [];
const result: GenerateResult = {
runId: resultStream.runId,
messages,
...((model ?? childModelId) ? { model: model ?? childModelId } : {}),
...(finishReason !== undefined ? { finishReason } : {}),
...(usage !== undefined ? { usage } : {}),
...(structuredOutput !== undefined ? { structuredOutput } : {}),
...(error !== undefined ? { error } : {}),
getState: () => resultStream.getState(),
};
return generateResultToDelegateSubAgentOutput(request.taskPath, result);
} finally {
await childRuntime.dispose();
}

View File

@ -0,0 +1,69 @@
import type { Attributes, Context, Span, SpanOptions, Tracer } from '@opentelemetry/api';
import type { AttributeValue } from '../types/telemetry';
class MetadataEnrichedTracer implements Tracer {
constructor(
private readonly delegate: Tracer,
private readonly attributes: Attributes,
) {}
startSpan(name: string, options?: SpanOptions, context?: Context): Span {
return this.delegate.startSpan(
name,
{
...options,
attributes: { ...this.attributes, ...options?.attributes },
},
context,
);
}
startActiveSpan<F extends (span: Span) => unknown>(name: string, fn: F): ReturnType<F>;
startActiveSpan<F extends (span: Span) => unknown>(
name: string,
options: SpanOptions,
fn: F,
): ReturnType<F>;
startActiveSpan<F extends (span: Span) => unknown>(
name: string,
options: SpanOptions,
context: Context,
fn: F,
): ReturnType<F>;
startActiveSpan<F extends (span: Span) => unknown>(
name: string,
optionsOrFn: SpanOptions | F,
contextOrFn?: Context | F,
fn?: F,
): ReturnType<F> {
if (typeof optionsOrFn === 'function') {
return this.delegate.startActiveSpan(name, optionsOrFn);
}
const options = {
...optionsOrFn,
attributes: { ...this.attributes, ...optionsOrFn.attributes },
};
if (typeof contextOrFn === 'function') {
return this.delegate.startActiveSpan(name, options, contextOrFn);
}
if (contextOrFn === undefined || fn === undefined) {
throw new Error('OpenTelemetry active span callback is required.');
}
return this.delegate.startActiveSpan(name, options, contextOrFn, fn);
}
}
export function createMetadataEnrichedTracer(
tracer: Tracer,
metadata: Record<string, AttributeValue> | undefined,
): Tracer {
if (!metadata || Object.keys(metadata).length === 0) return tracer;
const attributes = Object.fromEntries(
Object.entries(metadata).map(([key, value]) => [`ai.telemetry.metadata.${key}`, value]),
);
return new MetadataEnrichedTracer(tracer, attributes);
}

View File

@ -1,5 +1,8 @@
import type { TelemetryIntegration } from 'ai';
import { isRecord } from '@n8n/utils/is-record';
import type { Tracer } from '@opentelemetry/api';
import type { Telemetry as AiSdkTelemetry } from 'ai';
import { createMetadataEnrichedTracer } from './metadata-enriched-tracer';
import type {
AttributeValue,
BuiltTelemetry,
@ -8,6 +11,16 @@ import type {
} from '../types/telemetry';
type RedactFn = (data: Record<string, unknown>) => Record<string, unknown>;
type ExecuteHookKey = 'executeLanguageModelCall' | 'executeTool';
// Future on* hooks are wrapped automatically; other SDK members must be classified explicitly.
type RedactableTelemetry = AiSdkTelemetry &
Record<Exclude<keyof AiSdkTelemetry, `on${string}` | ExecuteHookKey>, never>;
type UnknownMethod = (this: unknown, ...args: unknown[]) => unknown;
const EXECUTION_CONTROL_FIELDS: Record<ExecuteHookKey, string[]> = {
executeLanguageModelCall: ['callId', 'execute'],
executeTool: ['callId', 'toolCallId', 'execute'],
};
/**
* Recursively apply the redact function to plain objects found anywhere
@ -22,7 +35,7 @@ function redactValue(value: unknown, redact: RedactFn): unknown {
value !== null &&
Object.getPrototypeOf(value) === Object.prototype
) {
const redacted = redact(value as Record<string, unknown>);
const redacted = { ...redact(value as Record<string, unknown>) };
// Recurse into the redacted result so deeply nested objects are also processed.
for (const key of Object.keys(redacted)) {
redacted[key] = redactValue(redacted[key], redact);
@ -41,7 +54,7 @@ function redactValue(value: unknown, redact: RedactFn): unknown {
function redactEvent<T extends object>(event: T, redact: RedactFn): T {
const cloned = { ...event };
// Redact the cloned event itself (it is a plain object).
const redacted = redact(cloned as unknown as Record<string, unknown>);
const redacted = { ...redact(cloned as unknown as Record<string, unknown>) };
// Then recurse into each value to handle arrays and nested objects.
for (const key of Object.keys(redacted)) {
const value = redacted[key];
@ -50,42 +63,82 @@ function redactEvent<T extends object>(event: T, redact: RedactFn): T {
return redacted as T;
}
function isExecuteHookKey(property: PropertyKey): property is ExecuteHookKey {
return property === 'executeLanguageModelCall' || property === 'executeTool';
}
function isTelemetryHook(property: PropertyKey): boolean {
return (typeof property === 'string' && property.startsWith('on')) || isExecuteHookKey(property);
}
function isMethod(value: unknown): value is UnknownMethod {
return typeof value === 'function';
}
function redactHookArgument(property: PropertyKey, argument: unknown, redact: RedactFn): unknown {
if (property === 'onError' || !isRecord(argument)) return redactValue(argument, redact);
const redacted = redactEvent(argument, redact);
if (isExecuteHookKey(property)) {
const controlFields = Object.fromEntries(
EXECUTION_CONTROL_FIELDS[property].map((field) => [field, argument[field]]),
);
return { ...redacted, ...controlFields };
}
return redacted;
}
/**
* Wrap a TelemetryIntegration so every hook passes event data through
* Wrap an AI SDK telemetry integration so every hook passes event data through
* the redact callback before forwarding to the original hook.
*/
function wrapIntegrationWithRedaction(
integration: TelemetryIntegration,
integration: RedactableTelemetry,
redact: RedactFn,
): TelemetryIntegration {
const wrapped: TelemetryIntegration = {};
): AiSdkTelemetry {
const methodCache = new Map<PropertyKey, { original: UnknownMethod; wrapped: UnknownMethod }>();
const facade: AiSdkTelemetry = {};
if (integration.onStart) {
const orig = integration.onStart;
wrapped.onStart = (event) => orig(redactEvent(event, redact));
}
if (integration.onStepStart) {
const orig = integration.onStepStart;
wrapped.onStepStart = (event) => orig(redactEvent(event, redact));
}
if (integration.onToolCallStart) {
const orig = integration.onToolCallStart;
wrapped.onToolCallStart = (event) => orig(redactEvent(event, redact));
}
if (integration.onToolCallFinish) {
const orig = integration.onToolCallFinish;
wrapped.onToolCallFinish = (event) => orig(redactEvent(event, redact));
}
if (integration.onStepFinish) {
const orig = integration.onStepFinish;
wrapped.onStepFinish = (event) => orig(redactEvent(event, redact));
}
if (integration.onFinish) {
const orig = integration.onFinish;
wrapped.onFinish = (event) => orig(redactEvent(event, redact));
return new Proxy(facade, {
get(_target, property) {
const original: unknown = Reflect.get(integration, property, integration);
if (!isTelemetryHook(property) || !isMethod(original)) return original;
const cached = methodCache.get(property);
if (cached?.original === original) return cached.wrapped;
const wrapped: UnknownMethod = function (...args) {
const [argument, ...rest] = args;
return Reflect.apply(original, integration, [
redactHookArgument(property, argument, redact),
...rest,
]);
};
methodCache.set(property, { original, wrapped });
return wrapped;
},
});
}
function isOpenTelemetryTracer(value: unknown): value is Tracer {
return (
value !== null &&
typeof value === 'object' &&
typeof Reflect.get(value, 'startSpan') === 'function' &&
typeof Reflect.get(value, 'startActiveSpan') === 'function'
);
}
async function createAiSdkOpenTelemetryIntegrationFactory(
tracer: OpaqueTracer,
): Promise<(metadata: Record<string, AttributeValue> | undefined) => AiSdkTelemetry> {
if (!isOpenTelemetryTracer(tracer)) {
throw new Error('Telemetry tracer must implement startSpan() and startActiveSpan().');
}
return wrapped;
const { LegacyOpenTelemetry } = await import('@ai-sdk/otel');
return (metadata) =>
new LegacyOpenTelemetry({ tracer: createMetadataEnrichedTracer(tracer, metadata) });
}
/**
@ -128,7 +181,7 @@ async function createOtlpTracer(endpoint: string): Promise<{
*
* Use `.tracer()` with a pre-built integration (e.g. `LangSmithTelemetry`,
* `integrations.langsmith()`) or `.otlpEndpoint()` for a generic OTLP
* collector. Add AI SDK `TelemetryIntegration` hooks via `.integration()`.
* collector. Add AI SDK `Telemetry` hooks via `.integration()`.
*
* @example
* ```typescript
@ -157,7 +210,7 @@ export class Telemetry {
protected redactFn?: RedactFn;
protected integrationsList: TelemetryIntegration[] = [];
protected integrationsList: AiSdkTelemetry[] = [];
protected tracerValue?: OpaqueTracer;
@ -242,7 +295,7 @@ export class Telemetry {
}
/** Add a telemetry integration (e.g. for observability platforms). */
integration(value: TelemetryIntegration): this {
integration(value: AiSdkTelemetry): this {
this.integrationsList.push(value);
return this;
}
@ -285,9 +338,26 @@ export class Telemetry {
provider = otlp.provider;
}
const integrations = this.redactFn
? this.integrationsList.map((i) => wrapIntegrationWithRedaction(i, this.redactFn!))
const redactFn = this.redactFn;
const customIntegrations = redactFn
? this.integrationsList.map((integration) =>
wrapIntegrationWithRedaction(integration, redactFn),
)
: [...this.integrationsList];
let resolveIntegrations: BuiltTelemetry['resolveIntegrations'];
let integrations = customIntegrations;
if (tracer !== undefined) {
const createOpenTelemetryIntegration =
await createAiSdkOpenTelemetryIntegrationFactory(tracer);
resolveIntegrations = (metadata) => {
const integration = createOpenTelemetryIntegration(metadata);
return [
redactFn ? wrapIntegrationWithRedaction(integration, redactFn) : integration,
...customIntegrations,
];
};
integrations = resolveIntegrations(this.metadataValue);
}
return {
enabled: this.enabledValue,
@ -297,6 +367,7 @@ export class Telemetry {
recordOutputs: this.recordOutputsValue,
runtimeRootSpanEnabled: this.runtimeRootSpanEnabledValue,
integrations,
...(resolveIntegrations && { resolveIntegrations }),
tracer,
provider,
credentialName: this.credentialNameValue,

View File

@ -7,7 +7,10 @@ export type {
ContentCitation,
ContentText,
ContentReasoning,
ContentReasoningFile,
ContentFile,
ContentFileRef,
ContentCustom,
ContentToolCall,
ContentInvalidToolCall,
ContentProvider,
@ -18,6 +21,10 @@ export type {
AgentDbMessage,
} from './sdk/message';
export { stripHydratedFileData } from './sdk/message';
export type { BuiltFileStore } from './sdk/file-store';
export type {
Provider,
AnthropicThinkingConfig,
@ -27,6 +34,7 @@ export type {
ThinkingConfigFor,
ThinkingConfig,
} from './sdk/provider';
export type { ReasoningLevel } from './sdk/reasoning';
export type {
AgentResult,
@ -174,6 +182,8 @@ export type {
AgentEventData,
AgentEventHandler,
AgentMiddleware,
ForwardedChildChunk,
SubAgentChunkPayload,
} from './runtime/event';
export type {

View File

@ -19,7 +19,7 @@ export interface SubAgentStartedPayload extends SubAgentLifecycleBase {
}
export interface SubAgentCompletedPayload extends SubAgentLifecycleBase {
status: 'completed' | 'failed' | 'suspended';
status: 'completed' | 'failed' | 'suspended' | 'cancelled';
startedAt: number;
finishedAt: number;
durationMs: number;
@ -33,6 +33,35 @@ export interface SubAgentCompletedPayload extends SubAgentLifecycleBase {
error?: string;
}
/**
* Child stream chunks that may be forwarded live to the parent chat while a
* delegation runs. Args/results and nested sub-agent lifecycle are excluded
* so fan-out stays bounded.
*/
export type ForwardedChildChunk =
| { type: 'text-delta'; id: string; delta: string }
| { type: 'reasoning-start'; id: string }
| { type: 'reasoning-delta'; id: string; delta: string }
| { type: 'reasoning-end'; id: string }
| { type: 'tool-input-start'; toolCallId: string; toolName: string }
| {
type: 'tool-execution-start';
toolCallId: string;
toolName: string;
startTime: number;
}
| {
type: 'tool-execution-end';
toolCallId: string;
toolName: string;
isError: boolean;
endTime: number;
};
export interface SubAgentChunkPayload extends SubAgentLifecycleBase {
chunk: ForwardedChildChunk;
}
export const enum AgentEvent {
AgentStart = 'agent_start',
AgentEnd = 'agent_end',
@ -42,6 +71,7 @@ export const enum AgentEvent {
ToolExecutionEnd = 'tool_execution_end',
SubAgentStarted = 'subagent_started',
SubAgentCompleted = 'subagent_completed',
SubAgentChunk = 'subagent_chunk',
Error = 'error',
}
@ -60,6 +90,7 @@ export type AgentEventData =
}
| ({ type: AgentEvent.SubAgentStarted } & SubAgentStartedPayload)
| ({ type: AgentEvent.SubAgentCompleted } & SubAgentCompletedPayload)
| ({ type: AgentEvent.SubAgentChunk } & SubAgentChunkPayload)
| {
type: AgentEvent.Error;
message: string;

View File

@ -2,6 +2,7 @@ import type { ExecutionOptions, ModelConfig } from './agent';
import type { BuiltEval } from './eval';
import type { BuiltGuardrail } from './guardrail';
import type { CheckpointStore } from './memory';
import type { ReasoningLevel } from './reasoning';
import type { BuiltProviderTool, BuiltTool } from './tool';
import type { ScopedMemoryTaskEvent } from '../../runtime/memory/scoped-memory-task-runner';
import type { RuntimeSkill, RuntimeSkillSource } from '../../skills';
@ -23,8 +24,10 @@ export interface AgentBuilder {
skills(sourceOrSkills: RuntimeSkillSource | RuntimeSkill[]): this;
providerTool(t: BuiltProviderTool): this;
thinking(provider: string, config?: Record<string, unknown>): this;
reasoning(level?: ReasoningLevel): this;
toolCallConcurrency(n: number): this;
memory(m: unknown): this;
fileStore(store: unknown): this;
memoryTaskObserver(observer: (event: ScopedMemoryTaskEvent) => void): this;
checkpoint(storage: 'memory' | CheckpointStore): this;
inputGuardrail(g: BuiltGuardrail): this;

View File

@ -1,5 +1,10 @@
import type { ProviderOptions } from '@ai-sdk/provider-utils';
import type { LanguageModel, OnStepFinishEvent, OnStepStartEvent, smoothStream } from 'ai';
import type {
GenerateTextStepEndEvent,
GenerateTextStepStartEvent,
LanguageModel,
smoothStream,
} from 'ai';
import type { JsonSchema7Type } from 'zod-to-json-schema';
import type { AgentMessage, ContentMetadata } from './message';
@ -7,6 +12,7 @@ import type { ProviderId, ProviderCredentials } from '../../runtime/model/provid
import type {
AgentEvent,
AgentEventHandler,
SubAgentChunkPayload,
SubAgentCompletedPayload,
SubAgentStartedPayload,
} from '../runtime/event';
@ -91,7 +97,7 @@ export type StreamChunk = ContentMetadata &
| {
/**
* Emitted just before a tool handler starts executing. Bridged from
* the runtime event bus (not part of the AI SDK fullStream). Pairs
* the runtime event bus (not part of the AI SDK stream). Pairs
* with the subsequent `tool-result` to let consumers show a
* mid-flight indicator between "LLM picked a tool" and "result arrived".
*/
@ -137,6 +143,7 @@ export type StreamChunk = ContentMetadata &
| { type: 'message'; message: AgentMessage }
| ({ type: 'subagent-started' } & SubAgentStartedPayload)
| ({ type: 'subagent-completed' } & SubAgentCompletedPayload)
| ({ type: 'subagent-chunk' } & SubAgentChunkPayload)
| {
type: 'finish';
finishReason: FinishReason;
@ -185,8 +192,10 @@ export interface ExecutionOptions {
telemetry?: BuiltTelemetry;
/** Inherited execution counter from the host runtime. Used for aggregate heartbeat telemetry. */
executionCounter?: AgentExecutionCounter;
onStepStart?: (event: OnStepStartEvent) => void | Promise<void>;
onStepFinish?: (event: OnStepFinishEvent) => void | Promise<void>;
onStepStart?: (event: GenerateTextStepStartEvent) => void | Promise<void>;
onStepEnd?: (event: GenerateTextStepEndEvent) => void | Promise<void>;
/** @deprecated Use `onStepEnd` instead. */
onStepFinish?: (event: GenerateTextStepEndEvent) => void | Promise<void>;
/**
* Durable-log RFC (resilience phase), opt-in: persist a `running`-status
* checkpoint at every step boundary (after a tool batch settles, before the

View File

@ -0,0 +1,26 @@
import type { ContentFileRef } from './message';
/**
* Host-injected store that resolves `ContentFileRef` references to bytes.
*
* File content parts are persisted by reference only; before each LLM call
* the runtime hydrates supported parts through this store (see
* `hydrateFileParts`). Parts left without `data` — unsupported media type,
* unknown reference, or load failure — are presented to the model as a text
* metadata line instead of a file part.
*/
export interface BuiltFileStore {
/**
* Resolve a file reference to its bytes. Returns `null` when the file is
* unknown or deleted. `scope.threadId` is the run's conversation thread
* (when the run has one) — stores should restrict resolution to it so a
* reference can't read files from another conversation.
*/
load(ref: ContentFileRef, scope?: { threadId?: string }): Promise<Uint8Array | null>;
/**
* Whether the current model accepts this media type as a file part.
* When omitted, every media type is hydrated.
*/
isMediaTypeSupported?(mediaType: string | undefined): boolean;
}

View File

@ -9,8 +9,10 @@ export type MessageContent =
| ContentToolCall
| ContentInvalidToolCall
| ContentReasoning
| ContentReasoningFile
| ContentFile
| ContentCitation
| ContentCustom
| ContentProvider;
export interface ContentMetadata {
@ -62,6 +64,17 @@ export type ContentReasoning = ContentMetadata & {
text: string;
};
/**
* Reference to file bytes held in a host-provided store (see `BuiltFileStore`).
* Only the reference is persisted.
*/
export interface ContentFileRef {
/** Stable identifier resolvable by the injected file store. */
id: string;
fileName?: string;
sizeBytes?: number;
}
export type ContentFile = ContentMetadata & {
type: 'file';
@ -79,8 +92,25 @@ export type ContentFile = ContentMetadata & {
* If the API returns base64 encoded strings, the file data should be returned
* as base64 encoded strings. If the API returns binary data, the file data should
* be returned as binary data.
*
* Absent on reference-only parts (`fileRef` set); hydrated by the runtime
* before LLM calls.
*/
data: Uint8Array | ArrayBuffer | Buffer | string;
data?: Uint8Array | ArrayBuffer | Buffer | string;
/** External-store reference for the file bytes. At least one of `data` / `fileRef` must be set. */
fileRef?: ContentFileRef;
};
export type ContentReasoningFile = ContentMetadata & {
type: 'reasoning-file';
data: ContentFile['data'];
mediaType: string;
};
export type ContentCustom = ContentMetadata & {
type: 'custom';
kind: `${string}.${string}`;
};
export type ContentToolCall = ContentMetadata & {
@ -189,3 +219,26 @@ export type AgentMessage = Message | CustomAgentMessage;
* cursors; both fields are populated on read by every backend.
*/
export type AgentDbMessage = { id: string; createdAt: Date } & AgentMessage;
/**
* Return a copy of the message with hydrated bytes removed from file parts
* that carry a `fileRef`. Persistence backends must apply this before
* serializing message content, so stored messages hold only references.
* Parts without a `fileRef` keep their inline `data`. Non-content messages
* and messages without hydrated file parts are returned as-is.
*/
export function stripHydratedFileData<T extends AgentMessage>(message: T): T {
if (!('content' in message) || !Array.isArray(message.content)) return message;
let changed = false;
const content = message.content.map((block) => {
if (block.type === 'file' && block.fileRef && block.data !== undefined) {
changed = true;
const { data: _data, ...rest } = block;
return rest;
}
return block;
});
return changed ? { ...message, content } : message;
}

View File

@ -0,0 +1,4 @@
import type { LanguageModelCallOptions } from 'ai';
/** Provider-agnostic reasoning level understood by AI SDK 7. */
export type ReasoningLevel = NonNullable<LanguageModelCallOptions['reasoning']>;

View File

@ -1,4 +1,4 @@
import type { TelemetryIntegration } from 'ai';
import type { Telemetry as AiSdkTelemetry } from 'ai';
/** OTel-compatible attribute values (no undefined). */
export type AttributeValue = string | number | boolean | string[] | number[] | boolean[];
@ -43,7 +43,11 @@ export interface BuiltTelemetry {
*/
readonly rootAnchored?: boolean;
/** Integrations are pre-wrapped with redaction if .redact() was set at build time. */
readonly integrations: TelemetryIntegration[];
readonly integrations: AiSdkTelemetry[];
/** @internal Rebuild metadata-sensitive integrations for the effective run metadata. */
readonly resolveIntegrations?: (
metadata: Record<string, AttributeValue> | undefined,
) => AiSdkTelemetry[];
readonly tracer?: OpaqueTracer;
/** @internal Provider reference for flush/shutdown. Only set when .otlpEndpoint() is used. */
readonly provider?: OpaqueTracerProvider;

View File

@ -2,7 +2,7 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { getNodeTypeDefinition } from '../get';
import { getNodeTypeDefinition, versionDirToNumber } from '../get';
describe('getNodeTypeDefinition — split-node discriminator handling', () => {
let defsDir: string;
@ -57,3 +57,58 @@ describe('getNodeTypeDefinition — split-node discriminator handling', () => {
expect(result.error).toContain('message (post, update)');
});
});
describe('versionDirToNumber', () => {
it('reads a two-digit suffix as major.minor, not a plain integer', () => {
// The bug: v22 (2.2) must rank below v3 (3), not above it.
expect(versionDirToNumber('v3')).toBeGreaterThan(versionDirToNumber('v22'));
expect(versionDirToNumber('v22')).toBe(2.2);
expect(versionDirToNumber('v21')).toBe(2.1);
expect(versionDirToNumber('v3')).toBe(3);
expect(versionDirToNumber('v1')).toBe(1);
});
it('leaves the known two-digit-major limitation documented', () => {
// v10 is ambiguous under the dot-dropping encoding and reads as 1.0;
// no node is near a two-digit major, so this is an accepted limitation.
expect(versionDirToNumber('v10')).toBe(1);
});
});
describe('getNodeTypeDefinition — latest version resolution', () => {
let defsDir: string;
beforeAll(() => {
defsDir = mkdtempSync(join(tmpdir(), 'ai-utils-node-defs-versions-'));
// Notion is a split (resource/operation) node shipping versions
// 1, 2, 2.1, 2.2 and 3, so on disk its version dirs are
// v1, v2, v21, v22, v3 (the dot is dropped when naming).
const notionDir = join(defsDir, 'nodes', 'n8n-nodes-base', 'notion');
for (const [version, content] of [
['v1', '// notion v1 get def'],
['v2', '// notion v2 get def'],
['v21', '// notion v2.1 get def'],
['v22', '// notion v2.2 get def'],
['v3', '// notion v3 get def'],
]) {
const opDir = join(notionDir, version, 'resource_database_page');
mkdirSync(opDir, { recursive: true });
writeFileSync(join(opDir, 'operation_get.ts'), content);
}
});
afterAll(() => {
rmSync(defsDir, { recursive: true, force: true });
});
it('treats v3 as latest, not v2.2, when no version is requested', () => {
const result = getNodeTypeDefinition('n8n-nodes-base.notion', undefined, [defsDir], {
resource: 'databasePage',
operation: 'get',
});
expect(result.error).toBeUndefined();
expect(result.version).toBe('v3');
expect(result.content).toBe('// notion v3 get def');
});
});

Some files were not shown because too many files have changed in this diff Show More