Skip to content
Open
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
6 changes: 6 additions & 0 deletions .changeset/tidy-mailboxes-wait.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@trigger.dev/core": patch
"@trigger.dev/sdk": patch
---

Custom agent loops can now inspect pending chat input without consuming it and consume one mailbox record at a time with `chat.messages.hasPending()` and `chat.messages.next()`. Mailbox records include stable identifiers for tracing and redelivery.
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { json } from "@remix-run/server-runtime";
import {
CreateSessionStreamWaitpointRequestBody,
SESSION_STREAM_WAITPOINT_RECORD_CONTENT_TYPE,
serializeSessionStreamWaitpointRecord,
type CreateSessionStreamWaitpointResponseBody,
} from "@trigger.dev/core/v3";
import { WaitpointId } from "@trigger.dev/core/v3/isomorphic";
Expand Down Expand Up @@ -125,7 +127,8 @@ const { action, loader } = createActionApiRoute(
addressingKey,
body.io,
result.waitpoint.id,
ttlMs && ttlMs > 0 ? ttlMs : undefined
ttlMs && ttlMs > 0 ? ttlMs : undefined,
body.responseFormat
);

// Race-check. If a record landed on the channel before this
Expand Down Expand Up @@ -155,8 +158,14 @@ const { action, loader } = createActionApiRoute(
await engine.completeWaitpoint({
id: result.waitpoint.id,
output: {
value: record.data,
type: "application/json",
value:
body.responseFormat === "record-v1"
? serializeSessionStreamWaitpointRecord(record.data, record.seqNum)
: record.data,
type:
body.responseFormat === "record-v1"
? SESSION_STREAM_WAITPOINT_RECORD_CONTENT_TYPE
: "application/json",
isError: false,
},
});
Expand Down
17 changes: 7 additions & 10 deletions apps/webapp/app/routes/realtime.v1.sessions.$session.$io.append.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
claimSessionStreamPart,
drainSessionStreamWaitpoints,
releaseSessionStreamPart,
sessionStreamWaitpointOutput,
} from "~/services/sessionStreamWaitpointCache.server";
import { anyResource, createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import { engine } from "~/v3/runEngine.server";
Expand Down Expand Up @@ -201,7 +202,7 @@ const { action, loader } = createActionApiRoute(
// keyed on the canonical addressing key the agent registered with via
// `sessions.open(...).in.wait()`, so writers and readers converge
// regardless of which URL form they used.
const [drainError, waitpointIds] = await tryCatch(
const [drainError, waitpoints] = await tryCatch(
drainSessionStreamWaitpoints(authentication.environment.id, addressingKey, params.io)
);
if (drainError) {
Expand All @@ -210,24 +211,20 @@ const { action, loader } = createActionApiRoute(
io: params.io,
error: drainError,
});
} else if (waitpointIds && waitpointIds.length > 0) {
} else if (waitpoints && waitpoints.length > 0) {
await Promise.all(
waitpointIds.map(async (waitpointId) => {
waitpoints.map(async (waitpoint) => {
const [completeError] = await tryCatch(
engine.completeWaitpoint({
id: waitpointId,
output: {
value: part,
type: "application/json",
isError: false,
},
id: waitpoint.id,
output: sessionStreamWaitpointOutput(waitpoint, part, appendSeq),
})
);
if (completeError) {
logger.error("Failed to complete session stream waitpoint", {
addressingKey,
io: params.io,
waitpointId,
waitpointId: waitpoint.id,
error: completeError,
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ import {
resolveSessionByIdOrExternalId,
} from "~/services/realtime/sessions.server";
import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server";
import { drainSessionStreamWaitpoints } from "~/services/sessionStreamWaitpointCache.server";
import {
drainSessionStreamWaitpoints,
sessionStreamWaitpointOutput,
} from "~/services/sessionStreamWaitpointCache.server";
import { requireUserId } from "~/services/session.server";
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
import { engine } from "~/v3/runEngine.server";
Expand Down Expand Up @@ -114,7 +117,7 @@ export async function action({ request, params }: ActionFunctionArgs) {

// Drain any waitpoints registered for this channel — same as the
// public append. Best-effort; failure doesn't fail the append.
const [drainError, waitpointIds] = await tryCatch(
const [drainError, waitpoints] = await tryCatch(
drainSessionStreamWaitpoints(environment.id, addressingKey, io)
);
if (drainError) {
Expand All @@ -123,24 +126,20 @@ export async function action({ request, params }: ActionFunctionArgs) {
io,
error: drainError,
});
} else if (waitpointIds && waitpointIds.length > 0) {
} else if (waitpoints && waitpoints.length > 0) {
await Promise.all(
waitpointIds.map(async (waitpointId) => {
waitpoints.map(async (waitpoint) => {
const [completeError] = await tryCatch(
engine.completeWaitpoint({
id: waitpointId,
output: {
value: part,
type: "application/json",
isError: false,
},
id: waitpoint.id,
output: sessionStreamWaitpointOutput(waitpoint, part, appendSeq ?? undefined),
})
);
if (completeError) {
logger.error("Failed to complete session stream waitpoint (playground)", {
addressingKey,
io,
waitpointId,
waitpointId: waitpoint.id,
error: completeError,
});
}
Expand Down
79 changes: 74 additions & 5 deletions apps/webapp/app/services/sessionStreamWaitpointCache.server.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { Redis } from "ioredis";
import { defaultReconnectOnError } from "@internal/redis";
import {
SESSION_STREAM_WAITPOINT_RECORD_CONTENT_TYPE,
serializeSessionStreamWaitpointRecord,
} from "@trigger.dev/core/v3";
import { env } from "~/env.server";
import { singleton } from "~/utils/singleton";
import { logger } from "./logger.server";
Expand All @@ -13,12 +17,35 @@ import { logger } from "./logger.server";
// is shared — without it, two environments using the same externalId
// would drain each other's waitpoints.
const KEY_PREFIX = "ssw:";
const FORMAT_KEY_PREFIX = "sswf:";
const DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days

export type SessionStreamWaitpoint = {
id: string;
responseFormat?: "record-v1";
};

export function sessionStreamWaitpointOutput(
waitpoint: SessionStreamWaitpoint,
data: string,
seqNum: number | undefined
): { value: string; type: string; isError: false } {
const hasRecordEnvelope = waitpoint.responseFormat === "record-v1" && seqNum !== undefined;
return {
value: hasRecordEnvelope ? serializeSessionStreamWaitpointRecord(data, seqNum) : data,
type: hasRecordEnvelope ? SESSION_STREAM_WAITPOINT_RECORD_CONTENT_TYPE : "application/json",
isError: false,
};
}

function buildKey(environmentId: string, addressingKey: string, io: "out" | "in"): string {
return `${KEY_PREFIX}${environmentId}:${addressingKey}:${io}`;
}

function buildFormatKey(waitpointId: string): string {
return `${FORMAT_KEY_PREFIX}${waitpointId}`;
}

// Pre-env-scoping key format, drained for one release so waitpoints from the
// previous deploy still wake. Removable once this has been live > turn timeout.
function buildLegacyKey(addressingKey: string, io: "out" | "in"): string {
Expand Down Expand Up @@ -81,13 +108,25 @@ export async function addSessionStreamWaitpoint(
addressingKey: string,
io: "out" | "in",
waitpointId: string,
ttlMs?: number
ttlMs?: number,
responseFormat?: "record-v1"
): Promise<void> {
if (!redis) return;

try {
const key = buildKey(environmentId, addressingKey, io);
await redis.eval(ADD_WAITPOINT_SCRIPT, 1, key, waitpointId, String(ttlMs ?? DEFAULT_TTL_MS));
const effectiveTtlMs = ttlMs ?? DEFAULT_TTL_MS;

// Keep the set member as the plain waitpoint id so an older append
// instance can still drain it during a rolling deploy. New instances read
// the optional response format from this separate, TTL-bound key.
if (responseFormat) {
await redis.set(buildFormatKey(waitpointId), responseFormat, "PX", effectiveTtlMs);
} else {
await redis.del(buildFormatKey(waitpointId));
}

await redis.eval(ADD_WAITPOINT_SCRIPT, 1, key, waitpointId, String(effectiveTtlMs));
} catch (error) {
logger.error("Failed to set session stream waitpoint cache", {
environmentId,
Expand All @@ -107,7 +146,7 @@ export async function drainSessionStreamWaitpoints(
environmentId: string,
addressingKey: string,
io: "out" | "in"
): Promise<string[]> {
): Promise<SessionStreamWaitpoint[]> {
if (!redis) return [];

try {
Expand All @@ -129,7 +168,34 @@ export async function drainSessionStreamWaitpoints(
if (err || !Array.isArray(members)) continue;
for (const m of members as string[]) ids.add(m);
}
return [...ids];
const waitpointIds = [...ids];
if (waitpointIds.length === 0) return [];

let formatResults: Awaited<ReturnType<typeof pipeline.exec>> | null = null;
try {
const formatPipeline = redis.multi();
for (const waitpointId of waitpointIds) {
formatPipeline.get(buildFormatKey(waitpointId));
formatPipeline.del(buildFormatKey(waitpointId));
}
formatResults = await formatPipeline.exec();
} catch (error) {
// The waitpoint ids were already drained. Complete them with raw data
// rather than losing the wake-up because optional metadata was unavailable.
logger.error("Failed to read session stream waitpoint response formats", {
environmentId,
addressingKey,
io,
error,
});
}

return waitpointIds.map((id, index) => {
const formatEntry = formatResults?.[index * 2];
const responseFormat =
formatEntry && !formatEntry[0] && formatEntry[1] === "record-v1" ? "record-v1" : undefined;
return { id, responseFormat };
});
} catch (error) {
logger.error("Failed to drain session stream waitpoint cache", {
environmentId,
Expand Down Expand Up @@ -240,7 +306,10 @@ export async function removeSessionStreamWaitpoint(

try {
const key = buildKey(environmentId, addressingKey, io);
await redis.srem(key, waitpointId);
const pipeline = redis.multi();
pipeline.srem(key, waitpointId);
pipeline.del(buildFormatKey(waitpointId));
await pipeline.exec();
} catch (error) {
logger.error("Failed to remove session stream waitpoint cache entry", {
environmentId,
Expand Down
15 changes: 9 additions & 6 deletions apps/webapp/app/v3/webhookEngine.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
claimSessionStreamPart,
drainSessionStreamWaitpoints,
releaseSessionStreamPart,
sessionStreamWaitpointOutput,
} from "~/services/sessionStreamWaitpointCache.server";
import { getSecretStore } from "~/services/secrets/secretStore.server";
import { singleton } from "~/utils/singleton";
Expand Down Expand Up @@ -229,10 +230,12 @@ function createWebhookEngine() {
"in",
deliveryId
);
let appendSeq: number | undefined;
if (wonClaim) {
const [appendError] = await tryCatch(
const [appendError, seqNum] = await tryCatch(
realtimeStream.appendPartToSessionStream(part, deliveryId, addressingKey, "in")
);
appendSeq = seqNum ?? undefined;
if (appendError) {
// Nothing landed — release the claim so a retry re-appends the same id.
await releaseSessionStreamPart(environment.id, addressingKey, "in", deliveryId);
Expand All @@ -245,21 +248,21 @@ function createWebhookEngine() {
}

// Wake any `.in` waitpoints the run registered (best-effort; the record is durable in S2).
const [drainError, waitpointIds] = await tryCatch(
const [drainError, waitpoints] = await tryCatch(
drainSessionStreamWaitpoints(environment.id, addressingKey, "in")
);
if (drainError) {
logger.error("deliverToSession: failed to drain session waitpoints", {
externalId,
error: drainError,
});
} else if (waitpointIds && waitpointIds.length > 0) {
} else if (waitpoints && waitpoints.length > 0) {
await Promise.all(
waitpointIds.map((waitpointId) =>
waitpoints.map((waitpoint) =>
tryCatch(
runEngine.completeWaitpoint({
id: waitpointId,
output: { value: part, type: "application/json", isError: false },
id: waitpoint.id,
output: sessionStreamWaitpointOutput(waitpoint, part, appendSeq),
})
)
)
Expand Down
48 changes: 47 additions & 1 deletion docs/ai-chat/custom-agents.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -213,14 +213,60 @@ For full control, skip `createSession` and compose the primitives directly:

| Primitive | Description |
| ------------------------------- | -------------------------------------------------------------------------------------------- |
| `chat.messages` | Input stream for incoming messages — use `.waitWithIdleTimeout()` to wait for the next turn |
| `chat.messages` | Mailbox for incoming messages — inspect buffered input, consume one record, or suspend until the next turn |
| `chat.createStopSignal()` | Create a managed stop signal wired to the stop input stream |
| `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 |

### `chat.messages` mailbox

`chat.messages` exposes the incoming message mailbox for hand-rolled loops:

| Method | Behavior |
| --- | --- |
| `peek()` | Return the buffer head when it is a message, without consuming it; otherwise return `undefined` |
| `hasPending()` | Resolve `true` when the buffer head is a message; does not consume it |
| `next({ timeoutInSeconds? })` | Consume exactly one message record in channel order, or resolve `undefined` when the optional timeout elapses |
| `on(handler)` | Consume messages as they arrive and invoke the handler |
| `waitWithIdleTimeout(options)` | Wait warm, then suspend the run until the next message arrives |

`hasPending()` checks whether the local, already-delivered buffer head is a
message that `next()` can consume immediately. It does not query the remote
Session channel or start a subscription. Use `waitWithIdleTimeout()` when the
loop needs to idle until future input arrives.

`next({ timeoutInSeconds: 0 })` is also a local, non-blocking read. Call
`next()` without a timeout, or with a positive timeout, to subscribe for future
input.

`next()` returns a readonly record envelope:

```ts
const record = await chat.messages.next({ timeoutInSeconds: 5 });
if (record) {
console.log(record.id, record.seqNum);
currentPayload = record.payload;
}
```

- `id` is the append's stable idempotency key.
- `seqNum` is the monotonic sequence on this Session's `.in` channel.
- `payload` is the existing `ChatTaskWirePayload` delivered by the other mailbox methods.

Both identifiers remain the same if the record is delivered again after a
reconnect. Each `next()` call commits only the record it returns, so a loop that
owns its own turn sequencing never advances past input it has not taken. By
contrast, `on()` commits a record as soon as it dispatches the handler; avoid
mixing `on()` and `next()` when a single loop owns mailbox consumption.

The Session `.in` channel also carries control records such as handovers. If one
comes before a message, `hasPending()` stays `false` and `next()` leaves the
control record for its own consumer. After that record is handled, the message
becomes pending.

A complete loop:

```ts trigger/my-chat-raw.ts
Expand Down
2 changes: 1 addition & 1 deletion docs/ai-chat/reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -506,7 +506,7 @@ All methods available on the `chat` object from `@trigger.dev/sdk/ai`.
| `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.messages` | Incoming message mailbox; supports non-consuming `.peek()` / `.hasPending()`, single-record `.next()`, `.on()`, and suspend-aware `.waitWithIdleTimeout()` |
| `chat.local<T>({ id })` | Create a per-run typed local (see [`chat.local`](/ai-chat/chat-local)) |
| `chat.createStartSessionAction(taskId, options?)` | Returns a server action that creates a chat Session + triggers the first run + returns a session-scoped PAT. Idempotent on `(env, externalId)`. |
| `chat.waitForHandover(options)` | Wait for a [`chat.headStart`](/ai-chat/fast-starts#handover-with-custom-agents) handover signal in a custom loop. Returns the signal or `null`. `chat.MessageAccumulator` wraps this as `consumeHandover()` / `applyHandover()` |
Expand Down
Loading