Skip to content

Commit e9f7f39

Browse files
committed
feat(chat): auto-hydrate chat.local values in ai.tool subtasks
1 parent 7f3c4ed commit e9f7f39

File tree

2 files changed

+84
-15
lines changed
  • packages/trigger-sdk/src/v3
  • references/ai-chat/src/trigger

2 files changed

+84
-15
lines changed

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)