Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/agent-limits-keys-step.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@tanstack/ai': minor
---

Subagent limits, provider keys for agents, and host hooks for agent steps and background agents.

- **`subagents.limits`**: `maxDepth`, `maxConcurrent`, `maxCalls`, and `timeoutMs` for the whole tree of children the model starts through tools. The tree shares one budget (`SubagentBudget`), which a child's `ctx.chat({ subagents })` passes on, so a child cannot reset it. A refused start reaches the model as a tool error.
- **`ctx.keys`** (`ProviderKeys`: `get`, `require`, and `adapter`) finds provider keys in agent code. A host sets them with `SubagentBinding.keys`. Without a host, `ctx.keys` reads each provider's `env` names, and a missing key throws an error that names the env var.
- **`ctx.step.do(name, fn)`** (`AgentStep`). A host can set `SubagentBinding.step` to keep the value of `fn`, so a side effect does not run again when the host runs the agent again. Without a host, `fn` runs each time.
- **`ctx.agents.start()`** (`AgentStarter`, `AgentRunHandle`) starts an agent in the background when a host binds it with `SubagentBinding.agents`. Without a host, it throws.
29 changes: 29 additions & 0 deletions docs/advanced/byok.md
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,35 @@ byok.setServerCoverage(true);

Then a send with no pasted key still POSTs. The relay uses the env key. If that is also empty, the relay returns `byokMissing` (401). The client sets `snapshot.prompt`.

## Keys inside agents

An agent that calls a model needs a key too. Wrap the adapter in `keyedAdapter`. Before the call, the agent makes it with `ctx.keys`:

```typescript
import { defineAgent, keyedAdapter } from "@tanstack/ai";
import { createOpenaiChat } from "@tanstack/ai-openai";
import { openaiByok } from "@tanstack/ai-openai/byok";

const gpt = keyedAdapter(openaiByok, (key) =>
createOpenaiChat("gpt-6.1-sol", key),
);

export const writer = defineAgent({
name: "writer",
description: "Writes a short draft",
run: async (ctx) =>
ctx.chat({
adapter: await ctx.keys.adapter(gpt),
messages: [{ role: "user", content: "Write a short draft." }],
stream: false,
}),
});
```

- Without a host, `ctx.keys` reads the env var of the provider, here `OPENAI_API_KEY`. If it is empty, the call throws `Missing OpenAI API key. Set OPENAI_API_KEY.`
- A host can give its own keys in `subagents.binding.keys`. A child agent gives them to its own children.
- `ctx.keys.get(provider)` returns the key or `null`. `ctx.keys.require(provider)` throws when there is no key.

## Image, audio, and other providers

For other cases:
Expand Down
45 changes: 45 additions & 0 deletions docs/chat/subagents.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,51 @@ const researcher = defineAgent({

A child that `ctx.chat` starts gets the prompt cache retention of the parent call, unless the call sets its own `promptCache`.

## Limit the tree

A child can have children of its own, and a model can call a child many times. Set `limits` so one request cannot start an unbounded tree:

```ts group=subagent-values
const limited = chat({
adapter: openaiText('gpt-6.1-sol'),
messages: [{ role: 'user', content: 'Research squids in depth' }],
subagents: {
agents: [researcher],
limits: { maxDepth: 2, maxConcurrent: 3, maxCalls: 12, timeoutMs: 120_000 },
},
})
```

- `maxDepth`: how deep the tree can grow. The first chat is depth 0.
- `maxCalls`: how many children the whole tree can start.
- `maxConcurrent`: how many children one run can have running at once.
- `timeoutMs`: how long one child can run. A child never gets more time than its parent has left.

The whole tree shares one budget. A child that calls `ctx.chat({ subagents })` passes it on, so a child cannot reset it. A refused start reaches the model as a tool error, for example `subagent limit reached (maxCalls 12)`.

- Set `limits` on the first `chat()`. A nested chat in a child uses that budget, so its own `limits` do not apply.
- A child that a router picks does not count against the limits. The children it starts do count, one level deeper.
- A child that runs past `timeoutMs` stops, and the model gets `subagent timed out after 120000 ms`.

## Run context for hosts

A host that runs agents for a long session can give each run more on `subagents.binding`:

- `keys`: the provider keys that the agent reads as `ctx.keys`. See [Keys inside agents](../advanced/byok#keys-inside-agents).
- `step`: what `ctx.step.do(name, fn)` uses. A host can store the value of `fn`, so that a side effect does not run again when the host runs the agent again after a crash. Without a host, `fn` runs each time.
- `agents`: what `ctx.agents.start(agent, input)` uses to start an agent in the background. Without a host, `ctx.agents.start` throws.

```ts group=subagent-values
const charger = defineAgent({
name: 'charger',
description: 'Charges the card once',
run: async (ctx) => {
const receipt = await ctx.step.do('charge', () => ({ id: 'rcpt-1' }))
return receipt
},
})
```

## Strategy

- `exclusive` (default): the chosen child owns the turn. Main does not answer after it.
Expand Down
2 changes: 1 addition & 1 deletion docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -1128,7 +1128,7 @@
"label": "Bring Your Own Key",
"to": "advanced/byok",
"addedAt": "2026-08-18",
"updatedAt": "2026-10-09"
"updatedAt": "2026-10-12"
},
{
"label": "Debug Logging",
Expand Down
88 changes: 85 additions & 3 deletions packages/ai/src/activities/chat/agents/bound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,14 @@ import { rerank } from '../../rerank/index'
import { decide } from '../../evaluate/index'
import type { GenerationMiddleware } from '../../middleware/types'
import type { AnyChatMiddleware } from '../middleware/types'
import type { PromptCacheRetention, RunAgentResumeItem } from '../../../types'
import type { SubagentRunInput } from './define-agent'
import type {
ContentPart,
PromptCacheRetention,
RunAgentResumeItem,
} from '../../../types'
import type { DefinedAgent, SubagentRunInput } from './define-agent'
import type { SubagentBudget } from './limits'
import type { ProviderKeys } from '../../../byok/keyed'

/**
* The fields a child `chat()` needs, in one spread:
Expand All @@ -36,11 +42,73 @@ export interface SubagentForward {
promptCache?: PromptCacheRetention
}

/** The steps of an agent run, as `ctx.step`. */
export interface AgentStep {
/**
* Run `fn` for `name`, and return its value. When a host runs a stopped
* agent again, a finished step returns its stored value and `fn` does not
* run again. Without such a host, `fn` runs each time. The value must be
* JSON. Use a name that is the same on every run.
*/
do: <T>(name: string, fn: () => T | Promise<T>) => Promise<T>
}

/**
* An agent run that `ctx.agents.start` started in the background. Await it
* for the run's result.
*/
export interface AgentRunHandle<
TResult = unknown,
> extends PromiseLike<TResult> {
readonly id: string
/**
* Add a message to the run: `'steer'` (default) for its next model call,
* or `'followUp'` to run the agent again after the run ends. The host
* decides the details.
*/
send: (
message: string | Array<ContentPart>,
options?: { mode?: 'steer' | 'followUp'; inputId?: string },
) => Promise<unknown>
/** Stop the run. */
cancel: () => Promise<unknown>
}

/** `ctx.agents`: start agents in the background. A host gives it. */
export interface AgentStarter {
/**
* Start `agent` (a definition, or a name the host knows) in the
* background, and return its run at once. Throws when the host refuses
* it, for example over the subagent tree budget.
*/
start: (
agent: DefinedAgent | string,
input?: unknown,
options?: {
wake?: boolean
attach?: 'reference' | 'none'
resume?: boolean
},
) => AgentRunHandle
}

/**
* What a host adds to every activity call an agent makes through `ctx`. Apps
* using plain `chat({ subagents })` do not set it.
*/
export interface SubagentBinding {
/**
* The steps the agent reads as `ctx.step`. Only this agent run gets them,
* not the children it starts. Without them, `ctx.step.do` runs `fn` each
* time.
*/
step?: AgentStep
/**
* What the agent reads as `ctx.agents`: it starts agents in the
* background. Only this agent run gets it, not the children it starts in
* a `ctx.chat({ subagents })`. Without it, `ctx.agents.start` throws.
*/
agents?: AgentStarter
/**
* Added before the call's own middleware on every `ctx.chat` call. A
* child's `ctx.chat({ subagents })` passes it down to its own children.
Expand All @@ -51,6 +119,17 @@ export interface SubagentBinding {
* child's `ctx.chat({ subagents })` passes it down to its own children.
*/
generationMiddleware?: ReadonlyArray<GenerationMiddleware>
/**
* The subagent tree budget. A child's `ctx.chat({ subagents })` passes it
* down, so limits hold across the whole tree.
*/
budget?: SubagentBudget
/**
* The provider keys an agent reads as `ctx.keys`. A child's
* `ctx.chat({ subagents })` passes them down to its own children. Without
* them, `ctx.keys` reads each provider's `env` names.
*/
keys?: ProviderKeys
/**
* The prompt cache retention of the parent call. A child's `ctx.chat()`
* uses it when the call gives no `promptCache`.
Expand Down Expand Up @@ -132,12 +211,14 @@ export function createBoundActivities(
...(binding?.promptCache ? { promptCache: binding.promptCache } : {}),
abortController,
...options,
// Nested children get the host middleware before their own.
// Nested children get the host middleware before their own, share
// the tree budget, and get the host keys unless the call sets its own.
...(options.subagents
? {
subagents: {
...options.subagents,
binding: {
...(binding?.keys ? { keys: binding.keys } : {}),
...options.subagents.binding,
chatMiddleware: [
...chatMiddleware,
Expand All @@ -147,6 +228,7 @@ export function createBoundActivities(
...generationMiddleware,
...(options.subagents.binding?.generationMiddleware ?? []),
],
...(binding?.budget ? { budget: binding.budget } : {}),
},
},
}
Expand Down
19 changes: 18 additions & 1 deletion packages/ai/src/activities/chat/agents/define-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,13 @@ import type {
UIMessage,
} from '../../../types'
import type { AnyClientTool } from '../tools/tool-definition'
import type { BoundActivities, SubagentForward } from './bound'
import type {
AgentStarter,
AgentStep,
BoundActivities,
SubagentForward,
} from './bound'
import type { ProviderKeys } from '../../../byok/keyed'

/**
* What the library knows about a child run before `run` starts.
Expand Down Expand Up @@ -55,11 +61,22 @@ export interface SubagentRunInput<
* - The activity functions (`ctx.chat`, `ctx.generateImage`, and the rest)
* take the same options as the plain functions and fill in the thread id,
* a run id, the abort signal, and any middleware a host adds.
* - `keys` finds provider keys. `await ctx.keys.adapter(adapter)` builds a
* `keyedAdapter(...)`. A host sets the keys. Without a host, they come
* from each provider's `env` names, and a missing key throws an error that
* names the env var.
* - `step.do(name, fn)` keeps a side effect from running twice when a host
* runs the agent again after a crash. See {@link AgentStep}.
* - `agents.start(agent, input, options)` starts an agent in the
* background, when a host runs this agent. Without such a host it throws.
*/
export type SubagentRunContext<TInput extends SchemaInput | undefined = any> =
SubagentRunInput<TInput> &
BoundActivities & {
forward: SubagentForward
keys: ProviderKeys
step: AgentStep
agents: AgentStarter
}

/**
Expand Down
75 changes: 75 additions & 0 deletions packages/ai/src/activities/chat/agents/limits.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/**
* Limits for a whole subagent tree. The root run makes one budget, and every
* child shares it, so a child cannot reset it.
*/
export interface SubagentLimits {
/** How deep the tree may grow. The root run is depth 0. */
maxDepth?: number
/** How many children one run may have running at once. */
maxConcurrent?: number
/** How many children the whole tree may start. */
maxCalls?: number
/** How long one child may run. Never later than its parent's own deadline. */
timeoutMs?: number
}

/** The shared budget of one subagent tree. */
export class SubagentBudget {
private constructor(
readonly limits: SubagentLimits,
/** The depth of the run that owns this budget view. */
readonly depth: number,
private readonly shared: { calls: number },
/** Epoch ms after which this run's children must stop, if any. */
readonly deadline: number | undefined,
) {}

/** A budget for a root run. */
static root(limits: SubagentLimits = {}): SubagentBudget {
return new SubagentBudget(limits, 0, { calls: 0 }, undefined)
}

/** How many children the tree started so far. */
get calls(): number {
return this.shared.calls
}

/**
* Reserve one child spawn. Returns the refusal message the model sees, or
* `undefined` when the child may start. `active` is how many children this
* run has running now.
*/
reserve(active: number): string | undefined {
const { maxDepth, maxCalls, maxConcurrent } = this.limits
if (maxDepth !== undefined && this.depth + 1 > maxDepth) {
return `subagent limit reached (maxDepth ${maxDepth})`
}
if (maxCalls !== undefined && this.shared.calls >= maxCalls) {
return `subagent limit reached (maxCalls ${maxCalls})`
}
if (maxConcurrent !== undefined && active >= maxConcurrent) {
return `subagent limit reached (maxConcurrent ${maxConcurrent})`
}
this.shared.calls += 1
return undefined
}

/** Milliseconds a child started now may run, or `undefined` for no limit. */
childTimeout(now = Date.now()): number | undefined {
const own = this.limits.timeoutMs
const remaining =
this.deadline === undefined ? undefined : Math.max(0, this.deadline - now)
if (own === undefined) return remaining
return remaining === undefined ? own : Math.min(own, remaining)
}

/** The budget a child's own subagents use: one level deeper, same counters. */
child(deadline?: number): SubagentBudget {
return new SubagentBudget(
this.limits,
this.depth + 1,
this.shared,
deadline ?? this.deadline,
)
}
}
Loading
Loading