@@ -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 {
15461560const CHAT_LOCAL_KEY : unique symbol = Symbol ( "chatLocalKey" ) ;
15471561/** @internal Symbol for storing the dirty-tracking locals key. */
15481562const 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 ,
0 commit comments