Skip to content

Commit fb4a699

Browse files
committed
feat(chat): auto-hydrate chat.local values in ai.tool subtasks
1 parent 562b67a commit fb4a699

File tree

3 files changed

+135
-21
lines changed

3 files changed

+135
-21
lines changed

docs/guides/ai-chat.mdx

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1122,9 +1122,11 @@ run: async ({ messages, signal }) => {
11221122

11231123
Use `chat.local` to create typed, run-scoped data that persists across turns and is accessible from anywhere — the run function, tools, nested helpers. Each run gets its own isolated copy, and locals are automatically cleared between runs.
11241124

1125+
When a subtask is invoked via `ai.tool()`, initialized locals are automatically serialized into the subtask's metadata and hydrated on first access — no extra code needed. Subtask changes to hydrated locals are local to the subtask and don't propagate back to the parent.
1126+
11251127
### Declaring and initializing
11261128

1127-
Declare locals at module level, then initialize them inside a lifecycle hook where you have context (chatId, clientData, etc.):
1129+
Declare locals at module level with a unique `id`, then initialize them inside a lifecycle hook where you have context (chatId, clientData, etc.):
11281130

11291131
```ts
11301132
import { chat } from "@trigger.dev/sdk/ai";
@@ -1133,12 +1135,12 @@ import { openai } from "@ai-sdk/openai";
11331135
import { z } from "zod";
11341136
import { db } from "@/lib/db";
11351137

1136-
// Declare at module level — multiple locals can coexist
1138+
// Declare at module level — each local needs a unique id
11371139
const userContext = chat.local<{
11381140
name: string;
11391141
plan: "free" | "pro";
11401142
messageCount: number;
1141-
}>();
1143+
}>({ id: "userContext" });
11421144

11431145
export const myChat = chat.task({
11441146
id: "my-chat",
@@ -1172,7 +1174,7 @@ export const myChat = chat.task({
11721174
Locals are accessible from anywhere during task execution — including AI SDK tools:
11731175

11741176
```ts
1175-
const userContext = chat.local<{ plan: "free" | "pro" }>();
1177+
const userContext = chat.local<{ plan: "free" | "pro" }>({ id: "userContext" });
11761178

11771179
const premiumTool = tool({
11781180
description: "Access premium features",
@@ -1186,6 +1188,49 @@ const premiumTool = tool({
11861188
});
11871189
```
11881190

1191+
### Accessing from subtasks
1192+
1193+
When you use `ai.tool()` to expose a subtask, chat locals are automatically available read-only:
1194+
1195+
```ts
1196+
import { chat, ai } from "@trigger.dev/sdk/ai";
1197+
import { schemaTask } from "@trigger.dev/sdk";
1198+
import { streamText } from "ai";
1199+
import { openai } from "@ai-sdk/openai";
1200+
import { z } from "zod";
1201+
1202+
const userContext = chat.local<{ name: string; plan: "free" | "pro" }>({ id: "userContext" });
1203+
1204+
export const analyzeData = schemaTask({
1205+
id: "analyze-data",
1206+
schema: z.object({ query: z.string() }),
1207+
run: async ({ query }) => {
1208+
// userContext.name just works — auto-hydrated from parent metadata
1209+
console.log(`Analyzing for ${userContext.name}`);
1210+
// Changes here are local to this subtask and don't propagate back
1211+
},
1212+
});
1213+
1214+
export const myChat = chat.task({
1215+
id: "my-chat",
1216+
onChatStart: async ({ clientData }) => {
1217+
userContext.init({ name: "Alice", plan: "pro" });
1218+
},
1219+
run: async ({ messages, signal }) => {
1220+
return streamText({
1221+
model: openai("gpt-4o"),
1222+
messages,
1223+
tools: { analyzeData: ai.tool(analyzeData) },
1224+
abortSignal: signal,
1225+
});
1226+
},
1227+
});
1228+
```
1229+
1230+
<Note>
1231+
Values must be JSON-serializable for subtask access. Non-serializable values (functions, class instances, etc.) will be lost during transfer.
1232+
</Note>
1233+
11891234
### Dirty tracking and persistence
11901235

11911236
The `hasChanged()` method returns `true` if any property was set since the last check, then resets the flag. Use it in lifecycle hooks to only persist when data actually changed:
@@ -1207,7 +1252,7 @@ onTurnComplete: async ({ chatId }) => {
12071252

12081253
| Method | Description |
12091254
|--------|-------------|
1210-
| `chat.local<T>()` | Create a typed local (declare at module level) |
1255+
| `chat.local<T>({ id })` | Create a typed local with a unique id (declare at module level) |
12111256
| `local.init(value)` | Initialize with a value (call in hooks or `run`) |
12121257
| `local.hasChanged()` | Returns `true` if modified since last check, resets flag |
12131258
| `local.get()` | Returns a plain object copy (for serialization) |
@@ -1337,7 +1382,7 @@ See [onTurnComplete](#onturncomplete) for the full field reference.
13371382
|--------|-------------|
13381383
| `chat.task(options)` | Create a chat task |
13391384
| `chat.pipe(source, options?)` | Pipe a stream to the frontend (from anywhere inside a task) |
1340-
| `chat.local<T>()` | Create a per-run typed local (see [Per-run data](#per-run-data-with-chatlocal)) |
1385+
| `chat.local<T>({ id })` | Create a per-run typed local (see [Per-run data](#per-run-data-with-chatlocal)) |
13411386
| `chat.createAccessToken(taskId)` | Create a public access token for a chat task |
13421387
| `chat.setTurnTimeout(duration)` | Override turn timeout at runtime (e.g. `"2h"`) |
13431388
| `chat.setTurnTimeoutInSeconds(seconds)` | Override turn timeout at runtime (in seconds) |

packages/trigger-sdk/src/v3/ai.ts

Lines changed: 82 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ export type ToolCallExecutionOptions = {
4040
turn?: number;
4141
continuation?: boolean;
4242
clientData?: unknown;
43+
/** Serialized chat.local values from the parent run. @internal */
44+
chatLocals?: Record<string, unknown>;
4345
};
4446

4547
/** Chat context stored in locals during each chat.task turn for auto-detection. */
@@ -121,6 +123,18 @@ function toolFromTask<
121123
toolMeta.clientData = chatCtx.clientData;
122124
}
123125

126+
// Serialize initialized chat.local values for subtask hydration
127+
const chatLocals: Record<string, unknown> = {};
128+
for (const entry of chatLocalRegistry) {
129+
const value = locals.get(entry.key);
130+
if (value !== undefined) {
131+
chatLocals[entry.id] = value;
132+
}
133+
}
134+
if (Object.keys(chatLocals).length > 0) {
135+
toolMeta.chatLocals = chatLocals;
136+
}
137+
124138
return await task
125139
.triggerAndWait(input as inferSchemaIn<TTaskSchema>, {
126140
metadata: {
@@ -1546,8 +1560,31 @@ function cleanupAbortedParts(message: UIMessage): UIMessage {
15461560
const CHAT_LOCAL_KEY: unique symbol = Symbol("chatLocalKey");
15471561
/** @internal Symbol for storing the dirty-tracking locals key. */
15481562
const CHAT_LOCAL_DIRTY_KEY: unique symbol = Symbol("chatLocalDirtyKey");
1549-
/** @internal Counter for generating unique locals IDs. */
1550-
let chatLocalCounter = 0;
1563+
1564+
// ---------------------------------------------------------------------------
1565+
// chat.local registry — tracks all declared locals for serialization
1566+
// ---------------------------------------------------------------------------
1567+
1568+
type ChatLocalEntry = { key: ReturnType<typeof locals.create>; id: string };
1569+
const chatLocalRegistry = new Set<ChatLocalEntry>();
1570+
1571+
/** @internal Run-scoped flag to ensure hydration happens at most once per run. */
1572+
const chatLocalsHydratedKey = locals.create<boolean>("chat.locals.hydrated");
1573+
1574+
/**
1575+
* Hydrate chat.local values from subtask metadata (set by toolFromTask).
1576+
* Runs once per run — subsequent calls are no-ops.
1577+
* @internal
1578+
*/
1579+
function hydrateLocalsFromMetadata(): void {
1580+
if (locals.get(chatLocalsHydratedKey)) return;
1581+
locals.set(chatLocalsHydratedKey, true);
1582+
const opts = metadata.get(METADATA_KEY) as ToolCallExecutionOptions | undefined;
1583+
if (!opts?.chatLocals) return;
1584+
for (const [id, value] of Object.entries(opts.chatLocals)) {
1585+
locals.set(locals.create(id), value);
1586+
}
1587+
}
15511588

15521589
/**
15531590
* A Proxy-backed, run-scoped data object that appears as `T` to users.
@@ -1574,12 +1611,16 @@ export type ChatLocal<T extends Record<string, unknown>> = T & {
15741611
*
15751612
* Multiple locals can coexist — each gets its own isolated run-scoped storage.
15761613
*
1614+
* The `id` is required and must be unique across all `chat.local()` calls in
1615+
* your project. It's used to serialize values into subtask metadata so that
1616+
* `ai.tool()` subtasks can auto-hydrate parent locals (read-only).
1617+
*
15771618
* @example
15781619
* ```ts
15791620
* import { chat } from "@trigger.dev/sdk/ai";
15801621
*
1581-
* const userPrefs = chat.local<{ theme: string; language: string }>();
1582-
* const gameState = chat.local<{ score: number; streak: number }>();
1622+
* const userPrefs = chat.local<{ theme: string; language: string }>({ id: "userPrefs" });
1623+
* const gameState = chat.local<{ score: number; streak: number }>({ id: "gameState" });
15831624
*
15841625
* export const myChat = chat.task({
15851626
* id: "my-chat",
@@ -1603,9 +1644,12 @@ export type ChatLocal<T extends Record<string, unknown>> = T & {
16031644
* });
16041645
* ```
16051646
*/
1606-
function chatLocal<T extends Record<string, unknown>>(): ChatLocal<T> {
1607-
const localKey = locals.create<T>(`chat.local.${chatLocalCounter++}`);
1608-
const dirtyKey = locals.create<boolean>(`chat.local.${chatLocalCounter++}.dirty`);
1647+
function chatLocal<T extends Record<string, unknown>>(options: { id: string }): ChatLocal<T> {
1648+
const id = `chat.local.${options.id}`;
1649+
const localKey = locals.create<T>(id);
1650+
const dirtyKey = locals.create<boolean>(`${id}.dirty`);
1651+
1652+
chatLocalRegistry.add({ key: localKey, id });
16091653

16101654
const target = {} as any;
16111655
target[CHAT_LOCAL_KEY] = localKey;
@@ -1633,7 +1677,11 @@ function chatLocal<T extends Record<string, unknown>>(): ChatLocal<T> {
16331677
}
16341678
if (prop === "get") {
16351679
return () => {
1636-
const current = locals.get(localKey);
1680+
let current = locals.get(localKey);
1681+
if (current === undefined) {
1682+
hydrateLocalsFromMetadata();
1683+
current = locals.get(localKey);
1684+
}
16371685
if (current === undefined) {
16381686
throw new Error(
16391687
"local.get() called before initialization. Call local.init() first."
@@ -1645,12 +1693,21 @@ function chatLocal<T extends Record<string, unknown>>(): ChatLocal<T> {
16451693
// toJSON for serialization (JSON.stringify(local))
16461694
if (prop === "toJSON") {
16471695
return () => {
1648-
const current = locals.get(localKey);
1696+
let current = locals.get(localKey);
1697+
if (current === undefined) {
1698+
hydrateLocalsFromMetadata();
1699+
current = locals.get(localKey);
1700+
}
16491701
return current ? { ...current } : undefined;
16501702
};
16511703
}
16521704

1653-
const current = locals.get(localKey);
1705+
let current = locals.get(localKey);
1706+
if (current === undefined) {
1707+
// Auto-hydrate from parent metadata in subtask context
1708+
hydrateLocalsFromMetadata();
1709+
current = locals.get(localKey);
1710+
}
16541711
if (current === undefined) return undefined;
16551712
return (current as any)[prop];
16561713
},
@@ -1673,18 +1730,30 @@ function chatLocal<T extends Record<string, unknown>>(): ChatLocal<T> {
16731730

16741731
has(_target, prop) {
16751732
if (typeof prop === "symbol") return prop in _target;
1676-
const current = locals.get(localKey);
1733+
let current = locals.get(localKey);
1734+
if (current === undefined) {
1735+
hydrateLocalsFromMetadata();
1736+
current = locals.get(localKey);
1737+
}
16771738
return current !== undefined && prop in current;
16781739
},
16791740

16801741
ownKeys() {
1681-
const current = locals.get(localKey);
1742+
let current = locals.get(localKey);
1743+
if (current === undefined) {
1744+
hydrateLocalsFromMetadata();
1745+
current = locals.get(localKey);
1746+
}
16821747
return current ? Reflect.ownKeys(current) : [];
16831748
},
16841749

16851750
getOwnPropertyDescriptor(_target, prop) {
16861751
if (typeof prop === "symbol") return undefined;
1687-
const current = locals.get(localKey);
1752+
let current = locals.get(localKey);
1753+
if (current === undefined) {
1754+
hydrateLocalsFromMetadata();
1755+
current = locals.get(localKey);
1756+
}
16881757
if (current === undefined || !(prop in current)) return undefined;
16891758
return {
16901759
configurable: true,

references/ai-chat/src/trigger/chat.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,12 +134,12 @@ const userContext = chat.local<{
134134
plan: "free" | "pro";
135135
preferredModel: string | null;
136136
messageCount: number;
137-
}>();
137+
}>({ id: "userContext" });
138138

139139
// Per-run dynamic tools — loaded from DB in onPreload/onChatStart
140140
const userToolDefs = chat.local<
141141
Array<{ name: string; description: string; responseTemplate: string }>
142-
>();
142+
>({ id: "userToolDefs" });
143143

144144
// --------------------------------------------------------------------------
145145
// Subtask: deep research — fetches multiple URLs and streams progress

0 commit comments

Comments
 (0)