Skip to content

Commit f694134

Browse files
committed
feat(sdk): chat.agent — durable conversational task runtime
Adds chat.agent({...}), a session-aware task definition for AI chat agents. The runtime sits on top of the Sessions primitive and handles: - Delta-only wire: each /in/append carries at most one message; agent rebuilds prior history at run boot from an S3 snapshot + session.out replay tail. Awaited snapshot writes after every onTurnComplete keep the chain durable across idle suspends. - Lifecycle hooks: onChatStart, onTurnStart, onTurnComplete, onAction, onValidateMessages, hydrateMessages (short-circuits snapshot+replay if the customer owns history). - chat.history read primitives for HITL flows (getPendingToolCalls, getResolvedToolCalls, extractNewToolResults, findMessage, all/getChain). - chat.local — per-run typed data with Proxy access + dirty tracking. - chat.headStart — first-turn TTFC bridge via a customer HTTP handler. - oomMachine — one-shot OOM-retry on a larger machine; cutoff derived from latest trigger:turn-complete chunk on session.out. - Actions are no longer turns — onAction may mutate via chat.history.* and may return a StreamTextResult without bumping the turn counter. - gen_ai.conversation.id stamped on chat spans + metrics for telemetry. - Skills runtime (loadable per-agent and per-task) + agent skills bundling. Snapshot + replay integration tests in apps/webapp/test/. mockChatAgent test harness updated for the new wire. Includes the chat-agent, chat-agent-delta-wire-snapshots, chat-history-read-primitives, chat-head-start, chat-actions-no-turn, chat-session-attributes, agent-skills, and mock-chat-agent-test-harness changesets.
1 parent f240799 commit f694134

69 files changed

Lines changed: 22112 additions & 276 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.changeset/agent-skills.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"@trigger.dev/sdk": patch
3+
"@trigger.dev/core": patch
4+
"@trigger.dev/build": patch
5+
"trigger.dev": patch
6+
---
7+
8+
Add Agent Skills for `chat.agent`. Drop a folder with a `SKILL.md` and any helper scripts/references next to your task code, register it with `skills.define({ id, path })`, and the CLI bundles it into the deploy image automatically — no `trigger.config.ts` changes. The agent gets a one-line summary in its system prompt and discovers full instructions on demand via `loadSkill`, with `bash` and `readFile` tools scoped per-skill (path-traversal guards, output caps, abort-signal propagation).
9+
10+
```ts
11+
const pdfSkill = skills.define({ id: "pdf-extract", path: "./skills/pdf-extract" });
12+
13+
chat.skills.set([await pdfSkill.local()]);
14+
```
15+
16+
Built on the [AI SDK cookbook pattern](https://ai-sdk.dev/cookbook/guides/agent-skills) — portable across providers. SDK + CLI only for now; dashboard-editable `SKILL.md` text is on the roadmap.

.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+
```
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@trigger.dev/sdk": patch
3+
"@trigger.dev/core": patch
4+
---
5+
6+
`chat.agent` wire is now delta-only — clients ship at most one new message per `.in/append` instead of the full `UIMessage[]` history. The agent rebuilds prior history at run boot from a JSON snapshot in object storage plus a `wait=0` replay of the `session.out` tail. Long chats stop hitting the 512 KiB body cap on `/realtime/v1/sessions/{id}/in/append`. Snapshot writes happen after every `onTurnComplete`, awaited so they survive idle suspend; reads happen only at run boot. Registering a `hydrateMessages` hook short-circuits both the snapshot read/write and the replay — the customer is the source of truth for history.
7+
8+
Custom transports that constructed `ChatTaskWirePayload` directly need to drop the `messages: UIMessage[]` field and use `message?: UIMessage` (singular). Built-in transports (`TriggerChatTransport`, `AgentChat`) handle the change below the customer-facing surface — most apps need no changes. Configure object-store env vars (`OBJECT_STORE_*`) on your webapp deployment if you haven't already; without an object store and without `hydrateMessages`, conversations don't survive run boundaries.

.changeset/chat-agent.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
"@trigger.dev/sdk": minor
3+
"@trigger.dev/core": patch
4+
---
5+
6+
Run AI chats as durable Trigger.dev tasks. Define the agent in one function, wire `useChat` to it from React, and the conversation survives page refreshes, network blips, and process restarts — with built-in support for tools, HITL approvals, multi-turn state, and stop-mid-stream cancellation.
7+
8+
```ts
9+
import { chat } from "@trigger.dev/sdk/ai";
10+
import { streamText } from "ai";
11+
import { openai } from "@ai-sdk/openai";
12+
13+
export const myChat = chat.agent({
14+
id: "my-chat",
15+
run: async ({ messages, signal }) =>
16+
streamText({ model: openai("gpt-4o"), messages, abortSignal: signal }),
17+
});
18+
```
19+
20+
```tsx
21+
import { useChat } from "@ai-sdk/react";
22+
import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";
23+
24+
const transport = useTriggerChatTransport({ task: "my-chat", accessToken });
25+
const { messages, sendMessage } = useChat({ transport });
26+
```
27+
28+
Lifecycle hooks (`onPreload`, `onTurnStart`, `onTurnComplete`, etc.) cover the common needs around persistence, validation, and post-turn work. `chat.store` gives you a typed shared-data slot the agent and client both read and write. `chat.endRun()` exits cleanly when the agent decides it's done. The transport's `watch` mode lets a dashboard tab observe a run without driving it.
29+
30+
Drops the pre-Sessions chat stream constants (`CHAT_STREAM_KEY`, `CHAT_MESSAGES_STREAM_ID`, `CHAT_STOP_STREAM_ID`) — migrate to `sessions.open(id).out` / `.in`.

.changeset/chat-head-start.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
"@trigger.dev/sdk": minor
3+
---
4+
5+
Add `chat.headStart` — an opt-in fast-path that runs the first turn's `streamText` step in your warm Next.js / Hono / Workers / Express handler while the trigger agent run boots in parallel. Cold-start TTFC drops by ~50% on the first message; the agent owns step 2+ (tool execution, persistence, hooks) so heavy deps stay where they belong.
6+
7+
```ts
8+
// app/api/chat/route.ts (Next.js / any Web Fetch framework)
9+
import { chat } from "@trigger.dev/sdk/chat-server";
10+
import { streamText } from "ai";
11+
import { openai } from "@ai-sdk/openai";
12+
import { headStartTools } from "@/lib/chat-tools-schemas"; // schema-only
13+
14+
export const POST = chat.headStart({
15+
agentId: "ai-chat",
16+
run: async ({ chat: chatHelper }) =>
17+
streamText({
18+
...chatHelper.toStreamTextOptions({ tools: headStartTools }),
19+
model: openai("gpt-4o-mini"),
20+
system: "You are a helpful AI assistant.",
21+
}),
22+
});
23+
```
24+
25+
```tsx
26+
// browser — opt in by pointing the transport at your handler
27+
const transport = useTriggerChatTransport({
28+
task: "ai-chat",
29+
accessToken,
30+
headStart: "/api/chat", // first-turn-only; turn 2+ bypasses the endpoint
31+
});
32+
```
33+
34+
For Node-only frameworks (Express, Fastify, Koa, raw `node:http`) use `chat.toNodeListener(handler)` to bridge the Web Fetch handler to `(req, res)`. Adds a new `@trigger.dev/sdk/chat-server` subpath; bundle stays Web Fetch–only with no `node:*` imports.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
"@trigger.dev/sdk": minor
3+
---
4+
5+
Add read primitives to `chat.history` for HITL flows: `getPendingToolCalls()`, `getResolvedToolCalls()`, `extractNewToolResults(message)`, `getChain()`, and `findMessage(messageId)`. These lift the accumulator-walking logic that customers building human-in-the-loop tools were re-implementing into the SDK.
6+
7+
Use `getPendingToolCalls()` to gate fresh user turns while a tool call is awaiting an answer. Use `extractNewToolResults(message)` to dedup tool results when persisting to your own store — the helper returns only the parts whose `toolCallId` is not already resolved on the chain.
8+
9+
```ts
10+
const pending = chat.history.getPendingToolCalls();
11+
if (pending.length > 0) {
12+
// an addToolOutput is expected before a new user message
13+
}
14+
15+
onTurnComplete: async ({ responseMessage }) => {
16+
const newResults = chat.history.extractNewToolResults(responseMessage);
17+
for (const r of newResults) {
18+
await db.toolResults.upsert({ id: r.toolCallId, output: r.output, errorText: r.errorText });
19+
}
20+
};
21+
```
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@trigger.dev/sdk": patch
3+
"@trigger.dev/core": patch
4+
---
5+
6+
Stamp `gen_ai.conversation.id` (the chat id) on every span and metric emitted from inside a `chat.task` or `chat.agent` run. Lets you filter dashboard spans, runs, and metrics by the chat conversation that produced them — independent of the run boundary, so multi-run chats correlate cleanly. No code changes required on the user side.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@trigger.dev/sdk": patch
3+
"@trigger.dev/core": patch
4+
---
5+
6+
Unit-test `chat.agent` definitions offline with `mockChatAgent` from `@trigger.dev/sdk/ai/test`. Drives a real agent's turn loop in-process — no network, no task runtime — so you can send messages, actions, and stop signals via driver methods, inspect captured output chunks, and verify hooks fire. Pairs with `MockLanguageModelV3` from `ai/test` for model mocking. `setupLocals` lets you pre-seed `locals` (DB clients, service stubs) before `run()` starts.
7+
8+
The broader `runInMockTaskContext` harness it's built on lives at `@trigger.dev/core/v3/test` — useful for unit-testing any task code, not just chat.

apps/webapp/app/components/code/AIQueryInput.tsx

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,15 @@
11
import { CheckIcon, PencilSquareIcon, PlusIcon, XMarkIcon } from "@heroicons/react/20/solid";
22
import { AnimatePresence, motion } from "framer-motion";
3-
import { Suspense, lazy, useCallback, useEffect, useRef, useState } from "react";
3+
import { Suspense, useCallback, useEffect, useRef, useState } from "react";
44
import { Button } from "~/components/primitives/Buttons";
55
import { Spinner } from "~/components/primitives/Spinner";
6+
import { StreamdownRenderer } from "~/components/code/StreamdownRenderer";
67
import { useEnvironment } from "~/hooks/useEnvironment";
78
import { useOrganization } from "~/hooks/useOrganizations";
89
import { useProject } from "~/hooks/useProject";
910
import type { AITimeFilter } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/types";
1011
import { cn } from "~/utils/cn";
1112

12-
// Lazy load streamdown components to avoid SSR issues
13-
const StreamdownRenderer = lazy(() =>
14-
import("streamdown").then((mod) => ({
15-
default: ({ children, isAnimating }: { children: string; isAnimating: boolean }) => (
16-
<mod.ShikiThemeContext.Provider value={["one-dark-pro", "one-dark-pro"]}>
17-
<mod.Streamdown isAnimating={isAnimating}>{children}</mod.Streamdown>
18-
</mod.ShikiThemeContext.Provider>
19-
),
20-
}))
21-
);
22-
2313
type StreamEventType =
2414
| { type: "thinking"; content: string }
2515
| { type: "tool_call"; tool: string; args: unknown }
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { lazy } from "react";
2+
import type { CodeHighlighterPlugin } from "streamdown";
3+
4+
export const StreamdownRenderer = lazy(() =>
5+
Promise.all([import("streamdown"), import("@streamdown/code"), import("./shikiTheme")]).then(
6+
([{ Streamdown }, { createCodePlugin }, { triggerDarkTheme }]) => {
7+
// Type assertion needed: @streamdown/code and streamdown resolve different shiki
8+
// versions under pnpm, causing structurally-identical CodeHighlighterPlugin types
9+
// to be considered incompatible (different BundledLanguage string unions).
10+
const codePlugin = createCodePlugin({
11+
themes: [triggerDarkTheme, triggerDarkTheme],
12+
}) as unknown as CodeHighlighterPlugin;
13+
14+
return {
15+
default: ({
16+
children,
17+
isAnimating = false,
18+
}: {
19+
children: string;
20+
isAnimating?: boolean;
21+
}) => (
22+
<Streamdown isAnimating={isAnimating} plugins={{ code: codePlugin }}>
23+
{children}
24+
</Streamdown>
25+
),
26+
};
27+
}
28+
)
29+
);

0 commit comments

Comments
 (0)