Skip to content

Commit e1fb45d

Browse files
committed
feat(sdk)!: chat.agent actions are no longer turns
Action turns previously fell through to the regular turn machinery, calling onTurnStart, run(), onTurnComplete, etc. — meaning every action fired an LLM call by default. Customers worked around this with a chat.store-based skipModelCall flag pattern (Graham at Arena). Now actions fire hydrateMessages and onAction only. No onTurnStart, prepareMessages, onBeforeTurnComplete, onTurnComplete; no run() invocation; no turn-counter increment. The trace span is named "chat action" instead of "chat turn N". onAction widens to accept the same return shapes as run(): void (side-effect-only, default), StreamTextResult (auto-piped as the response), string, or UIMessage. Customers who want a model response from an action return streamText(...) directly from onAction. If an action arrives but no onAction handler is configured, console.warn fires once and the action is ignored (vs. silently triggering run() on a stale wire payload). Closes TRI-9118. BREAKING: customers who relied on actions auto-invoking run() must move that logic into onAction. See the changeset for the migration snippet.
1 parent f66fbb0 commit e1fb45d

3 files changed

Lines changed: 303 additions & 20 deletions

File tree

.changeset/chat-actions-no-turn.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
"@trigger.dev/sdk": minor
3+
---
4+
5+
`chat.agent` actions are no longer treated as turns. They fire `hydrateMessages` and `onAction` only — no `onTurnStart` / `prepareMessages` / `onBeforeTurnComplete` / `onTurnComplete`, no `run()`, no turn-counter increment. The trace span is named `chat action` instead of `chat turn N`.
6+
7+
`onAction` can now return a `StreamTextResult`, `string`, or `UIMessage` to produce a model response from the action; returning `void` (the previous and now default) is side-effect-only.
8+
9+
**Migration**: if you previously had `run()` branching on `payload.trigger === "action"`, return your `streamText(...)` from `onAction` instead. If you persisted in `onTurnComplete`, do that work inside `onAction`. For any other state-only action, just remove your skip-the-model workaround — the default is now correct.
10+
11+
```ts
12+
// before
13+
onAction: async ({ action }) => {
14+
if (action.type === "regenerate") {
15+
chat.store.set({ skipModelCall: false });
16+
chat.history.slice(0, -1);
17+
}
18+
},
19+
run: async ({ messages, signal }) => {
20+
if (chat.store.get()?.skipModelCall) return;
21+
return streamText({ model, messages, abortSignal: signal });
22+
},
23+
24+
// after
25+
onAction: async ({ action, messages, signal }) => {
26+
if (action.type === "regenerate") {
27+
chat.history.slice(0, -1);
28+
return streamText({ model, messages, abortSignal: signal });
29+
}
30+
},
31+
run: async ({ messages, signal }) =>
32+
streamText({ model, messages, abortSignal: signal }),
33+
```

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

Lines changed: 102 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2679,6 +2679,17 @@ function isUIMessageStreamable(value: unknown): value is UIMessageStreamable {
26792679
);
26802680
}
26812681

2682+
let warnedMissingOnAction = false;
2683+
function warnMissingOnActionOnce() {
2684+
if (warnedMissingOnAction) return;
2685+
warnedMissingOnAction = true;
2686+
console.warn(
2687+
"[chat.agent] Received an action but no `onAction` handler is configured. " +
2688+
"The action is being ignored. Define `onAction` (and optionally `actionSchema`) on " +
2689+
"your agent to handle it."
2690+
);
2691+
}
2692+
26822693
function isAsyncIterable(value: unknown): value is AsyncIterable<unknown> {
26832694
return typeof value === "object" && value !== null && Symbol.asyncIterator in value;
26842695
}
@@ -3174,17 +3185,22 @@ export type ChatAgentOptions<
31743185
/**
31753186
* Called when the frontend sends a custom action via `transport.sendAction()`.
31763187
*
3177-
* Fires after message hydration (if set) but before `onTurnStart` and `run()`.
3178-
* Use `chat.history.*` to modify the conversation state — the LLM will respond
3179-
* to the modified state.
3188+
* Actions are not turns. They fire `hydrateMessages` (if configured) and
3189+
* `onAction` only — no `onTurnStart` / `prepareMessages` /
3190+
* `onBeforeTurnComplete` / `onTurnComplete`, no `run()`. Use
3191+
* `chat.history.*` inside `onAction` to mutate state.
3192+
*
3193+
* To produce a model response from an action, return a
3194+
* `StreamTextResult` (auto-piped), `string`, or `UIMessage`. Returning
3195+
* `void` or nothing is the side-effect-only default.
31803196
*/
31813197
onAction?: (
31823198
event: ActionEvent<
31833199
[TActionSchema] extends [TaskSchema] ? inferSchemaOut<TActionSchema> : unknown,
31843200
inferSchemaOut<TClientDataSchema>,
31853201
TUIMessage
31863202
>
3187-
) => Promise<void> | void;
3203+
) => Promise<unknown> | unknown;
31883204

31893205
/**
31903206
* The run function for the chat task.
@@ -4072,13 +4088,20 @@ function chatAgent<
40724088
) as inferSchemaOut<TClientDataSchema>;
40734089
const lastUserMessage = extractLastUserMessageText(uiMessages);
40744090

4091+
// Actions are not turns. They use a different span name
4092+
// and don't carry a turn.number. Branched on at `isAction`.
4093+
const isAction = currentWirePayload.trigger === "action";
4094+
const spanName = isAction ? "chat action" : `chat turn ${turn + 1}`;
4095+
40754096
const turnAttributes: Attributes = {
4076-
"turn.number": turn + 1,
4097+
...(isAction ? {} : { "turn.number": turn + 1 }),
40774098
"gen_ai.conversation.id": currentWirePayload.chatId,
40784099
"gen_ai.operation.name": "chat",
40794100
"chat.trigger": currentWirePayload.trigger,
4080-
[SemanticInternalAttributes.STYLE_ICON]: "tabler-message-chatbot",
4081-
[SemanticInternalAttributes.ENTITY_TYPE]: "chat-turn",
4101+
[SemanticInternalAttributes.STYLE_ICON]: isAction
4102+
? "tabler-bolt"
4103+
: "tabler-message-chatbot",
4104+
[SemanticInternalAttributes.ENTITY_TYPE]: isAction ? "chat-action" : "chat-turn",
40824105
};
40834106

40844107
if (lastUserMessage) {
@@ -4102,7 +4125,7 @@ function chatAgent<
41024125
}
41034126

41044127
const turnResult = await tracer.startActiveSpan(
4105-
`chat turn ${turn + 1}`,
4128+
spanName,
41064129
async (turnSpan) => {
41074130
// (errors are caught by the outer try/catch which writes an error chunk)
41084131
locals.set(chatPipeCountKey, 0);
@@ -4268,10 +4291,17 @@ function chatAgent<
42684291
const turnNewUIMessages: TUIMessage[] = [];
42694292

42704293
// ── Action handling ──────────────────────────────────────
4271-
// Actions arrive via the same chat-messages stream but with
4272-
// trigger === "action". They wake the agent, modify state
4273-
// (via onAction + chat.history), then fall through to run().
4274-
if (currentWirePayload.trigger === "action") {
4294+
// Actions arrive on the same input stream but with
4295+
// trigger === "action". They are NOT turns — only
4296+
// `hydrateMessages` and `onAction` fire. No turn lifecycle
4297+
// hooks (`onTurnStart` / `prepareMessages` /
4298+
// `onBeforeTurnComplete` / `onTurnComplete`) and no
4299+
// `run()` invocation. To produce a model response from
4300+
// an action, return a `StreamTextResult` (auto-piped),
4301+
// string, or UIMessage from `onAction`. Turn counter
4302+
// does not advance.
4303+
let actionStreamResult: unknown = undefined;
4304+
if (isAction) {
42754305
// Parse and validate the action payload
42764306
const parsedAction = parseAction
42774307
? await parseAction(currentWirePayload.action)
@@ -4298,7 +4328,6 @@ function chatAgent<
42984328
[SemanticInternalAttributes.STYLE_ICON]: "task-hook-onStart",
42994329
[SemanticInternalAttributes.COLLAPSED]: true,
43004330
"chat.id": currentWirePayload.chatId,
4301-
"chat.turn": turn + 1,
43024331
"chat.trigger": "action",
43034332
},
43044333
}
@@ -4308,12 +4337,13 @@ function chatAgent<
43084337
locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
43094338
}
43104339

4311-
// Fire onAction — handler uses chat.history.* to modify state
4340+
// Fire onAction — handler may mutate state via
4341+
// `chat.history.*` and / or return a model response.
43124342
if (onAction) {
4313-
await tracer.startActiveSpan(
4343+
actionStreamResult = await tracer.startActiveSpan(
43144344
"onAction()",
43154345
async () => {
4316-
await onAction({
4346+
return await onAction({
43174347
action: parsedAction as any,
43184348
chatId: currentWirePayload.chatId,
43194349
turn,
@@ -4327,10 +4357,10 @@ function chatAgent<
43274357
[SemanticInternalAttributes.STYLE_ICON]: "task-hook-onStart",
43284358
[SemanticInternalAttributes.COLLAPSED]: true,
43294359
"chat.id": currentWirePayload.chatId,
4330-
"chat.turn": turn + 1,
4331-
"chat.action": typeof parsedAction === "object" && parsedAction !== null
4332-
? JSON.stringify(parsedAction)
4333-
: String(parsedAction),
4360+
"chat.action":
4361+
typeof parsedAction === "object" && parsedAction !== null
4362+
? JSON.stringify(parsedAction)
4363+
: String(parsedAction),
43344364
},
43354365
}
43364366
);
@@ -4343,6 +4373,8 @@ function chatAgent<
43434373
accumulatedMessages = await toModelMessages(actionOverride);
43444374
locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
43454375
}
4376+
} else {
4377+
warnMissingOnActionOnce();
43464378
}
43474379
}
43484380

@@ -4537,6 +4569,54 @@ function chatAgent<
45374569

45384570
} // end if (trigger !== "action")
45394571

4572+
// ── Action result handling ──────────────────────────────
4573+
// For action turns, skip the turn machinery entirely.
4574+
// If `onAction` returned a stream / string / UIMessage,
4575+
// pipe it as the response. Either way, emit
4576+
// `trigger:turn-complete` and then fall through to the
4577+
// wait-for-next-message logic (shared with message turns).
4578+
// The turn counter is decremented so the next iteration
4579+
// sees the same `turn` value — actions don't count.
4580+
if (isAction) {
4581+
msgSub.off();
4582+
4583+
if (
4584+
(locals.get(chatPipeCountKey) ?? 0) === 0 &&
4585+
isUIMessageStreamable(actionStreamResult)
4586+
) {
4587+
try {
4588+
const resolvedOptions = resolveUIMessageStreamOptions();
4589+
const uiStream = (
4590+
actionStreamResult as UIMessageStreamable
4591+
).toUIMessageStream({
4592+
...resolvedOptions,
4593+
generateMessageId:
4594+
resolvedOptions.generateMessageId ?? generateMessageId,
4595+
});
4596+
await pipeChat(uiStream, {
4597+
signal: combinedSignal,
4598+
spanName: "stream response",
4599+
});
4600+
} catch (error) {
4601+
if (
4602+
error instanceof Error &&
4603+
error.name === "AbortError" &&
4604+
runSignal.aborted
4605+
) {
4606+
return "exit";
4607+
}
4608+
throw error;
4609+
}
4610+
}
4611+
4612+
await writeTurnCompleteChunk(currentWirePayload.chatId);
4613+
4614+
// Don't consume a turn iteration — actions aren't turns.
4615+
turn--;
4616+
}
4617+
4618+
if (!isAction) {
4619+
45404620
// Mint a scoped public access token once per turn, reused for
45414621
// onChatStart, onTurnStart, onTurnComplete, and the turn-complete chunk.
45424622
const currentRunId = ctx.run.id;
@@ -5235,6 +5315,8 @@ function chatAgent<
52355315
);
52365316
}
52375317

5318+
} // end if (!isAction)
5319+
52385320
// NOTE: We intentionally do NOT await deferred work from onTurnComplete here.
52395321
// Promises deferred in onTurnComplete (e.g. background self-review via
52405322
// chat.defer + chat.inject) run during the idle wait. If they complete

0 commit comments

Comments
 (0)