Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .changeset/chat-custom-agent-capture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
"@trigger.dev/sdk": patch
---
Comment thread
matt-aitken marked this conversation as resolved.

Custom chat agent loops get two ergonomic wins for owning the turn loop.

`chat.writeTurnComplete()` now returns the turn boundary's resume cursors (`lastEventId` for the output stream and `sessionInEventId` for the input stream), so you can persist them straight from the task instead of round-tripping them back from the client.

```ts
const { lastEventId, sessionInEventId } = await chat.writeTurnComplete();
await db.chats.update(chatId, { lastEventId, sessionInEventId });
```

`chat.pipeAndCapture()` no longer throws when a stream is stopped or fails. It now returns a `PipeAndCaptureResult` whose `message` holds any partial output captured before the stop or failure, alongside a typed `status` (`"complete" | "aborted" | "error"`) and, on failure, the `error`. Read the message off the result:

```ts
const { message, status, error } = await chat.pipeAndCapture(result, { signal });
if (message) conversation.addResponse(message);
if (status === "error") logger.error("turn failed", { error });
```

Note: `pipeAndCapture` previously resolved to `UIMessage | undefined`. Update call sites to read `.message` from the returned result.
4 changes: 2 additions & 2 deletions docs/ai-chat/changelog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,10 @@ if (isFinal) {
const result = streamText({ model, messages: conversation.modelMessages, tools });
// Pass originalMessages so the handed-over tool round merges into the
// step-1 assistant instead of starting a new message.
const response = await chat.pipeAndCapture(result, {
const { message } = await chat.pipeAndCapture(result, {
originalMessages: conversation.uiMessages,
});
if (response) await conversation.addResponse(response);
if (message) await conversation.addResponse(message);
}
```

Expand Down
4 changes: 2 additions & 2 deletions docs/ai-chat/compaction.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -365,8 +365,8 @@ for (let turn = 0; turn < 100; turn++) {
stopWhen: stepCountIs(15),
});

const response = await chat.pipeAndCapture(result);
if (response) await conversation.addResponse(response);
const { message } = await chat.pipeAndCapture(result);
if (message) await conversation.addResponse(message);

// Outer-loop compaction
const usage = await result.totalUsage;
Expand Down
35 changes: 15 additions & 20 deletions docs/ai-chat/custom-agents.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -160,11 +160,11 @@ for await (const turn of session) {
});

// Manual: pipe and capture separately
const response = await chat.pipeAndCapture(result, { signal: turn.signal });
const { message } = await chat.pipeAndCapture(result, { signal: turn.signal });

if (response) {
if (message) {
// Custom processing before accumulating
await turn.addResponse(response);
await turn.addResponse(message);
}

// Custom persistence, analytics, etc.
Expand Down Expand Up @@ -215,8 +215,8 @@ For full control, skip `createSession` and compose the primitives directly:
| ------------------------------- | -------------------------------------------------------------------------------------------- |
| `chat.messages` | Input stream for incoming messages — use `.waitWithIdleTimeout()` to wait for the next turn |
| `chat.createStopSignal()` | Create a managed stop signal wired to the stop input stream |
| `chat.pipeAndCapture(result)` | Pipe a `StreamTextResult` to the chat stream and capture the response |
| `chat.writeTurnComplete()` | Signal the frontend that the current turn is complete |
| `chat.pipeAndCapture(result)` | Pipe a stream and capture the response; returns `{ message, status, error }` |
| `chat.writeTurnComplete()` | Signal turn complete; returns `{ lastEventId, sessionInEventId }` resume cursors |
| `chat.MessageAccumulator` | Accumulates conversation messages across turns |
| `chat.pipe(stream)` | Pipe a stream to the frontend (no response capture) |
| `chat.cleanupAbortedParts(msg)` | Clean up incomplete parts from a stopped response |
Expand Down Expand Up @@ -285,21 +285,16 @@ export const myChat = chat.customAgent({
stopWhen: stepCountIs(15),
});

let response;
try {
response = await chat.pipeAndCapture(result, { signal: combinedSignal });
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
if (runSignal.aborted) break;
// Stop — fall through to accumulate partial
} else {
throw error;
}
}
const { message, status, error } = await chat.pipeAndCapture(result, {
signal: combinedSignal,
});
// pipeAndCapture never throws: a user stop returns status "aborted" with
// the partial message, and a failure returns status "error" with `error`.
if (status === "error") throw error;

if (response) {
if (message) {
const cleaned =
stop.signal.aborted && !runSignal.aborted ? chat.cleanupAbortedParts(response) : response;
stop.signal.aborted && !runSignal.aborted ? chat.cleanupAbortedParts(message) : message;
await conversation.addResponse(cleaned);
}

Expand Down Expand Up @@ -346,8 +341,8 @@ const messages = await conversation.addIncoming(
);

// After piping, add the response
const response = await chat.pipeAndCapture(result);
if (response) await conversation.addResponse(response);
const { message } = await chat.pipeAndCapture(result);
if (message) await conversation.addResponse(message);

// Access accumulated messages for persistence
conversation.uiMessages; // UIMessage[]
Expand Down
4 changes: 2 additions & 2 deletions docs/ai-chat/fast-starts.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -589,10 +589,10 @@ if (turn === 0 && payload.trigger === "handover-prepare") {
messages: conversation.modelMessages,
stopWhen: stepCountIs(10),
});
const response = await chat.pipeAndCapture(result, {
const { message } = await chat.pipeAndCapture(result, {
originalMessages: conversation.uiMessages,
});
if (response) await conversation.addResponse(response);
if (message) await conversation.addResponse(message);
}
await chat.writeTurnComplete(); // on isFinal the warm partial is already the response
return;
Expand Down
8 changes: 6 additions & 2 deletions docs/ai-chat/pending-messages.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -179,10 +179,14 @@ for (let turn = 0; turn < 100; turn++) {
stopWhen: stepCountIs(15),
});

const response = await chat.pipeAndCapture(result);
const { message, status, error } = await chat.pipeAndCapture(result);
sub.off();

if (response) await conversation.addResponse(response);
// Keep any partial output, then branch on the outcome: pipeAndCapture no
// longer throws, so rethrow a real failure and don't complete a failed turn.
if (message) await conversation.addResponse(message);
if (status === "error") throw error;

await chat.writeTurnComplete();
}
```
Expand Down
4 changes: 2 additions & 2 deletions docs/ai-chat/reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -503,8 +503,8 @@ All methods available on the `chat` object from `@trigger.dev/sdk/ai`.
| `chat.agent(options)` | Create a chat agent |
| `chat.createSession(payload, options)` | Create an async iterator for chat turns |
| `chat.pipe(source, options?)` | Pipe a stream to the frontend (from anywhere inside a task) |
| `chat.pipeAndCapture(source, options?)` | Pipe and capture the response `UIMessage` |
| `chat.writeTurnComplete(options?)` | Signal the frontend that the current turn is complete |
| `chat.pipeAndCapture(source, options?)` | Pipe and capture the response; returns `{ message, status, error }` |
| `chat.writeTurnComplete(options?)` | Signal turn complete; returns `{ lastEventId, sessionInEventId }` resume cursors |
| `chat.createStopSignal()` | Create a managed stop signal wired to the stop input stream |
| `chat.messages` | Input stream for incoming messages — use `.waitWithIdleTimeout()` |
| `chat.local<T>({ id })` | Create a per-run typed local (see [`chat.local`](/ai-chat/chat-local)) |
Expand Down
Loading
Loading