chat.task -> chat.agent

plus playground support, including playground conversations, and a new
agent list
This commit is contained in:
Eric Allam
2026-03-30 21:57:31 +01:00
parent 84d430edcd
commit ed40628946
55 changed files with 4308 additions and 1117 deletions
@@ -240,6 +240,19 @@ export function BulkActionFilterSummary({
/>
);
}
case "sources": {
const values = Array.isArray(value) ? value : [`${value}`];
return (
<AppliedFilter
variant="minimal/medium"
key={key}
label={filterTitle(key)}
icon={filterIcon(key)}
value={appliedSummary(values)}
removable={false}
/>
);
}
default: {
assertNever(typedKey);
}
@@ -10,6 +10,7 @@ import {
ClockIcon,
Cog8ToothIcon,
CogIcon,
CpuChipIcon,
CubeIcon,
ExclamationTriangleIcon,
FolderIcon,
@@ -69,7 +70,9 @@ import {
organizationTeamPath,
queryPath,
regionsPath,
v3AgentsPath,
v3ApiKeysPath,
v3PlaygroundPath,
v3BatchesPath,
v3BillingPath,
v3BuiltInDashboardPath,
@@ -467,6 +470,22 @@ export function SideMenu({
initialCollapsed={getSectionCollapsed(user.dashboardPreferences.sideMenu, "ai")}
onCollapseToggle={handleSectionToggle("ai")}
>
<SideMenuItem
name="Agents"
icon={CpuChipIcon}
activeIconColor="text-indigo-500"
inactiveIconColor="text-indigo-500"
to={v3AgentsPath(organization, project, environment)}
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Playground"
icon={BeakerIcon}
activeIconColor="text-indigo-400"
inactiveIconColor="text-indigo-400"
to={v3PlaygroundPath(organization, project, environment)}
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Prompts"
icon={AIPromptsIcon}
@@ -2,6 +2,7 @@ import * as Ariakit from "@ariakit/react";
import {
CalendarIcon,
ClockIcon,
CpuChipIcon,
FingerPrintIcon,
RectangleStackIcon,
Squares2X2Icon,
@@ -184,6 +185,9 @@ export const TaskRunListSearchFilters = z.object({
`Machine presets to filter by (${machines.join(", ")})`
),
errorId: z.string().optional().describe("Error ID to filter runs by (e.g. error_abc123)"),
sources: StringOrStringArray.describe(
"Task trigger sources to filter by (STANDARD, SCHEDULED, AGENT)"
),
});
export type TaskRunListSearchFilters = z.infer<typeof TaskRunListSearchFilters>;
@@ -225,6 +229,8 @@ export function filterTitle(filterKey: string) {
return "Version";
case "errorId":
return "Error ID";
case "sources":
return "Source";
default:
return filterKey;
}
@@ -265,6 +271,8 @@ export function filterIcon(filterKey: string): ReactNode | undefined {
return <IconRotateClockwise2 className="size-4" />;
case "errorId":
return <IconBugFilled className="size-4" />;
case "sources":
return <CpuChipIcon className="size-4" />;
default:
return undefined;
}
@@ -312,6 +320,10 @@ export function getRunFiltersFromSearchParams(
? searchParams.getAll("versions")
: undefined,
errorId: searchParams.get("errorId") ?? undefined,
sources:
searchParams.getAll("sources").filter((v) => v.length > 0).length > 0
? searchParams.getAll("sources")
: undefined,
};
const parsed = TaskRunListSearchFilters.safeParse(params);
@@ -353,7 +365,8 @@ export function RunsFilters(props: RunFiltersProps) {
searchParams.has("queues") ||
searchParams.has("machines") ||
searchParams.has("versions") ||
searchParams.has("errorId");
searchParams.has("errorId") ||
searchParams.has("sources");
return (
<div className="flex flex-row flex-wrap items-center gap-1">
@@ -390,6 +403,7 @@ const filterTypes = [
{ name: "schedule", title: "Schedule ID", icon: <ClockIcon className="size-4" /> },
{ name: "bulk", title: "Bulk action", icon: <ListCheckedIcon className="size-4" /> },
{ name: "error", title: "Error ID", icon: <IconBugFilled className="size-4" /> },
{ name: "source", title: "Source", icon: <CpuChipIcon className="size-4" /> },
] as const;
type FilterType = (typeof filterTypes)[number]["name"];
@@ -445,6 +459,7 @@ function AppliedFilters({ possibleTasks, bulkActions }: RunFiltersProps) {
<AppliedScheduleIdFilter />
<AppliedBulkActionsFilter bulkActions={bulkActions} />
<AppliedErrorIdFilter />
<AppliedSourceFilter />
</>
);
}
@@ -483,6 +498,8 @@ function Menu(props: MenuProps) {
return <VersionsDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
case "error":
return <ErrorIdDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
case "source":
return <SourceDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
}
}
@@ -1896,3 +1913,101 @@ function AppliedErrorIdFilter() {
</FilterMenuProvider>
);
}
const sourceOptions: { value: TaskTriggerSource; title: string }[] = [
{ value: "STANDARD", title: "Standard" },
{ value: "SCHEDULED", title: "Scheduled" },
{ value: "AGENT", title: "Agent" },
];
function SourceDropdown({
trigger,
clearSearchValue,
searchValue,
onClose,
}: {
trigger: ReactNode;
clearSearchValue: () => void;
searchValue: string;
onClose?: () => void;
}) {
const { values, replace } = useSearchParams();
const handleChange = (values: string[]) => {
clearSearchValue();
replace({ sources: values, cursor: undefined, direction: undefined });
};
const filtered = useMemo(() => {
return sourceOptions.filter((item) =>
item.title.toLowerCase().includes(searchValue.toLowerCase())
);
}, [searchValue]);
return (
<SelectProvider value={values("sources")} setValue={handleChange} virtualFocus={true}>
{trigger}
<SelectPopover
className="min-w-0 max-w-[min(240px,var(--popover-available-width))]"
hideOnEscape={() => {
if (onClose) {
onClose();
return false;
}
return true;
}}
>
<ComboBox placeholder={"Filter by source..."} value={searchValue} />
<SelectList>
{filtered.map((item, index) => (
<SelectItem
key={item.value}
value={item.value}
icon={
<TaskTriggerSourceIcon source={item.value} className="size-4 flex-none" />
}
shortcut={shortcutFromIndex(index, { shortcutsEnabled: true })}
>
{item.title}
</SelectItem>
))}
</SelectList>
</SelectPopover>
</SelectProvider>
);
}
function AppliedSourceFilter() {
const { values, del } = useSearchParams();
const sources = values("sources");
if (sources.length === 0 || sources.every((v) => v === "")) {
return null;
}
return (
<FilterMenuProvider>
{(search, setSearch) => (
<SourceDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Source"
icon={<CpuChipIcon className="size-4" />}
value={appliedSummary(
sources.map(
(v) => sourceOptions.find((o) => o.value === v)?.title ?? v
)
)}
onRemove={() => del(["sources", "cursor", "direction"])}
variant="secondary/small"
/>
</Ariakit.Select>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
/>
)}
</FilterMenuProvider>
);
}
@@ -55,8 +55,10 @@ import {
filterableTaskRunStatuses,
TaskRunStatusCombo,
} from "./TaskRunStatus";
import { TaskTriggerSourceIcon } from "./TaskTriggerSource";
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
import { useSearchParams } from "~/hooks/useSearchParam";
import type { TaskTriggerSource } from "@trigger.dev/database";
type RunsTableProps = {
total: number;
@@ -343,6 +345,10 @@ export function TaskRunsTable({
</TableCell>
<TableCell to={path}>
<span className="flex items-center gap-x-1">
<TaskTriggerSourceIcon
source={run.taskKind as TaskTriggerSource}
className="size-3.5 flex-none"
/>
{run.taskIdentifier}
{run.rootTaskRunId === null ? <Badge variant="extra-small">Root</Badge> : null}
</span>
@@ -1,4 +1,4 @@
import { ClockIcon } from "@heroicons/react/20/solid";
import { ClockIcon, CpuChipIcon } from "@heroicons/react/20/solid";
import type { TaskTriggerSource } from "@trigger.dev/database";
import { TaskIconSmall } from "~/assets/icons/TaskIcon";
import { cn } from "~/utils/cn";
@@ -19,6 +19,11 @@ export function TaskTriggerSourceIcon({
<ClockIcon className={cn("size-[1.125rem] min-w-[1.125rem] text-schedules", className)} />
);
}
case "AGENT": {
return (
<CpuChipIcon className={cn("size-[1.125rem] min-w-[1.125rem] text-indigo-500", className)} />
);
}
}
}
@@ -30,5 +35,8 @@ export function taskTriggerSourceDescription(source: TaskTriggerSource) {
case "SCHEDULED": {
return "Scheduled task";
}
case "AGENT": {
return "Agent";
}
}
}
@@ -259,7 +259,7 @@ function ToolUseSection({ tools }: { tools: ToolUse[] }) {
type ToolTab = "input" | "output" | "details";
function ToolUseRow({ tool }: { tool: ToolUse }) {
export function ToolUseRow({ tool }: { tool: ToolUse }) {
const hasInput = tool.inputJson !== "{}";
const hasResult = !!tool.resultOutput;
const hasDetails = !!tool.description || !!tool.parametersJson;
@@ -36,6 +36,7 @@ export async function getRunFiltersFromRequest(request: Request): Promise<Filter
queues,
machines,
errorId,
sources,
} = TaskRunListSearchFilters.parse(s);
return {
@@ -56,5 +57,6 @@ export async function getRunFiltersFromRequest(request: Request): Promise<Filter
queues,
machines,
errorId,
sources,
};
}
@@ -0,0 +1,288 @@
import {
type PrismaClientOrTransaction,
type RuntimeEnvironmentType,
type TaskTriggerSource,
} from "@trigger.dev/database";
import { ClickHouse } from "@internal/clickhouse";
import { z } from "zod";
import { $replica } from "~/db.server";
import { clickhouseClient } from "~/services/clickhouseInstance.server";
import { singleton } from "~/utils/singleton";
import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.server";
export type AgentListItem = {
slug: string;
filePath: string;
createdAt: Date;
triggerSource: TaskTriggerSource;
config: unknown;
};
export type AgentActiveState = {
running: number;
suspended: number;
};
export class AgentListPresenter {
constructor(
private readonly clickhouse: ClickHouse,
private readonly _replica: PrismaClientOrTransaction
) {}
public async call({
organizationId,
projectId,
environmentId,
environmentType,
}: {
organizationId: string;
projectId: string;
environmentId: string;
environmentType: RuntimeEnvironmentType;
}) {
const currentWorker = await findCurrentWorkerFromEnvironment(
{
id: environmentId,
type: environmentType,
},
this._replica
);
if (!currentWorker) {
return {
agents: [],
activeStates: Promise.resolve({} as Record<string, AgentActiveState>),
conversationSparklines: Promise.resolve({} as Record<string, number[]>),
costSparklines: Promise.resolve({} as Record<string, number[]>),
tokenSparklines: Promise.resolve({} as Record<string, number[]>),
};
}
const agents = await this._replica.backgroundWorkerTask.findMany({
where: {
workerId: currentWorker.id,
triggerSource: "AGENT",
},
select: {
id: true,
slug: true,
filePath: true,
triggerSource: true,
config: true,
createdAt: true,
},
orderBy: {
slug: "asc",
},
});
const slugs = agents.map((a) => a.slug);
if (slugs.length === 0) {
return {
agents,
activeStates: Promise.resolve({} as Record<string, AgentActiveState>),
conversationSparklines: Promise.resolve({} as Record<string, number[]>),
costSparklines: Promise.resolve({} as Record<string, number[]>),
tokenSparklines: Promise.resolve({} as Record<string, number[]>),
};
}
// All queries are deferred for streaming
const activeStates = this.#getActiveStates(environmentId, slugs);
const conversationSparklines = this.#getConversationSparklines(environmentId, slugs);
const costSparklines = this.#getCostSparklines(environmentId, slugs);
const tokenSparklines = this.#getTokenSparklines(environmentId, slugs);
return { agents, activeStates, conversationSparklines, costSparklines, tokenSparklines };
}
/** Count runs currently executing vs suspended per agent */
async #getActiveStates(
environmentId: string,
slugs: string[]
): Promise<Record<string, AgentActiveState>> {
const queryFn = this.clickhouse.reader.query({
name: "agentActiveStates",
query: `SELECT
task_identifier,
countIf(status = 'EXECUTING') AS running,
countIf(status IN ('WAITING_TO_RESUME', 'QUEUED_EXECUTING')) AS suspended
FROM trigger_dev.task_runs_v2
WHERE environment_id = {environmentId: String}
AND task_identifier IN {slugs: Array(String)}
AND task_kind = 'AGENT'
AND status IN ('EXECUTING', 'WAITING_TO_RESUME', 'QUEUED_EXECUTING')
GROUP BY task_identifier`,
params: z.object({
environmentId: z.string(),
slugs: z.array(z.string()),
}),
schema: z.object({
task_identifier: z.string(),
running: z.coerce.number(),
suspended: z.coerce.number(),
}),
});
const [error, rows] = await queryFn({ environmentId, slugs });
if (error) {
console.error("Agent active states query failed:", error);
return {};
}
const result: Record<string, AgentActiveState> = {};
for (const row of rows) {
result[row.task_identifier] = { running: row.running, suspended: row.suspended };
}
return result;
}
/** 24h hourly sparkline of conversation (run) count per agent */
async #getConversationSparklines(
environmentId: string,
slugs: string[]
): Promise<Record<string, number[]>> {
const queryFn = this.clickhouse.reader.query({
name: "agentConversationSparklines",
query: `SELECT
task_identifier,
toStartOfHour(created_at) AS bucket,
count() AS val
FROM trigger_dev.task_runs_v2
WHERE environment_id = {environmentId: String}
AND task_identifier IN {slugs: Array(String)}
AND task_kind = 'AGENT'
AND created_at >= now() - INTERVAL 24 HOUR
GROUP BY task_identifier, bucket
ORDER BY task_identifier, bucket`,
params: z.object({
environmentId: z.string(),
slugs: z.array(z.string()),
}),
schema: z.object({
task_identifier: z.string(),
bucket: z.string(),
val: z.coerce.number(),
}),
});
return this.#buildSparklineMap(await queryFn({ environmentId, slugs }), slugs);
}
/** 24h hourly sparkline of LLM cost per agent */
async #getCostSparklines(
environmentId: string,
slugs: string[]
): Promise<Record<string, number[]>> {
const queryFn = this.clickhouse.reader.query({
name: "agentCostSparklines",
query: `SELECT
task_identifier,
toStartOfHour(start_time) AS bucket,
sum(total_cost) AS val
FROM trigger_dev.llm_metrics_v1
WHERE environment_id = {environmentId: String}
AND task_identifier IN {slugs: Array(String)}
AND start_time >= now() - INTERVAL 24 HOUR
GROUP BY task_identifier, bucket
ORDER BY task_identifier, bucket`,
params: z.object({
environmentId: z.string(),
slugs: z.array(z.string()),
}),
schema: z.object({
task_identifier: z.string(),
bucket: z.string(),
val: z.coerce.number(),
}),
});
return this.#buildSparklineMap(await queryFn({ environmentId, slugs }), slugs);
}
/** 24h hourly sparkline of total tokens per agent */
async #getTokenSparklines(
environmentId: string,
slugs: string[]
): Promise<Record<string, number[]>> {
const queryFn = this.clickhouse.reader.query({
name: "agentTokenSparklines",
query: `SELECT
task_identifier,
toStartOfHour(start_time) AS bucket,
sum(total_tokens) AS val
FROM trigger_dev.llm_metrics_v1
WHERE environment_id = {environmentId: String}
AND task_identifier IN {slugs: Array(String)}
AND start_time >= now() - INTERVAL 24 HOUR
GROUP BY task_identifier, bucket
ORDER BY task_identifier, bucket`,
params: z.object({
environmentId: z.string(),
slugs: z.array(z.string()),
}),
schema: z.object({
task_identifier: z.string(),
bucket: z.string(),
val: z.coerce.number(),
}),
});
return this.#buildSparklineMap(await queryFn({ environmentId, slugs }), slugs);
}
/** Convert ClickHouse query result to sparkline map with zero-filled 24 hourly buckets */
#buildSparklineMap(
queryResult: [Error, null] | [null, { task_identifier: string; bucket: string; val: number }[]],
slugs: string[]
): Record<string, number[]> {
const [error, rows] = queryResult;
if (error) {
console.error("Agent sparkline query failed:", error);
return {};
}
return this.#buildSparklineFromRows(rows, slugs);
}
#buildSparklineFromRows(
rows: { task_identifier: string; bucket: string; val: number }[],
slugs: string[]
): Record<string, number[]> {
const now = new Date();
const startHour = new Date(
Date.UTC(
now.getUTCFullYear(),
now.getUTCMonth(),
now.getUTCDate(),
now.getUTCHours() - 23,
0,
0,
0
)
);
const bucketKeys: string[] = [];
for (let i = 0; i < 24; i++) {
const h = new Date(startHour.getTime() + i * 3600_000);
bucketKeys.push(h.toISOString().slice(0, 13).replace("T", " ") + ":00:00");
}
const rowMap = new Map<string, number>();
for (const row of rows) {
rowMap.set(`${row.task_identifier}|${row.bucket}`, row.val);
}
const result: Record<string, number[]> = {};
for (const slug of slugs) {
result[slug] = bucketKeys.map((key) => rowMap.get(`${slug}|${key}`) ?? 0);
}
return result;
}
}
export const agentListPresenter = singleton("agentListPresenter", setupAgentListPresenter);
function setupAgentListPresenter() {
return new AgentListPresenter(clickhouseClient, $replica);
}
@@ -304,6 +304,7 @@ export class ApiRunListPresenter extends BasePresenter {
durationMs: run.usageDurationMs,
depth: run.depth,
metadata,
taskKind: run.taskKind,
...ApiRetrieveRunPresenter.apiBooleanHelpersFromRunStatus(
ApiRetrieveRunPresenter.apiStatusFromRunStatus(run.status, apiVersion)
),
@@ -1,5 +1,6 @@
import { type ClickHouse } from "@internal/clickhouse";
import { MachinePresetName } from "@trigger.dev/core/v3";
import { RunAnnotations } from "@trigger.dev/core/v3/schemas";
import {
type PrismaClient,
type PrismaClientOrTransaction,
@@ -34,6 +35,7 @@ export type RunListOptions = {
queues?: string[];
machines?: MachinePresetName[];
errorId?: string;
sources?: string[];
//pagination
direction?: Direction;
cursor?: string;
@@ -72,6 +74,7 @@ export class NextRunListPresenter {
queues,
machines,
errorId,
sources,
from,
to,
direction = "forward",
@@ -89,6 +92,7 @@ export class NextRunListPresenter {
const hasStatusFilters = statuses && statuses.length > 0;
const hasFilters =
(sources !== undefined && sources.length > 0) ||
(tasks !== undefined && tasks.length > 0) ||
(versions !== undefined && versions.length > 0) ||
hasStatusFilters ||
@@ -186,6 +190,7 @@ export class NextRunListPresenter {
queues,
machines,
errorId,
taskKinds: sources,
page: {
size: pageSize,
cursor,
@@ -250,6 +255,7 @@ export class NextRunListPresenter {
name: run.queue.replace("task/", ""),
type: run.queue.startsWith("task/") ? "task" : "custom",
},
taskKind: RunAnnotations.safeParse(run.annotations).data?.taskKind ?? "STANDARD",
};
}),
pagination: {
@@ -0,0 +1,147 @@
import type { RuntimeEnvironmentType, TaskRunStatus, TaskTriggerSource } from "@trigger.dev/database";
import { $replica } from "~/db.server";
import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.server";
import { isFinalRunStatus } from "~/v3/taskStatus";
export type PlaygroundAgent = {
slug: string;
filePath: string;
triggerSource: TaskTriggerSource;
config: unknown;
payloadSchema: unknown;
};
export type PlaygroundConversation = {
id: string;
chatId: string;
title: string;
agentSlug: string;
runFriendlyId: string | null;
runStatus: TaskRunStatus | null;
clientData: unknown;
messages: unknown;
lastEventId: string | null;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
};
export class PlaygroundPresenter {
async listAgents({
environmentId,
environmentType,
}: {
environmentId: string;
environmentType: RuntimeEnvironmentType;
}): Promise<PlaygroundAgent[]> {
const currentWorker = await findCurrentWorkerFromEnvironment(
{ id: environmentId, type: environmentType },
$replica
);
if (!currentWorker) return [];
return $replica.backgroundWorkerTask.findMany({
where: {
workerId: currentWorker.id,
triggerSource: "AGENT",
},
select: {
slug: true,
filePath: true,
triggerSource: true,
config: true,
payloadSchema: true,
},
orderBy: { slug: "asc" },
});
}
async getAgent({
environmentId,
environmentType,
agentSlug,
}: {
environmentId: string;
environmentType: RuntimeEnvironmentType;
agentSlug: string;
}): Promise<PlaygroundAgent | null> {
const currentWorker = await findCurrentWorkerFromEnvironment(
{ id: environmentId, type: environmentType },
$replica
);
if (!currentWorker) return null;
return $replica.backgroundWorkerTask.findFirst({
where: {
workerId: currentWorker.id,
triggerSource: "AGENT",
slug: agentSlug,
},
select: {
slug: true,
filePath: true,
triggerSource: true,
config: true,
payloadSchema: true,
},
});
}
async getRecentConversations({
environmentId,
agentSlug,
userId,
limit = 10,
}: {
environmentId: string;
agentSlug: string;
userId: string;
limit?: number;
}): Promise<PlaygroundConversation[]> {
const conversations = await $replica.playgroundConversation.findMany({
where: {
runtimeEnvironmentId: environmentId,
agentSlug,
userId,
},
select: {
id: true,
chatId: true,
title: true,
agentSlug: true,
clientData: true,
messages: true,
lastEventId: true,
createdAt: true,
updatedAt: true,
run: {
select: {
friendlyId: true,
status: true,
},
},
},
orderBy: { updatedAt: "desc" },
take: limit,
});
return conversations.map((c) => ({
id: c.id,
chatId: c.chatId,
title: c.title,
agentSlug: c.agentSlug,
runFriendlyId: c.run?.friendlyId ?? null,
runStatus: c.run?.status ?? null,
clientData: c.clientData,
messages: c.messages,
lastEventId: c.lastEventId,
isActive: c.run?.status ? !isFinalRunStatus(c.run.status) : false,
createdAt: c.createdAt,
updatedAt: c.updatedAt,
}));
}
}
export const playgroundPresenter = new PlaygroundPresenter();
@@ -61,6 +61,7 @@ export class TaskListPresenter {
const tasks = await this._replica.backgroundWorkerTask.findMany({
where: {
workerId: currentWorker.id,
triggerSource: { not: "AGENT" },
},
select: {
id: true,
@@ -19,15 +19,13 @@ export class TestPresenter extends BasePresenter {
const tasks = await this.#getTasks(environmentId, isDev);
return {
tasks: tasks.map((task) => {
return {
id: task.id,
taskIdentifier: task.slug,
filePath: task.filePath,
friendlyId: task.friendlyId,
triggerSource: task.triggerSource,
};
}),
tasks: tasks.map((task) => ({
id: task.id,
taskIdentifier: task.slug,
filePath: task.filePath,
friendlyId: task.friendlyId,
triggerSource: task.triggerSource,
})),
};
}
@@ -54,10 +52,13 @@ export class TestPresenter extends BasePresenter {
SELECT bwt.id, version, slug, "filePath", bwt."friendlyId", bwt."triggerSource"
FROM latest_workers
JOIN ${sqlDatabaseSchema}."BackgroundWorkerTask" bwt ON bwt."workerId" = latest_workers.id
WHERE bwt."triggerSource" != 'AGENT'
ORDER BY slug ASC;`;
} else {
const currentDeployment = await findCurrentWorkerDeployment({ environmentId: envId });
return currentDeployment?.worker?.tasks ?? [];
return (currentDeployment?.worker?.tasks ?? []).filter(
(t) => t.triggerSource !== "AGENT"
);
}
}
}
@@ -0,0 +1,360 @@
import { BeakerIcon, CpuChipIcon, MagnifyingGlassIcon } from "@heroicons/react/20/solid";
import { type MetaFunction } from "@remix-run/node";
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { Suspense } from "react";
import { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson";
import { RunsIcon } from "~/assets/icons/RunsIcon";
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
import { Badge } from "~/components/primitives/Badge";
import { Header2 } from "~/components/primitives/Headers";
import { Input } from "~/components/primitives/Input";
import { LinkButton } from "~/components/primitives/Buttons";
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Spinner } from "~/components/primitives/Spinner";
import {
Table,
TableBlankRow,
TableBody,
TableCell,
TableCellMenu,
TableHeader,
TableHeaderCell,
TableRow,
} from "~/components/primitives/Table";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { PopoverMenuItem } from "~/components/primitives/Popover";
import { TaskFileName } from "~/components/runs/v3/TaskPath";
import { useFuzzyFilter } from "~/hooks/useFuzzyFilter";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import {
type AgentListItem,
type AgentActiveState,
agentListPresenter,
} from "~/presenters/v3/AgentListPresenter.server";
import { requireUserId } from "~/services/session.server";
import { EnvironmentParamSchema, v3RunsPath, v3PlaygroundAgentPath } from "~/utils/pathBuilder";
import { cn } from "~/utils/cn";
export const meta: MetaFunction = () => {
return [{ title: "Agents | Trigger.dev" }];
};
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
throw new Response(undefined, { status: 404, statusText: "Project not found" });
}
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
if (!environment) {
throw new Response(undefined, { status: 404, statusText: "Environment not found" });
}
const result = await agentListPresenter.call({
organizationId: project.organizationId,
projectId: project.id,
environmentId: environment.id,
environmentType: environment.type,
});
return typeddefer(result);
};
export default function AgentsPage() {
const { agents, activeStates, conversationSparklines, costSparklines, tokenSparklines } =
useTypedLoaderData<typeof loader>();
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const { filterText, setFilterText, filteredItems } = useFuzzyFilter({
items: agents,
keys: ["slug", "filePath"],
});
if (agents.length === 0) {
return (
<PageContainer>
<NavBar>
<PageTitle title="Agents" />
</NavBar>
<PageBody>
<MainCenteredContainer>
<div className="flex flex-col items-center gap-4 py-20">
<CpuChipIcon className="size-12 text-indigo-500" />
<Header2>No agents deployed</Header2>
<Paragraph variant="small" className="max-w-md text-center">
Create a chat agent using <code>chat.agent()</code> from{" "}
<code>@trigger.dev/sdk/ai</code> and deploy it to see it here.
</Paragraph>
</div>
</MainCenteredContainer>
</PageBody>
</PageContainer>
);
}
return (
<PageContainer>
<NavBar>
<PageTitle title="Agents" />
</NavBar>
<PageBody scrollable={false}>
<div className="grid h-full grid-rows-1">
<div className="flex min-w-0 max-w-full flex-col">
<div className="max-h-full overflow-hidden">
<div className="flex items-center gap-1 p-2">
<Input
placeholder="Search agents"
variant="tertiary"
icon={MagnifyingGlassIcon}
fullWidth={true}
value={filterText}
onChange={(e) => setFilterText(e.target.value)}
autoFocus
/>
</div>
<Table containerClassName="max-h-full pb-[2.5rem]">
<TableHeader>
<TableRow>
<TableHeaderCell>ID</TableHeaderCell>
<TableHeaderCell>Type</TableHeaderCell>
<TableHeaderCell>File</TableHeaderCell>
<TableHeaderCell>Active</TableHeaderCell>
<TableHeaderCell>Conversations (24h)</TableHeaderCell>
<TableHeaderCell>Cost (24h)</TableHeaderCell>
<TableHeaderCell>Tokens (24h)</TableHeaderCell>
<TableHeaderCell hiddenLabel>Go to page</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{filteredItems.length > 0 ? (
filteredItems.map((agent) => {
const path = v3RunsPath(organization, project, environment, {
tasks: [agent.slug],
});
const agentType =
(agent.config as { type?: string } | null)?.type ?? "unknown";
return (
<TableRow key={agent.slug} className="group">
<TableCell to={path} isTabbableCell>
<div className="flex items-center gap-2">
<SimpleTooltip
button={
<CpuChipIcon className="size-[1.125rem] min-w-[1.125rem] text-indigo-500" />
}
content="Agent"
/>
<span>{agent.slug}</span>
</div>
</TableCell>
<TableCell to={path}>
<Badge variant="extra-small">{formatAgentType(agentType)}</Badge>
</TableCell>
<TableCell to={path}>
<TaskFileName fileName={agent.filePath} variant="extra-extra-small" />
</TableCell>
<TableCell to={path}>
<Suspense fallback={<Spinner color="muted" />}>
<TypedAwait resolve={activeStates} errorElement={<></>}>
{(data) => {
const state = data[agent.slug];
if (!state || (state.running === 0 && state.suspended === 0)) {
return (
<span className="text-text-dimmed"></span>
);
}
return (
<span className="flex items-center gap-1.5 text-xs">
{state.running > 0 && (
<span className="flex items-center gap-0.5">
<span className="size-1.5 rounded-full bg-success" />
<span>{state.running}</span>
</span>
)}
{state.running > 0 && state.suspended > 0 && (
<span className="text-text-dimmed">·</span>
)}
{state.suspended > 0 && (
<span className="flex items-center gap-0.5">
<span className="size-1.5 rounded-full bg-blue-500" />
<span>{state.suspended}</span>
</span>
)}
</span>
);
}}
</TypedAwait>
</Suspense>
</TableCell>
<TableCell to={path} actionClassName="py-1.5">
<Suspense fallback={<SparklinePlaceholder />}>
<TypedAwait resolve={conversationSparklines} errorElement={<></>}>
{(data) => (
<SparklineWithTotal
data={data[agent.slug]}
formatTotal={formatCount}
/>
)}
</TypedAwait>
</Suspense>
</TableCell>
<TableCell to={path} actionClassName="py-1.5">
<Suspense fallback={<SparklinePlaceholder />}>
<TypedAwait resolve={costSparklines} errorElement={<></>}>
{(data) => (
<SparklineWithTotal
data={data[agent.slug]}
formatTotal={formatCost}
color="text-amber-400"
barColor="#F59E0B"
/>
)}
</TypedAwait>
</Suspense>
</TableCell>
<TableCell to={path} actionClassName="py-1.5">
<Suspense fallback={<SparklinePlaceholder />}>
<TypedAwait resolve={tokenSparklines} errorElement={<></>}>
{(data) => (
<SparklineWithTotal
data={data[agent.slug]}
formatTotal={formatTokens}
color="text-purple-400"
barColor="#A855F7"
/>
)}
</TypedAwait>
</Suspense>
</TableCell>
<TableCellMenu
isSticky
popoverContent={
<>
<PopoverMenuItem
icon={RunsIcon}
to={path}
title="View runs"
leadingIconClassName="text-runs"
/>
<PopoverMenuItem
icon={BeakerIcon}
to={v3PlaygroundAgentPath(organization, project, environment, agent.slug)}
title="Playground"
leadingIconClassName="text-indigo-400"
/>
</>
}
hiddenButtons={
<LinkButton
variant="minimal/small"
LeadingIcon={BeakerIcon}
leadingIconClassName="text-text-bright"
to={v3PlaygroundAgentPath(organization, project, environment, agent.slug)}
>
Playground
</LinkButton>
}
/>
</TableRow>
);
})
) : (
<TableBlankRow colSpan={8}>
<Paragraph variant="small" className="flex items-center justify-center">
No agents match your filters
</Paragraph>
</TableBlankRow>
)}
</TableBody>
</Table>
</div>
</div>
</div>
</PageBody>
</PageContainer>
);
}
function formatAgentType(type: string): string {
switch (type) {
case "ai-sdk-chat":
return "AI SDK Chat";
default:
return type;
}
}
function formatCount(total: number): string {
if (total === 0) return "0";
if (total >= 1000) return `${(total / 1000).toFixed(1)}k`;
return total.toString();
}
function formatCost(total: number): string {
if (total === 0) return "$0";
if (total < 0.01) return `$${total.toFixed(4)}`;
if (total < 1) return `$${total.toFixed(2)}`;
return `$${total.toFixed(2)}`;
}
function formatTokens(total: number): string {
if (total === 0) return "0";
if (total >= 1_000_000) return `${(total / 1_000_000).toFixed(1)}M`;
if (total >= 1000) return `${(total / 1000).toFixed(1)}k`;
return total.toString();
}
function SparklinePlaceholder() {
return <div className="h-6 w-24" />;
}
function SparklineWithTotal({
data,
formatTotal,
color = "text-text-bright",
barColor = "#3B82F6",
}: {
data?: number[];
formatTotal: (total: number) => string;
color?: string;
barColor?: string;
}) {
if (!data || data.every((v) => v === 0)) {
return <span className="text-text-dimmed"></span>;
}
const total = data.reduce((sum, v) => sum + v, 0);
const max = Math.max(...data);
return (
<div className="flex items-center gap-2">
<div className="flex h-5 items-end gap-px">
{data.map((value, i) => {
const height = max > 0 ? Math.max((value / max) * 100, value > 0 ? 8 : 0) : 0;
return (
<div
key={i}
className="w-[3px] rounded-t-[1px]"
style={{
height: `${height}%`,
backgroundColor: value > 0 ? barColor : "transparent",
opacity: value > 0 ? 0.8 : 0,
}}
/>
);
})}
</div>
<span className={cn("text-xs tabular-nums", color)}>{formatTotal(total)}</span>
</div>
);
}
@@ -0,0 +1,133 @@
import { CpuChipIcon } from "@heroicons/react/20/solid";
import { json, type MetaFunction } from "@remix-run/node";
import { Outlet, useNavigate, useParams, useLoaderData } from "@remix-run/react";
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
import { Header2 } from "~/components/primitives/Headers";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import {
Select,
SelectItem,
} from "~/components/primitives/Select";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import {
type PlaygroundAgent,
playgroundPresenter,
} from "~/presenters/v3/PlaygroundPresenter.server";
import { requireUserId } from "~/services/session.server";
import { EnvironmentParamSchema, v3PlaygroundAgentPath } from "~/utils/pathBuilder";
export const meta: MetaFunction = () => {
return [{ title: "Playground | Trigger.dev" }];
};
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
throw new Response(undefined, { status: 404, statusText: "Project not found" });
}
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
if (!environment) {
throw new Response(undefined, { status: 404, statusText: "Environment not found" });
}
const agents = await playgroundPresenter.listAgents({
environmentId: environment.id,
environmentType: environment.type,
});
return json({ agents });
};
export default function PlaygroundPage() {
const { agents } = useLoaderData<typeof loader>();
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const navigate = useNavigate();
const params = useParams();
const selectedAgent = params.agentParam ?? "";
if (agents.length === 0) {
return (
<PageContainer>
<NavBar>
<PageTitle title="Playground" />
</NavBar>
<PageBody>
<MainCenteredContainer>
<div className="flex flex-col items-center gap-4 py-20">
<CpuChipIcon className="size-12 text-indigo-500" />
<Header2>No agents deployed</Header2>
<Paragraph variant="small" className="max-w-md text-center">
Create a chat agent using <code>chat.agent()</code> from{" "}
<code>@trigger.dev/sdk/ai</code> and deploy it to see it here.
</Paragraph>
</div>
</MainCenteredContainer>
</PageBody>
</PageContainer>
);
}
return (
<PageContainer>
<NavBar>
<PageTitle title="Playground" />
<PageAccessories>
<Select
value={selectedAgent}
setValue={(slug) => {
if (slug && typeof slug === "string") {
navigate(v3PlaygroundAgentPath(organization, project, environment, slug));
}
}}
icon={<CpuChipIcon className="size-4 text-indigo-500" />}
text={(val) => val || undefined}
placeholder="Select an agent..."
variant="tertiary/small"
items={agents}
filter={(item, search) =>
item.slug.toLowerCase().includes(search.toLowerCase())
}
>
{(matches) =>
matches.map((agent, index) => (
<SelectItem key={agent.slug} value={agent.slug}>
<div className="flex items-center gap-2">
<CpuChipIcon className="size-3.5 text-indigo-500" />
<span>{agent.slug}</span>
</div>
</SelectItem>
))
}
</Select>
</PageAccessories>
</NavBar>
<PageBody scrollable={false}>
{selectedAgent ? (
<Outlet />
) : (
<MainCenteredContainer>
<div className="flex flex-col items-center gap-4 py-20">
<CpuChipIcon className="size-10 text-indigo-500/50" />
<Header2 className="text-text-dimmed">Select an agent</Header2>
<Paragraph variant="small" className="max-w-md text-center text-text-dimmed">
Choose an agent from the dropdown to start a conversation.
</Paragraph>
</div>
</MainCenteredContainer>
)}
</PageBody>
</PageContainer>
);
}
@@ -31,11 +31,17 @@ export function AIPayloadTabContent({
payloadSchema,
taskIdentifier,
getCurrentPayload,
generateButtonLabel = "Generate payload",
placeholder,
examplePromptsOverride,
}: {
onPayloadGenerated: (payload: string) => void;
payloadSchema?: unknown;
taskIdentifier: string;
getCurrentPayload?: () => string;
generateButtonLabel?: string;
placeholder?: string;
examplePromptsOverride?: string[];
}) {
const [prompt, setPrompt] = useState("");
const [isLoading, setIsLoading] = useState(false);
@@ -191,7 +197,7 @@ export function AIPayloadTabContent({
}
}, [error]);
const examplePrompts = payloadSchema
const examplePrompts = examplePromptsOverride ?? (payloadSchema
? [
"Generate a valid payload",
"Generate a payload with edge cases",
@@ -201,7 +207,7 @@ export function AIPayloadTabContent({
"Generate a simple JSON payload",
"Generate a payload with nested objects",
"Generate a payload with an array of items",
];
]);
return (
<div className="space-y-2">
@@ -215,9 +221,9 @@ export function AIPayloadTabContent({
ref={textareaRef}
name="prompt"
placeholder={
payloadSchema
placeholder ?? (payloadSchema
? "e.g. generate a payload for a new user signup"
: "e.g. generate a JSON payload with name, email, and age fields"
: "e.g. generate a JSON payload with name, email, and age fields")
}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
@@ -251,7 +257,7 @@ export function AIPayloadTabContent({
className={cn(!prompt.trim() && "opacity-50")}
onClick={() => handleSubmit()}
>
Generate payload
{generateButtonLabel}
</Button>
)}
</div>
@@ -9,18 +9,34 @@ import { docsPath } from "~/utils/pathBuilder";
export function SchemaTabContent({
schema,
inferredSchema,
title = "Payload schema",
description,
showDocsLink = true,
}: {
schema?: unknown;
inferredSchema?: unknown;
title?: string;
description?: string;
showDocsLink?: boolean;
}) {
if (schema) {
return (
<div className="space-y-2">
<Header3 className="text-text-bright">Payload schema</Header3>
<Paragraph variant="extra-small" className="text-text-dimmed">
JSON Schema defined by this task via{" "}
<TextLink to={docsPath("tasks/schemaTask")}>schemaTask</TextLink>.
</Paragraph>
<Header3 className="text-text-bright">{title}</Header3>
{showDocsLink ? (
<Paragraph variant="extra-small" className="text-text-dimmed">
{description ?? (
<>
JSON Schema defined by this task via{" "}
<TextLink to={docsPath("tasks/schemaTask")}>schemaTask</TextLink>.
</>
)}
</Paragraph>
) : description ? (
<Paragraph variant="extra-small" className="text-text-dimmed">
{description}
</Paragraph>
) : null}
<CodeBlock
code={JSON.stringify(schema, null, 2)}
language="json"
@@ -0,0 +1,225 @@
import { json } from "@remix-run/server-runtime";
import { type ActionFunctionArgs } from "@remix-run/server-runtime";
import {
generateJWT as internal_generateJWT,
} from "@trigger.dev/core/v3";
import { z } from "zod";
import { prisma } from "~/db.server";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { requireUserId } from "~/services/session.server";
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
import { TriggerTaskService } from "~/v3/services/triggerTask.server";
import { extractJwtSigningSecretKey } from "~/services/realtime/jwtAuth.server";
const PlaygroundAction = z.object({
intent: z.enum(["create", "trigger", "renew", "save", "delete"]),
agentSlug: z.string(),
// For create
conversationId: z.string().optional(),
// For trigger
chatId: z.string().optional(),
payload: z.string().optional(),
clientData: z.string().optional(),
tags: z.string().optional(),
machine: z.string().optional(),
// For renew
runId: z.string().optional(),
// For save
messages: z.string().optional(),
lastEventId: z.string().optional(),
// For delete
deleteConversationId: z.string().optional(),
});
export const action = async ({ request, params }: ActionFunctionArgs) => {
const userId = await requireUserId(request);
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
return json({ error: "Project not found" }, { status: 404 });
}
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
if (!environment) {
return json({ error: "Environment not found" }, { status: 404 });
}
const formData = await request.formData();
const parsed = PlaygroundAction.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
return json({ error: "Invalid request", details: parsed.error.issues }, { status: 400 });
}
const { intent } = parsed.data;
switch (intent) {
case "create": {
const { agentSlug } = parsed.data;
const chatId = crypto.randomUUID();
const conversation = await prisma.playgroundConversation.create({
data: {
chatId,
agentSlug,
projectId: project.id,
runtimeEnvironmentId: environment.id,
userId,
},
});
return json({
conversationId: conversation.id,
chatId,
});
}
case "trigger": {
const { agentSlug, chatId, payload: payloadStr, clientData, tags: tagsStr, machine } = parsed.data;
if (!payloadStr || !chatId) {
return json({ error: "payload and chatId are required" }, { status: 400 });
}
const payload = JSON.parse(payloadStr) as Record<string, any>;
const triggerService = new TriggerTaskService();
const result = await triggerService.call(
agentSlug,
environment,
{
payload,
options: {
payloadType: "application/json",
test: true,
tags: [
`chat:${chatId}`,
"playground:true",
...(tagsStr ? tagsStr.split(",").map((t) => t.trim()).filter(Boolean) : []),
].slice(0, 5),
machine: machine as any,
},
},
{ triggerSource: "dashboard", triggerAction: "test", realtimeStreamsVersion: "v2" }
);
if (!result?.run) {
return json({ error: "Failed to trigger agent" }, { status: 500 });
}
// Create or update the playground conversation
let parsedClientData: unknown;
try {
parsedClientData = clientData ? JSON.parse(clientData) : undefined;
} catch {
// Client data JSON was invalid — proceed without it
}
// Extract first message text for title
const firstMessage = payload?.messages?.[0];
const firstText =
firstMessage?.parts?.find((p: any) => p.type === "text")?.text ?? "New conversation";
const title = firstText.length > 60 ? firstText.slice(0, 60) + "..." : firstText;
const conversation = await prisma.playgroundConversation.upsert({
where: {
chatId_runtimeEnvironmentId: {
chatId,
runtimeEnvironmentId: environment.id,
},
},
create: {
chatId,
title,
agentSlug,
runId: result.run.id,
clientData: parsedClientData as any,
projectId: project.id,
runtimeEnvironmentId: environment.id,
userId,
},
update: {
runId: result.run.id,
clientData: parsedClientData as any,
title,
},
});
const jwt = await mintRunToken(environment, result.run.friendlyId);
return json({
runId: result.run.friendlyId,
publicAccessToken: jwt,
conversationId: conversation.id,
});
}
case "renew": {
const { runId } = parsed.data;
if (!runId) {
return json({ error: "runId is required" }, { status: 400 });
}
const jwt = await mintRunToken(environment, runId);
return json({ publicAccessToken: jwt });
}
case "save": {
const { chatId, messages: messagesStr, lastEventId } = parsed.data;
if (!chatId) {
return json({ error: "chatId is required" }, { status: 400 });
}
const messagesData = messagesStr ? JSON.parse(messagesStr) : undefined;
await prisma.playgroundConversation.updateMany({
where: {
chatId,
runtimeEnvironmentId: environment.id,
},
data: {
...(messagesData ? { messages: messagesData as any } : {}),
...(lastEventId ? { lastEventId } : {}),
},
});
return json({ ok: true });
}
case "delete": {
const { deleteConversationId } = parsed.data;
if (!deleteConversationId) {
return json({ error: "deleteConversationId is required" }, { status: 400 });
}
await prisma.playgroundConversation.deleteMany({
where: {
id: deleteConversationId,
runtimeEnvironmentId: environment.id,
userId,
},
});
return json({ ok: true });
}
}
};
async function mintRunToken(
environment: Parameters<typeof extractJwtSigningSecretKey>[0],
runFriendlyId: string
): Promise<string> {
return internal_generateJWT({
secretKey: extractJwtSigningSecretKey(environment),
payload: {
sub: environment.id,
pub: true,
scopes: [
`read:runs:${runFriendlyId}`,
`write:inputStreams:${runFriendlyId}`,
],
},
expirationTime: "1h",
});
}
@@ -0,0 +1,61 @@
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { z } from "zod";
import { $replica } from "~/db.server";
import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { requireUserId } from "~/services/session.server";
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
const ParamsSchema = z.object({
runId: z.string(),
streamId: z.string(),
});
// GET: SSE stream subscription — authenticated via session cookie
export async function loader({ request, params }: LoaderFunctionArgs) {
const userId = await requireUserId(request);
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
const { runId, streamId } = ParamsSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
return new Response("Project not found", { status: 404 });
}
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
if (!environment) {
return new Response("Environment not found", { status: 404 });
}
const run = await $replica.taskRun.findFirst({
where: {
friendlyId: runId,
runtimeEnvironmentId: environment.id,
},
select: {
id: true,
friendlyId: true,
realtimeStreamsVersion: true,
},
});
if (!run) {
return new Response("Run not found", { status: 404 });
}
const lastEventId = request.headers.get("Last-Event-ID") || undefined;
const timeoutInSecondsRaw = request.headers.get("Timeout-Seconds") ?? undefined;
const timeoutInSeconds = timeoutInSecondsRaw ? parseInt(timeoutInSecondsRaw) : undefined;
if (timeoutInSeconds && (isNaN(timeoutInSeconds) || timeoutInSeconds < 1 || timeoutInSeconds > 600)) {
return new Response("Invalid timeout", { status: 400 });
}
const realtimeStream = getRealtimeStreamInstance(environment, run.realtimeStreamsVersion);
return realtimeStream.streamResponse(request, run.friendlyId, streamId, request.signal, {
lastEventId,
timeoutInSeconds,
});
}
@@ -0,0 +1,96 @@
import { json, type ActionFunctionArgs } from "@remix-run/server-runtime";
import { z } from "zod";
import { $replica } from "~/db.server";
import {
getInputStreamWaitpoint,
deleteInputStreamWaitpoint,
} from "~/services/inputStreamWaitpointCache.server";
import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server";
import { engine } from "~/v3/runEngine.server";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { requireUserId } from "~/services/session.server";
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
const ParamsSchema = z.object({
runId: z.string(),
streamId: z.string(),
});
const BodySchema = z.object({
data: z.unknown(),
});
// POST: Send data to an input stream — authenticated via session cookie
export async function action({ request, params }: ActionFunctionArgs) {
const userId = await requireUserId(request);
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
const { runId, streamId } = ParamsSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
return json({ ok: false, error: "Project not found" }, { status: 404 });
}
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
if (!environment) {
return json({ ok: false, error: "Environment not found" }, { status: 404 });
}
const run = await $replica.taskRun.findFirst({
where: {
friendlyId: runId,
runtimeEnvironmentId: environment.id,
},
select: {
id: true,
friendlyId: true,
completedAt: true,
realtimeStreamsVersion: true,
},
});
if (!run) {
return json({ ok: false, error: "Run not found" }, { status: 404 });
}
if (run.completedAt) {
return json(
{ ok: false, error: "Cannot send to input stream on a completed run" },
{ status: 400 }
);
}
const body = BodySchema.safeParse(await request.json());
if (!body.success) {
return json({ ok: false, error: "Invalid request body" }, { status: 400 });
}
const realtimeStream = getRealtimeStreamInstance(environment, run.realtimeStreamsVersion);
const recordId = `inp_${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`;
const record = JSON.stringify(body.data.data);
await realtimeStream.appendPart(
record,
recordId,
run.friendlyId,
`$trigger.input:${streamId}`
);
// Complete any linked waitpoint
const waitpointId = await getInputStreamWaitpoint(runId, streamId);
if (waitpointId) {
await engine.completeWaitpoint({
id: waitpointId,
output: {
value: JSON.stringify(body.data.data),
type: "application/json",
isError: false,
},
});
await deleteInputStreamWaitpoint(runId, streamId);
}
return json({ ok: true });
}
+11 -1
View File
@@ -28,6 +28,7 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
},
},
select: {
spanId: true,
runtimeEnvironment: {
select: {
slug: true,
@@ -57,11 +58,20 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
);
}
// Preserve existing search params from the request, add span if not already set
const url = new URL(request.url);
const searchParams = url.searchParams;
if (!searchParams.has("span") && run.spanId) {
searchParams.set("span", run.spanId);
}
const path = v3RunPath(
{ slug: run.project.organization.slug },
{ slug: run.project.slug },
{ slug: run.runtimeEnvironment.slug },
{ friendlyId: runParam }
{ friendlyId: runParam },
searchParams
);
return redirect(path);
@@ -79,6 +79,7 @@ export class DefaultQueueManager implements QueueManager {
let queueName: string;
let lockedQueueId: string | undefined;
let taskTtl: string | null | undefined;
let taskKind: string | undefined;
// Determine queue name based on lockToVersion and provided options
if (lockedBackgroundWorker) {
@@ -158,6 +159,7 @@ export class DefaultQueueManager implements QueueManager {
// Use the task's default queue name
queueName = lockedTask.queue.name;
lockedQueueId = lockedTask.queue.id;
taskKind = lockedTask.triggerSource;
}
} else {
// Task is not locked to a specific version, use regular logic
@@ -172,6 +174,7 @@ export class DefaultQueueManager implements QueueManager {
const taskInfo = await this.getTaskQueueInfo(request);
queueName = taskInfo.queueName;
taskTtl = taskInfo.taskTtl;
taskKind = taskInfo.taskKind;
}
// Sanitize the final determined queue name once
@@ -188,12 +191,13 @@ export class DefaultQueueManager implements QueueManager {
queueName,
lockedQueueId,
taskTtl,
taskKind,
};
}
private async getTaskQueueInfo(
request: TriggerTaskRequest
): Promise<{ queueName: string; taskTtl?: string | null }> {
): Promise<{ queueName: string; taskTtl?: string | null; taskKind?: string | undefined }> {
const { taskId, environment, body } = request;
const { queue } = body.options ?? {};
@@ -228,10 +232,10 @@ export class DefaultQueueManager implements QueueManager {
runtimeEnvironmentId: environment.id,
slug: taskId,
},
select: { ttl: true },
select: { ttl: true, triggerSource: true },
});
return { queueName: overriddenQueueName, taskTtl: task?.ttl };
return { queueName: overriddenQueueName, taskTtl: task?.ttl, taskKind: task?.triggerSource };
}
const task = await this.replicaPrisma.backgroundWorkerTask.findFirst({
@@ -261,10 +265,10 @@ export class DefaultQueueManager implements QueueManager {
queueConfig: task.queueConfig,
});
return { queueName: defaultQueueName, taskTtl: task.ttl };
return { queueName: defaultQueueName, taskTtl: task.ttl, taskKind: task.triggerSource };
}
return { queueName: task.queue.name ?? defaultQueueName, taskTtl: task.ttl };
return { queueName: task.queue.name ?? defaultQueueName, taskTtl: task.ttl, taskKind: task.triggerSource };
}
async validateQueueLimits(
@@ -185,7 +185,7 @@ export class RunEngineTriggerTaskService {
if (debounceDelayError || !debounceDelayUntil) {
throw new ServiceValidationError(
`Invalid debounce delay: ${body.options.debounce.delay}. ` +
`Supported formats: {number}s, {number}m, {number}h, {number}d, {number}w`
`Supported formats: {number}s, {number}m, {number}h, {number}d, {number}w`
);
}
}
@@ -193,11 +193,11 @@ export class RunEngineTriggerTaskService {
// Get parent run if specified
const parentRun = body.options?.parentRunId
? await this.prisma.taskRun.findFirst({
where: {
id: RunId.fromFriendlyId(body.options.parentRunId),
runtimeEnvironmentId: environment.id,
},
})
where: {
id: RunId.fromFriendlyId(body.options.parentRunId),
runtimeEnvironmentId: environment.id,
},
})
: undefined;
// Validate parent run
@@ -231,21 +231,21 @@ export class RunEngineTriggerTaskService {
const lockedToBackgroundWorker = body.options?.lockToVersion
? await this.prisma.backgroundWorker.findFirst({
where: {
projectId: environment.projectId,
runtimeEnvironmentId: environment.id,
version: body.options?.lockToVersion,
},
select: {
id: true,
version: true,
sdkVersion: true,
cliVersion: true,
},
})
where: {
projectId: environment.projectId,
runtimeEnvironmentId: environment.id,
version: body.options?.lockToVersion,
},
select: {
id: true,
version: true,
sdkVersion: true,
cliVersion: true,
},
})
: undefined;
const { queueName, lockedQueueId, taskTtl } =
const { queueName, lockedQueueId, taskTtl, taskKind } =
await this.queueConcern.resolveQueueProperties(
triggerRequest,
lockedToBackgroundWorker ?? undefined
@@ -281,10 +281,10 @@ export class RunEngineTriggerTaskService {
const metadataPacket = body.options?.metadata
? handleMetadataPacket(
body.options?.metadata,
body.options?.metadataType ?? "application/json",
this.metadataMaximumSize
)
body.options?.metadata,
body.options?.metadataType ?? "application/json",
this.metadataMaximumSize
)
: undefined;
const tags = (
@@ -313,6 +313,7 @@ export class RunEngineTriggerTaskService {
triggerAction,
rootTriggerSource: parentAnnotations?.rootTriggerSource ?? triggerSource,
rootScheduleId: parentAnnotations?.rootScheduleId || options.scheduleId || undefined,
taskKind: taskKind ?? "STANDARD",
};
try {
@@ -369,9 +370,9 @@ export class RunEngineTriggerTaskService {
rootTaskRunId: parentRun?.rootTaskRunId ?? parentRun?.id,
batch: options?.batchId
? {
id: options.batchId,
index: options.batchIndex ?? 0,
}
id: options.batchId,
index: options.batchIndex ?? 0,
}
: undefined,
resumeParentOnCompletion: body.options?.resumeParentOnCompletion,
depth,
@@ -402,26 +403,26 @@ export class RunEngineTriggerTaskService {
onDebounced:
body.options?.debounce && body.options?.resumeParentOnCompletion
? async ({ existingRun, waitpoint, debounceKey }) => {
return await this.traceEventConcern.traceDebouncedRun(
triggerRequest,
parentRun?.taskEventStore,
{
existingRun,
debounceKey,
incomplete: waitpoint.status === "PENDING",
isError: waitpoint.outputIsError,
},
async (spanEvent) => {
const spanId =
options?.parentAsLinkType === "replay"
? spanEvent.spanId
: spanEvent.traceparent?.spanId
return await this.traceEventConcern.traceDebouncedRun(
triggerRequest,
parentRun?.taskEventStore,
{
existingRun,
debounceKey,
incomplete: waitpoint.status === "PENDING",
isError: waitpoint.outputIsError,
},
async (spanEvent) => {
const spanId =
options?.parentAsLinkType === "replay"
? spanEvent.spanId
: spanEvent.traceparent?.spanId
? `${spanEvent.traceparent.spanId}:${spanEvent.spanId}`
: spanEvent.spanId;
return spanId;
}
);
}
return spanId;
}
);
}
: undefined,
},
this.prisma
+18 -17
View File
@@ -37,18 +37,19 @@ export type TriggerTaskResult = {
export type QueueValidationResult =
| {
ok: true;
}
ok: true;
}
| {
ok: false;
maximumSize: number;
queueSize: number;
};
ok: false;
maximumSize: number;
queueSize: number;
};
export type QueueProperties = {
queueName: string;
lockedQueueId?: string;
taskTtl?: string | null;
taskKind?: string;
};
export type LockedBackgroundWorker = Pick<
@@ -98,22 +99,22 @@ export interface ParentRunValidationParams {
export type ValidationResult =
| {
ok: true;
}
ok: true;
}
| {
ok: false;
error: Error;
};
ok: false;
error: Error;
};
export type EntitlementValidationResult =
| {
ok: true;
plan?: ReportUsagePlan;
}
ok: true;
plan?: ReportUsagePlan;
}
| {
ok: false;
error: Error;
};
ok: false;
error: Error;
};
export interface TriggerTaskValidator {
validateTags(params: TagValidationParams): ValidationResult;
@@ -921,6 +921,7 @@ export class RunsReplicationService {
run.maxDurationInSeconds ?? null, // max_duration_in_seconds
annotations?.triggerSource ?? "", // trigger_source
annotations?.rootTriggerSource ?? "", // root_trigger_source
annotations?.taskKind ?? "", // task_kind
run.isWarmStart ?? null, // is_warm_start
];
}
@@ -151,6 +151,7 @@ export class ClickHouseRunsRepository implements IRunsRepository {
metadataType: true,
machinePreset: true,
queue: true,
annotations: true,
},
});
@@ -334,4 +335,22 @@ function applyRunFiltersToQueryBuilder<T>(
errorFingerprint: ErrorId.toId(options.errorId),
});
}
if (options.taskKinds && options.taskKinds.length > 0) {
const includesStandard = options.taskKinds.includes("STANDARD");
// Include empty string when filtering for STANDARD (default value for pre-existing runs)
const effectiveKinds = includesStandard
? [...options.taskKinds, ""]
: options.taskKinds;
if (effectiveKinds.length === 1) {
queryBuilder.where("task_kind = {taskKind: String}", {
taskKind: effectiveKinds[0]!,
});
} else {
queryBuilder.where("task_kind IN {taskKinds: Array(String)}", {
taskKinds: effectiveKinds,
});
}
}
}
@@ -42,6 +42,7 @@ const RunListInputOptionsSchema = z.object({
queues: z.array(z.string()).optional(),
machines: MachinePresetName.array().optional(),
errorId: z.string().optional(),
taskKinds: z.array(z.string()).optional(),
});
export type RunListInputOptions = z.infer<typeof RunListInputOptionsSchema>;
@@ -53,6 +54,7 @@ export type RunListInputFilters = Omit<
export type ParsedRunFilters = RunListInputFilters & {
cursor?: string;
direction?: "forward" | "backward";
sources?: string[];
};
export type FilterRunsOptions = Omit<RunListInputOptions, "period"> & {
@@ -102,6 +104,7 @@ export type ListedRun = Prisma.TaskRunGetPayload<{
metadataType: true;
machinePreset: true;
queue: true;
annotations: true;
};
}>;
+25
View File
@@ -314,6 +314,31 @@ export function v3TestTaskPath(
)}`;
}
export function v3PlaygroundPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath
) {
return `${v3EnvironmentPath(organization, project, environment)}/playground`;
}
export function v3PlaygroundAgentPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath,
agentSlug: string
) {
return `${v3PlaygroundPath(organization, project, environment)}/${encodeURIComponent(agentSlug)}`;
}
export function v3AgentsPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath
) {
return `${v3EnvironmentPath(organization, project, environment)}/agents`;
}
export function v3RunsPath(
organization: OrgForPath,
project: ProjectForPath,
@@ -288,6 +288,19 @@ async function createWorkerTask(
);
}
// @crumbs
console.log(`[crumbs:webapp] createWorkerTask task=${task.id} triggerSource=${task.triggerSource} agentConfig=${JSON.stringify(task.agentConfig)} taskKeys=${Object.keys(task).join(",")}`); // @crumbs
const resolvedTriggerSource =
task.triggerSource === "schedule"
? ("SCHEDULED" as const)
: task.triggerSource === "agent"
? ("AGENT" as const)
: ("STANDARD" as const);
// @crumbs
console.log(`[crumbs:webapp] createWorkerTask resolved triggerSource=${resolvedTriggerSource} for task=${task.id}`); // @crumbs
await prisma.backgroundWorkerTask.create({
data: {
friendlyId: generateFriendlyId("task"),
@@ -301,7 +314,8 @@ async function createWorkerTask(
retryConfig: task.retry,
queueConfig: task.queue,
machineConfig: task.machine,
triggerSource: task.triggerSource === "schedule" ? "SCHEDULED" : "STANDARD",
triggerSource: resolvedTriggerSource,
config: task.agentConfig ? (task.agentConfig as any) : undefined,
fileId: tasksToBackgroundFiles?.get(task.id) ?? null,
maxDurationInSeconds: task.maxDuration ? clampMaxDuration(task.maxDuration) : null,
ttl:
+1
View File
@@ -28,6 +28,7 @@
],
"dependencies": {
"@ai-sdk/openai": "^1.3.23",
"@ai-sdk/react": "^3.0.0",
"@ariakit/react": "^0.4.6",
"@ariakit/react-core": "^0.4.6",
"@aws-sdk/client-ecr": "^3.931.0",
@@ -0,0 +1,7 @@
-- +goose Up
ALTER TABLE trigger_dev.task_runs_v2
ADD COLUMN task_kind LowCardinality(String) DEFAULT '';
-- +goose Down
ALTER TABLE trigger_dev.task_runs_v2
DROP COLUMN task_kind;
@@ -51,6 +51,7 @@ export const TaskRunV2 = z.object({
max_duration_in_seconds: z.number().int().nullish(),
trigger_source: z.string().default(""),
root_trigger_source: z.string().default(""),
task_kind: z.string().default(""),
is_warm_start: z.boolean().nullish(),
_version: z.string(),
_is_deleted: z.number().int().default(0),
@@ -110,6 +111,7 @@ export const TASK_RUN_COLUMNS = [
"max_duration_in_seconds",
"trigger_source",
"root_trigger_source",
"task_kind",
"is_warm_start",
] as const;
@@ -176,6 +178,7 @@ export type TaskRunFieldTypes = {
max_duration_in_seconds: number | null;
trigger_source: string;
root_trigger_source: string;
task_kind: string;
is_warm_start: boolean | null;
};
@@ -313,6 +316,7 @@ export type TaskRunInsertArray = [
max_duration_in_seconds: number | null,
trigger_source: string,
root_trigger_source: string,
task_kind: string,
is_warm_start: boolean | null,
];
@@ -0,0 +1,5 @@
-- AlterEnum
ALTER TYPE "public"."TaskTriggerSource" ADD VALUE 'AGENT';
-- AlterTable
ALTER TABLE "public"."BackgroundWorkerTask" ADD COLUMN "config" JSONB;
@@ -0,0 +1,34 @@
-- CreateTable
CREATE TABLE "public"."PlaygroundConversation" (
"id" TEXT NOT NULL,
"chatId" TEXT NOT NULL,
"title" TEXT NOT NULL DEFAULT 'New conversation',
"agentSlug" TEXT NOT NULL,
"runId" TEXT,
"clientData" JSONB,
"projectId" TEXT NOT NULL,
"runtimeEnvironmentId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PlaygroundConversation_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "PlaygroundConversation_runtimeEnvironmentId_agentSlug_updat_idx" ON "public"."PlaygroundConversation"("runtimeEnvironmentId", "agentSlug", "updatedAt" DESC);
-- CreateIndex
CREATE INDEX "PlaygroundConversation_userId_runtimeEnvironmentId_idx" ON "public"."PlaygroundConversation"("userId", "runtimeEnvironmentId");
-- CreateIndex
CREATE UNIQUE INDEX "PlaygroundConversation_chatId_runtimeEnvironmentId_key" ON "public"."PlaygroundConversation"("chatId", "runtimeEnvironmentId");
-- AddForeignKey
ALTER TABLE "public"."PlaygroundConversation" ADD CONSTRAINT "PlaygroundConversation_runId_fkey" FOREIGN KEY ("runId") REFERENCES "public"."TaskRun"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."PlaygroundConversation" ADD CONSTRAINT "PlaygroundConversation_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "public"."Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."PlaygroundConversation" ADD CONSTRAINT "PlaygroundConversation_runtimeEnvironmentId_fkey" FOREIGN KEY ("runtimeEnvironmentId") REFERENCES "public"."RuntimeEnvironment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "public"."PlaygroundConversation" ADD COLUMN "lastEventId" TEXT,
ADD COLUMN "messages" JSONB;
@@ -376,7 +376,8 @@ model RuntimeEnvironment {
waitpointTags WaitpointTag[]
BulkActionGroup BulkActionGroup[]
customerQueries CustomerQuery[]
prompts Prompt[]
prompts Prompt[]
playgroundConversations PlaygroundConversation[]
errorGroupStates ErrorGroupState[]
taskIdentifiers TaskIdentifier[]
revokedApiKeys RevokedApiKey[]
@@ -460,6 +461,7 @@ model Project {
connectedGithubRepository ConnectedGithubRepository?
organizationProjectIntegration OrganizationProjectIntegration[]
customerQueries CustomerQuery[]
playgroundConversations PlaygroundConversation[]
buildSettings Json?
onboardingData Json?
@@ -696,6 +698,10 @@ model BackgroundWorkerTask {
triggerSource TaskTriggerSource @default(STANDARD)
/// Extra task configuration JSON. Shape depends on triggerSource.
/// AGENT: { type: "ai-sdk-chat" }
config Json?
payloadSchema Json?
@@unique([workerId, slug])
@@ -708,6 +714,49 @@ model BackgroundWorkerTask {
enum TaskTriggerSource {
STANDARD
SCHEDULED
AGENT
}
model PlaygroundConversation {
id String @id @default(cuid())
/// The chat session ID used by the transport
chatId String
/// User-editable conversation title (auto-generated from first message)
title String @default("New conversation")
/// Which agent this conversation is with
agentSlug String
/// The current active run backing this conversation (null if no run yet)
runId String?
run TaskRun? @relation(fields: [runId], references: [id], onDelete: SetNull, onUpdate: Cascade)
/// The client data JSON used for this conversation
clientData Json?
/// Accumulated UIMessages from completed turns (for resume without stream replay)
messages Json?
/// Last SSE event ID — resume from this position to avoid replaying old turns
lastEventId String?
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade)
projectId String
runtimeEnvironment RuntimeEnvironment @relation(fields: [runtimeEnvironmentId], references: [id], onDelete: Cascade, onUpdate: Cascade)
runtimeEnvironmentId String
/// The user who started this conversation
userId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([chatId, runtimeEnvironmentId])
@@index([runtimeEnvironmentId, agentSlug, updatedAt(sort: Desc)])
@@index([userId, runtimeEnvironmentId])
}
/// Durable, typed, bidirectional I/O primitive. Owns two S2 streams (.out / .in).
@@ -1011,6 +1060,8 @@ model TaskRun {
/// (OSS, or pre-backfill); reads fall back to the global basin.
streamBasinName String?
playgroundConversations PlaygroundConversation[]
@@unique([oneTimeUseToken])
@@unique([runtimeEnvironmentId, taskIdentifier, idempotencyKey])
// Finding child runs
+19
View File
@@ -1,3 +1,5 @@
import { trail } from "agentcrumbs"; // @crumbs
const _cliCrumb = trail("cli"); // @crumbs
import { spawn, type ChildProcess } from "node:child_process";
import { readFileSync, writeFileSync, renameSync, unlinkSync, existsSync, mkdirSync } from "node:fs";
import { join } from "node:path";
@@ -345,6 +347,23 @@ class DevSupervisor implements WorkerRuntime {
const sourceFiles = resolveSourceFiles(manifest.sources, backgroundWorker.manifest.tasks);
// #region @crumbs
const _agentTasksSupervisor = (backgroundWorker.manifest.tasks as any[]).filter(
(t: any) => t.triggerSource || t.agentConfig
);
_cliCrumb("devSupervisor sending worker metadata to API", {
totalTasks: backgroundWorker.manifest.tasks.length,
agentTasks: _agentTasksSupervisor.map((t: any) => ({
id: t.id,
triggerSource: t.triggerSource,
agentConfig: t.agentConfig,
})),
manifestTaskKeys: backgroundWorker.manifest.tasks[0]
? Object.keys(backgroundWorker.manifest.tasks[0])
: [],
});
// #endregion @crumbs
const backgroundWorkerBody: CreateBackgroundWorkerRequestBody = {
localOnly: true,
metadata: {
@@ -1,3 +1,5 @@
import { trail } from "agentcrumbs"; // @crumbs
const _cliCrumb = trail("cli"); // @crumbs
import {
BuildManifest,
type HandleErrorFunction,
@@ -119,6 +121,18 @@ const { buildManifest, importErrors, config, timings } = await bootstrap();
let tasks = await convertSchemasToJsonSchemas(resourceCatalog.listTaskManifests());
// #region @crumbs
const _agentTasks = tasks.filter((t: any) => t.triggerSource || t.agentConfig);
_cliCrumb("dev-index-worker tasks after listTaskManifests", {
totalTasks: tasks.length,
agentTasks: _agentTasks.map((t: any) => ({
id: t.id,
triggerSource: t.triggerSource,
agentConfig: t.agentConfig,
})),
});
// #endregion @crumbs
// If the config has retry defaults, we need to apply them to all tasks that don't have any retry settings
if (config.retries?.default) {
tasks = tasks.map((task) => {
@@ -1,3 +1,5 @@
import { trail } from "agentcrumbs"; // @crumbs
const _coreCrumb = trail("core"); // @crumbs
import {
PromptManifest,
PromptMetadata,
@@ -73,6 +75,15 @@ export class StandardResourceCatalog implements ResourceCatalog {
return;
}
// #region @crumbs
_coreCrumb("registerTaskMetadata", {
taskId: task.id,
triggerSource: metadata.triggerSource,
agentConfig: metadata.agentConfig,
metadataKeys: Object.keys(metadata),
});
// #endregion @crumbs
this._taskFileMetadata.set(task.id, {
...this._currentFileContext,
});
@@ -86,25 +97,31 @@ export class StandardResourceCatalog implements ResourceCatalog {
}
updateTaskMetadata(id: string, updates: Partial<TaskMetadataWithFunctions>): void {
const { fns, schema, ...metadataUpdates } = updates;
const existingMetadata = this._taskMetadata.get(id);
if (existingMetadata) {
if (existingMetadata && Object.keys(metadataUpdates).length > 0) {
this._taskMetadata.set(id, {
...existingMetadata,
...updates,
...metadataUpdates,
});
}
if (updates.fns) {
if (fns) {
const existingFunctions = this._taskFunctions.get(id);
if (existingFunctions) {
this._taskFunctions.set(id, {
...existingFunctions,
...updates.fns,
...fns,
});
}
}
if (schema) {
this._taskSchemas.set(id, schema);
}
}
// Return all the tasks, without the functions
@@ -123,6 +140,18 @@ export class StandardResourceCatalog implements ResourceCatalog {
...fileMetadata,
};
// #region @crumbs
if (metadata.triggerSource || metadata.agentConfig) {
_coreCrumb("listTaskManifests building manifest", {
taskId: id,
triggerSource: metadata.triggerSource,
agentConfig: metadata.agentConfig,
manifestTriggerSource: taskManifest.triggerSource,
manifestAgentConfig: (taskManifest as any).agentConfig,
});
}
// #endregion @crumbs
result.push(taskManifest);
}
+1
View File
@@ -1115,6 +1115,7 @@ const CommonRunFields = {
baseCostInCents: z.number(),
durationMs: z.number(),
metadata: z.record(z.any()).optional(),
taskKind: z.string().optional(),
};
const RetrieveRunCommandFields = {
@@ -2,6 +2,12 @@ import { z } from "zod";
import { QueueManifest, RetryOptions, ScheduleMetadata } from "./schemas.js";
import { MachineConfig } from "./common.js";
export const AgentConfig = z.object({
type: z.string(), // "ai-sdk-chat" initially, extensible for future agent types
});
export type AgentConfig = z.infer<typeof AgentConfig>;
export const TaskResource = z.object({
id: z.string(),
description: z.string().optional(),
@@ -11,6 +17,7 @@ export const TaskResource = z.object({
retry: RetryOptions.optional(),
machine: MachineConfig.optional(),
triggerSource: z.string().optional(),
agentConfig: AgentConfig.optional(),
schedule: ScheduleMetadata.optional(),
maxDuration: z.number().optional(),
ttl: z.string().or(z.number().nonnegative().int()).optional(),
@@ -15,11 +15,15 @@ export const TriggerAction = z.enum(["trigger", "replay", "test"]).or(anyString)
export type TriggerAction = z.infer<typeof TriggerAction>;
export const TaskKind = z.enum(["STANDARD", "SCHEDULED", "AGENT"]).or(anyString);
export type TaskKind = z.infer<typeof TaskKind>;
export const RunAnnotations = z.object({
triggerSource: TriggerSource,
triggerAction: TriggerAction,
rootTriggerSource: TriggerSource,
rootScheduleId: z.string().optional(),
taskKind: TaskKind.optional(),
});
export type RunAnnotations = z.infer<typeof RunAnnotations>;
+5
View File
@@ -180,6 +180,10 @@ export const ScheduleMetadata = z.object({
environments: z.array(EnvironmentType).optional(),
});
const AgentConfig = z.object({
type: z.string(),
});
const taskMetadata = {
id: z.string(),
description: z.string().optional(),
@@ -187,6 +191,7 @@ const taskMetadata = {
retry: RetryOptions.optional(),
machine: MachineConfig.optional(),
triggerSource: z.string().optional(),
agentConfig: AgentConfig.optional(),
schedule: ScheduleMetadata.optional(),
maxDuration: z.number().optional(),
ttl: z.string().or(z.number().nonnegative().int()).optional(),
+6
View File
@@ -387,6 +387,12 @@ type CommonTaskOptions<
* Should be a valid JSON Schema Draft 7 object.
*/
jsonSchema?: JSONSchema;
/** @internal Set by SDK internals (e.g. `chat.agent()`, `schedules.task()`). */
triggerSource?: string;
/** @internal Agent configuration, only set when `triggerSource` is `"agent"`. */
agentConfig?: { type: string };
};
export type TaskOptions<
File diff suppressed because it is too large Load Diff
+29 -5
View File
@@ -4,7 +4,7 @@
* Browser-safe module for AI SDK chat transport integration.
* Use this on the frontend with the AI SDK's `useChat` hook.
*
* For backend helpers (`chatTask`, `pipeChat`), use `@trigger.dev/sdk/ai` instead.
* For backend helpers (`chatAgent`, `pipeChat`), use `@trigger.dev/sdk/ai` instead.
*
* @example
* ```tsx
@@ -104,7 +104,7 @@ export type TriggerChatTaskResult = {
type TriggerChatTransportOptionsBase<TClientData = unknown> = {
/**
* The Trigger.dev task ID to trigger for chat completions.
* This task should be defined using `chatTask()` from `@trigger.dev/sdk/ai`,
* This task should be defined using `chatAgent()` from `@trigger.dev/sdk/ai`,
* or a regular `task()` that uses `pipeChat()`.
*/
task: string;
@@ -117,7 +117,7 @@ type TriggerChatTransportOptionsBase<TClientData = unknown> = {
/**
* The stream key where the task pipes UIMessageChunk data.
* When using `chatTask()` or `pipeChat()`, this is handled automatically.
* When using `chatAgent()` or `pipeChat()`, this is handled automatically.
* Only set this if you're using a custom stream key.
*
* @default "chat"
@@ -664,6 +664,22 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
this.triggerTaskFn = fn;
}
/**
* Inject or update a session for a chat. Useful for resuming conversations
* from persisted state without recreating the transport.
*/
setSession(
chatId: string,
session: { runId: string; publicAccessToken: string; lastEventId?: string }
): void {
this.sessions.set(chatId, {
runId: session.runId,
publicAccessToken: session.publicAccessToken,
lastEventId: session.lastEventId,
});
this.notifySessionChange(chatId, this.sessions.get(chatId)!);
}
/**
* Eagerly trigger a run for a chat before the first message is sent.
* This allows initialization (DB setup, context loading) to happen
@@ -676,15 +692,23 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
*
* No-op if a session already exists for this chatId.
*/
async preload(chatId: string, options?: { idleTimeoutInSeconds?: number }): Promise<void> {
async preload(
chatId: string,
options?: { idleTimeoutInSeconds?: number; metadata?: Record<string, unknown> }
): Promise<void> {
// Don't preload if session already exists
if (this.sessions.get(chatId)?.runId) return;
const mergedMetadata =
this.defaultMetadata || options?.metadata
? { ...(this.defaultMetadata ?? {}), ...(options?.metadata ?? {}) }
: undefined;
const payload = {
messages: [] as never[],
chatId,
trigger: "preload" as const,
metadata: this.defaultMetadata,
metadata: mergedMetadata,
...(options?.idleTimeoutInSeconds !== undefined
? { idleTimeoutInSeconds: options.idleTimeoutInSeconds }
: {}),
+17
View File
@@ -1,4 +1,6 @@
import { SpanKind } from "@opentelemetry/api";
import { trail } from "agentcrumbs"; // @crumbs
const _sdkCrumb = trail("sdk"); // @crumbs
import { SerializableJson } from "@trigger.dev/core";
import {
accessoryAttributes,
@@ -250,12 +252,25 @@ export function createTask<
registerTaskLifecycleHooks(params.id, params);
// #region @crumbs
_sdkCrumb("createTask registerTaskMetadata", {
taskId: params.id,
triggerSource: params.triggerSource,
agentConfig: params.agentConfig,
hasTriggerSource: "triggerSource" in params,
hasAgentConfig: "agentConfig" in params,
paramKeys: Object.keys(params).filter((k: string) => k.includes("trigger") || k.includes("agent") || k.includes("config")),
});
// #endregion @crumbs
resourceCatalog.registerTaskMetadata({
id: params.id,
description: params.description,
queue: params.queue,
retry: params.retry ? { ...defaultRetryOptions, ...params.retry } : undefined,
machine: typeof params.machine === "string" ? { preset: params.machine } : params.machine,
triggerSource: params.triggerSource,
agentConfig: params.agentConfig,
maxDuration: params.maxDuration,
ttl: params.ttl,
payloadSchema: params.jsonSchema,
@@ -408,6 +423,8 @@ export function createSchemaTask<
queue: params.queue,
retry: params.retry ? { ...defaultRetryOptions, ...params.retry } : undefined,
machine: typeof params.machine === "string" ? { preset: params.machine } : params.machine,
triggerSource: params.triggerSource,
agentConfig: params.agentConfig,
maxDuration: params.maxDuration,
ttl: params.ttl,
fns: {
+16 -3
View File
@@ -242,6 +242,9 @@ importers:
'@ai-sdk/openai':
specifier: ^1.3.23
version: 1.3.23(zod@3.25.76)
'@ai-sdk/react':
specifier: ^3.0.0
version: 3.0.170(react@18.2.0)(zod@3.25.76)
'@ariakit/react':
specifier: ^0.4.6
version: 0.4.6(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
@@ -21402,6 +21405,16 @@ snapshots:
optionalDependencies:
zod: 3.25.76
'@ai-sdk/react@3.0.170(react@18.2.0)(zod@3.25.76)':
dependencies:
'@ai-sdk/provider-utils': 4.0.23(zod@3.25.76)
ai: 6.0.168(zod@3.25.76)
react: 18.2.0
swr: 2.2.5(react@18.2.0)
throttleit: 2.1.0
transitivePeerDependencies:
- zod
'@ai-sdk/react@3.0.170(react@19.1.0)(zod@3.25.76)':
dependencies:
'@ai-sdk/provider-utils': 4.0.23(zod@3.25.76)
@@ -24881,9 +24894,9 @@ snapshots:
dependencies:
react: 18.2.0
'@hono/node-server@1.12.2(hono@4.5.11)':
'@hono/node-server@1.12.2(hono@4.12.15)':
dependencies:
hono: 4.5.11
hono: 4.12.15
'@hono/node-server@1.19.11(hono@4.12.15)':
dependencies:
@@ -24899,7 +24912,7 @@ snapshots:
'@hono/node-ws@1.0.4(@hono/node-server@1.12.2(hono@4.5.11))(bufferutil@4.0.9)':
dependencies:
'@hono/node-server': 1.12.2(hono@4.5.11)
'@hono/node-server': 1.12.2(hono@4.12.15)
ws: 8.18.3(bufferutil@4.0.9)
transitivePeerDependencies:
- bufferutil
@@ -119,7 +119,7 @@ export function ChatSidebar({
onChange={(e) => onTaskModeChange(e.target.value)}
className="flex-1 rounded border border-gray-300 px-1.5 py-0.5 text-xs text-gray-600 outline-none focus:border-blue-500"
>
<option value="ai-chat">ai-chat (chat.task)</option>
<option value="ai-chat">ai-chat (chat.agent)</option>
<option value="ai-chat-raw">ai-chat-raw (raw task)</option>
<option value="ai-chat-session">ai-chat-session (session)</option>
</select>
+1 -1
View File
@@ -308,7 +308,7 @@ export const executeJs = tool({
},
});
/** Tool set passed to `streamText` for the main `chat.task` run (includes PostHog). */
/** Tool set passed to `streamText` for the main `chat.agent` run (includes PostHog). */
export const chatTools = {
inspectEnvironment,
webFetch,
+4 -4
View File
@@ -1,11 +1,11 @@
/**
* E2B sandboxes keyed by Trigger run id.
*
* - Warmed from `chat.task` `onTurnStart` (non-blocking) so the first `executeCode` tool call is faster.
* - Disposed in task `onWait` when `wait.type === "token"` (input-stream suspend, same path as `wait.for` tokens).
* - `onComplete` disposes any leftover sandbox if the run ends without hitting another token wait.
* - Warmed from `chat.agent` `onTurnStart` (non-blocking) so the first `executeCode` tool call is faster.
* - Disposed in `onChatSuspend` before the run suspends waiting for the next message.
* - `onComplete` disposes any leftover sandbox if the run ends without hitting another suspend.
*
* No extra `chat.task` SDK hook is required for the suspend boundary platform `onWait` is sufficient.
* No extra SDK hook is required beyond `onChatSuspend` and `onComplete`.
*/
import { chat } from "@trigger.dev/sdk/ai";
import { Sandbox } from "@e2b/code-interpreter";
+3 -3
View File
@@ -146,7 +146,7 @@ const userContext = chat.local<{
// #endregion
// ============================================================================
// chat.task — the main chat agent
// chat.agent — the main chat agent
// ============================================================================
export const aiChat = chat
@@ -172,7 +172,7 @@ export const aiChat = chat
.onChatResume(async ({ phase, ctx }) => {
logger.debug("Chat resumed", { phase, runId: ctx.run.id });
})
.task({
.agent({
id: "ai-chat",
idleTimeoutInSeconds: 60,
chatAccessTokenTTL: "1m",
@@ -442,7 +442,7 @@ export const aiChat = chat
},
// #endregion
// #region run — just return streamText(), chat.task handles everything else
// #region run — just return streamText(), chat.agent handles everything else
run: async ({ messages, clientData, stopSignal }) => {
userContext.messageCount++;
if (clientData?.model) {
+61 -2
View File
@@ -1,17 +1,76 @@
import { defineConfig } from "@trigger.dev/sdk";
import { prismaExtension } from "@trigger.dev/build/extensions/prisma";
import { secureExec } from "@trigger.dev/build/extensions/secureExec";
import { esbuildPlugin } from "@trigger.dev/build/extensions";
import { createRequire } from "node:module";
import fs from "node:fs";
import path from "node:path";
export default defineConfig({
project: process.env.TRIGGER_PROJECT_REF!,
dirs: ["./src/trigger"],
maxDuration: 3600,
runtime: "node-22",
build: {
extensions: [
prismaExtension({
mode: "modern",
}),
secureExec(),
// Trigger's ESM shim anchors require.resolve() to the chunk path, so
// node-stdlib-browser's runtime require.resolve("./mock/empty.js") breaks.
// Fix: load the real node-stdlib-browser at build time (where require.resolve
// works), capture the resolved path map, and inline it as a static export.
esbuildPlugin({
name: "node-stdlib-browser-stub",
setup(build) {
build.onResolve({ filter: /^node-stdlib-browser$/ }, () => ({
path: "node-stdlib-browser",
namespace: "nsb-resolved",
}));
build.onLoad({ filter: /.*/, namespace: "nsb-resolved" }, () => {
const buildRequire = createRequire(import.meta.url);
const resolved = buildRequire("node-stdlib-browser");
return {
contents: `export default ${JSON.stringify(resolved)};`,
loader: "js",
};
});
},
}),
// @secure-exec/node's bridge-loader.js runs require.resolve("@secure-exec/core")
// at module scope to locate dist/bridge.js on disk. This fails in Trigger's
// Docker container where the code is bundled into chunks and the package
// isn't on disk. Fix: inline bridge.js content at build time so no runtime
// filesystem access or package resolution is needed.
esbuildPlugin({
name: "inline-secure-exec-bridge",
setup(build) {
build.onLoad(
{ filter: /[\\/]@secure-exec[\\/]node[\\/]dist[\\/]bridge-loader\.js$/ },
(args) => {
const buildRequire = createRequire(args.path);
const coreEntry = buildRequire.resolve("@secure-exec/core");
const coreRoot = path.resolve(path.dirname(coreEntry), "..");
const bridgeCode = fs.readFileSync(path.join(coreRoot, "dist", "bridge.js"), "utf8");
return {
contents: [
`import { getIsolateRuntimeSource } from "@secure-exec/core";`,
`const bridgeCodeCache = ${JSON.stringify(bridgeCode)};`,
`export function getRawBridgeCode() { return bridgeCodeCache; }`,
`export function getBridgeAttachCode() { return getIsolateRuntimeSource("bridgeAttach"); }`,
].join("\n"),
loader: "js",
};
},
);
},
}),
],
external: [
// esbuild must not be bundled — it locates its native binary via a
// relative path from its JS API entry point. secure-exec uses esbuild
// at runtime to bundle polyfills for sandbox code.
"esbuild",
],
keepNames: false,
},
});
@@ -0,0 +1,56 @@
import { chat } from "@trigger.dev/sdk/ai";
import { prompts } from "@trigger.dev/sdk";
import { streamText, createProviderRegistry } from "ai";
import { openai } from "@ai-sdk/openai";
import { z } from "zod";
const registry = createProviderRegistry({ openai });
type RegistryModelId = Parameters<typeof registry.languageModel>[0];
const systemPrompt = prompts.define({
id: "test-agent-system",
model: "openai:gpt-4o-mini" satisfies RegistryModelId,
config: { temperature: 0.7 },
variables: z.object({ userId: z.string() }),
content: `You are a helpful AI assistant in the Trigger.dev playground.
The current user is {{userId}}.
## Guidelines
- Be concise and friendly. Prefer short, direct answers.
- Use markdown formatting for code blocks and lists.
- If you don't know something, say so.`,
});
export const testAgent = chat
.withClientData({
schema: z.object({
userId: z.string().optional().default("anonymous"),
model: z.string().optional().default("openai:gpt-4o-mini"),
}),
})
.onChatStart(async ({ clientData }) => {
const resolved = await systemPrompt.resolve({
userId: clientData?.userId ?? "anonymous",
});
chat.prompt.set(resolved);
})
.agent({
id: "test-agent",
run: async ({ messages, clientData, signal }) => {
// chat.toStreamTextOptions({ registry }) resolves the prompt's model via
// the registry and injects system prompt + telemetry automatically
const model = registry.languageModel(clientData?.model ? (clientData.model as RegistryModelId) : "openai:gpt-4o-mini")
if (!model) {
throw new Error("Model not found");
}
return streamText({
...chat.toStreamTextOptions({ registry }),
model,
messages,
abortSignal: signal,
});
},
});