Skip to content

Commit e1cbf64

Browse files
committed
fix(sdk): fail closed on tools() errors and re-apply toModelOutput on resume
Addresses review on the chat.agent tools option: - A throwing per-turn tools() resolver now fails the turn instead of reusing the prior turn's set, so the function form cannot leak stale capabilities when it gates tools by user or flag. - A function-form tools option is now resolved at boot (with the run or continuation payload's clientData) before the restored history is converted, so toModelOutput is re-applied to a resumed chat's prior tool results. That boot resolution fails open (logs, converts without tools) since it only re-renders saved history; per-turn resolution stays fail-closed. Adds a function-form variant of the test agent for the smoke test.
1 parent 30393ca commit e1cbf64

2 files changed

Lines changed: 72 additions & 26 deletions

File tree

  • packages/trigger-sdk/src/v3
  • references/ai-chat/src/trigger

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

Lines changed: 47 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2861,15 +2861,22 @@ async function applyPrepareMessages(
28612861
}
28622862

28632863
/**
2864-
* Resolve the `tools` option for the current turn and cache it in locals so
2864+
* Resolve the `tools` option into a concrete `ToolSet` and cache it in locals so
28652865
* `toModelMessages` can pass it to `convertToModelMessages`. For the function
2866-
* form, invokes the user function once per turn with the current turn context
2867-
* (which already has parsed `clientData`). No-op when no `tools` were declared.
2868-
* A throwing tools function logs and leaves the previously-resolved value in
2869-
* place rather than killing the turn.
2866+
* form, invokes the user function with the given context (or the current turn
2867+
* context when no override is passed). Pass an `override` for the boot-time
2868+
* history conversion, which runs before the per-turn context exists and uses
2869+
* the run/continuation payload's `clientData`.
2870+
*
2871+
* Fails closed: a throwing resolver propagates rather than carrying a prior
2872+
* turn's set forward. The function form can gate capabilities by user or flag,
2873+
* so reusing stale tools would leak capabilities. No-op when no `tools` were
2874+
* declared.
28702875
* @internal
28712876
*/
2872-
async function resolveTurnTools(): Promise<void> {
2877+
async function resolveTurnTools(
2878+
override?: { chatId: string; turn: number; continuation: boolean; clientData: unknown }
2879+
): Promise<void> {
28732880
const option = locals.get(chatToolsOptionKey);
28742881
if (!option) return;
28752882

@@ -2878,20 +2885,14 @@ async function resolveTurnTools(): Promise<void> {
28782885
return;
28792886
}
28802887

2881-
const turnCtx = locals.get(chatTurnContextKey);
2882-
try {
2883-
const resolved = await option({
2884-
chatId: turnCtx?.chatId ?? "",
2885-
turn: turnCtx?.turn ?? 0,
2886-
continuation: turnCtx?.continuation ?? false,
2887-
clientData: turnCtx?.clientData,
2888-
});
2889-
locals.set(chatResolvedToolsKey, resolved);
2890-
} catch (error) {
2891-
logger.warn("chat.agent: tools() resolver threw; reusing previous tools for this turn", {
2892-
error: error instanceof Error ? error.message : String(error),
2893-
});
2894-
}
2888+
const ctx = override ?? locals.get(chatTurnContextKey);
2889+
const resolved = await option({
2890+
chatId: ctx?.chatId ?? "",
2891+
turn: ctx?.turn ?? 0,
2892+
continuation: ctx?.continuation ?? false,
2893+
clientData: ctx?.clientData,
2894+
});
2895+
locals.set(chatResolvedToolsKey, resolved);
28952896
}
28962897

28972898
/**
@@ -5194,9 +5195,9 @@ function chatAgent<
51945195
| ToolSet
51955196
| ((event: ResolveToolsEvent<unknown>) => ToolSet | Promise<ToolSet>)
51965197
);
5197-
// Static tools are usable immediately (e.g. the boot-time history
5198-
// seed before the first turn context exists). The function form is
5199-
// resolved per-turn once `clientData` is parsed (see resolveTurnTools).
5198+
// Static tools are usable immediately. The function form is resolved
5199+
// just before the boot history conversion (with the payload's
5200+
// clientData) and again per-turn (see resolveTurnTools).
52005201
if (typeof toolsOption !== "function") {
52015202
locals.set(chatResolvedToolsKey, toolsOption);
52025203
}
@@ -5591,6 +5592,29 @@ function chatAgent<
55915592
}
55925593

55935594
if (accumulatedUIMessages.length > 0) {
5595+
// Resolve a function-form `tools` with the run/continuation payload's
5596+
// clientData so this conversion of the restored history applies each
5597+
// tool's toModelOutput (static tools were already seeded above). This
5598+
// only re-renders saved history, so it fails open: a resolver hiccup
5599+
// logs and converts without tools rather than blocking the resume.
5600+
// Per-turn resolveTurnTools still fails closed for live turns.
5601+
if (typeof toolsOption === "function") {
5602+
try {
5603+
await resolveTurnTools({
5604+
chatId: payload.chatId,
5605+
turn: 0,
5606+
continuation: payload.continuation ?? false,
5607+
clientData: parseClientData
5608+
? await parseClientData(payload.metadata)
5609+
: payload.metadata,
5610+
});
5611+
} catch (error) {
5612+
logger.warn(
5613+
"chat.agent: tools() resolver threw at boot; restored history converted without toModelOutput",
5614+
{ error: error instanceof Error ? error.message : String(error) }
5615+
);
5616+
}
5617+
}
55945618
try {
55955619
accumulatedMessages = await toModelMessages(accumulatedUIMessages);
55965620
} catch (error) {

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

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1150,6 +1150,10 @@ function logVaultProbe(messages: ModelMessage[]) {
11501150
}
11511151
}
11521152

1153+
const vaultSystemPrompt =
1154+
"You are a vault assistant. Follow the user's formatting instructions exactly. " +
1155+
"When the user asks for the codeword, answer with it directly.";
1156+
11531157
export const toolModelOutputTest = chat.agent({
11541158
id: "tool-model-output-test",
11551159
idleTimeoutInSeconds: 60,
@@ -1161,9 +1165,27 @@ export const toolModelOutputTest = chat.agent({
11611165
logVaultProbe(messages);
11621166
return streamText({
11631167
model: openai("gpt-4o-mini"),
1164-
system:
1165-
"You are a vault assistant. Follow the user's formatting instructions exactly. " +
1166-
"When the user asks for the codeword, answer with it directly.",
1168+
system: vaultSystemPrompt,
1169+
messages,
1170+
tools,
1171+
stopWhen: stepCountIs(5),
1172+
abortSignal: signal,
1173+
});
1174+
},
1175+
});
1176+
1177+
// Same test, but with the per-turn function form of `tools`. Exercises the
1178+
// resolver path: resolved per turn (and at boot, with the payload's clientData,
1179+
// so a continuation's restored history still gets toModelOutput re-applied).
1180+
export const toolModelOutputFnTest = chat.agent({
1181+
id: "tool-model-output-fn-test",
1182+
idleTimeoutInSeconds: 60,
1183+
tools: () => ({ vault: vaultTool }),
1184+
run: async ({ messages, tools, signal }) => {
1185+
logVaultProbe(messages);
1186+
return streamText({
1187+
model: openai("gpt-4o-mini"),
1188+
system: vaultSystemPrompt,
11671189
messages,
11681190
tools,
11691191
stopWhen: stepCountIs(5),

0 commit comments

Comments
 (0)