trigger.dev
Version:
A Command-Line Interface for Trigger.dev projects
427 lines • 19.9 kB
JavaScript
import { z } from "zod";
import { controlSubtype, SSEStreamSubscription, TRIGGER_CONTROL_SUBTYPE, } from "@trigger.dev/core/v3";
import { toolsMetadata } from "../config.js";
import { CommonProjectsInput } from "../schemas.js";
import { respondWithError, toolHandler } from "../utils.js";
const activeSessions = new Map();
function serializeInputChunk(chunk) {
return JSON.stringify(chunk);
}
// ─── Start Agent Chat ─────────────────────────────────────────────
const StartAgentChatInput = CommonProjectsInput.extend({
agentId: z
.string()
.describe("The agent task ID to chat with. Use get_current_worker to see available agents."),
chatId: z
.string()
.describe("A unique conversation ID. Reuse to resume a conversation.")
.optional(),
clientData: z
.record(z.unknown())
.describe("Client data to include with every message (e.g. userId, model).")
.optional(),
preload: z
.boolean()
.describe("Whether to preload the agent before the first message.")
.default(true),
});
export const startAgentChatTool = {
name: toolsMetadata.start_agent_chat.name,
title: toolsMetadata.start_agent_chat.title,
description: toolsMetadata.start_agent_chat.description,
inputSchema: StartAgentChatInput.shape,
handler: toolHandler(StartAgentChatInput.shape, async (input, { ctx }) => {
ctx.logger?.log("calling start_agent_chat", { input });
if (ctx.options.devOnly && input.environment !== "dev") {
return respondWithError(`This MCP server is only available for the dev environment.`);
}
const projectRef = await ctx.getProjectRef({
projectRef: input.projectRef,
cwd: input.configPath,
});
const apiClient = await ctx.getApiClient({
projectRef,
environment: input.environment,
scopes: ["write:tasks", "read:runs", "read:sessions", "write:sessions"],
branch: input.branch,
});
const chatId = input.chatId ?? crypto.randomUUID();
// Check if session already exists
if (activeSessions.has(chatId)) {
return {
content: [
{
type: "text",
text: `Chat ${chatId} is already active with agent ${activeSessions.get(chatId).agentId}. Use send_agent_message to continue the conversation.`,
},
],
};
}
// Create (or upsert) the backing Session. Idempotent via externalId —
// two MCP clients targeting the same chatId converge to the same row.
// Sessions are now task-bound: taskIdentifier + triggerConfig are
// required, and the server reuses them for every run scheduled by
// this session (initial + continuations after run termination).
//
// basePayload mirrors the browser-mediated `chat.createStartSessionAction`
// shape so the auto-triggered first run hits `onPreload` (not
// `onChatStart` with `preloaded: true`). Without `trigger: "preload"`
// + `messages: []`, the agent runtime bypasses both lifecycle hooks
// and `onTurnStart`'s DB write fails with "No record found".
//
// POST /api/v1/sessions auto-triggers the first run and returns its
// runId, so we don't need a separate triggerTask call. The `preload`
// flag on this MCP tool is kept as a no-op signal (true=default) for
// backwards compat — a Session is always created with a live run now.
const session = await apiClient.createSession({
type: "chat.agent",
externalId: chatId,
taskIdentifier: input.agentId,
triggerConfig: {
basePayload: {
messages: [],
trigger: "preload",
chatId,
...(input.clientData ? { metadata: input.clientData } : {}),
},
tags: [`chat:${chatId}`],
},
});
activeSessions.set(chatId, {
sessionId: session.id,
runId: session.runId,
chatId,
agentId: input.agentId,
apiClient,
clientData: input.clientData,
messages: [],
});
return {
content: [
{
type: "text",
text: [
`Agent chat started${input.preload ? " and preloaded" : ""}.`,
`- Chat ID: ${chatId}`,
`- Session ID: ${session.id}`,
`- Agent: ${input.agentId}`,
`- Run ID: ${session.runId}`,
``,
`Use send_agent_message with chatId "${chatId}" to send messages.`,
].join("\n"),
},
],
};
}),
};
// ─── Send Agent Message ───────────────────────────────────────────
const SendAgentMessageInput = z.object({
chatId: z.string().describe("The chat ID from start_agent_chat."),
message: z.string().describe("The message to send to the agent."),
});
export const sendAgentMessageTool = {
name: toolsMetadata.send_agent_message.name,
title: toolsMetadata.send_agent_message.title,
description: toolsMetadata.send_agent_message.description,
inputSchema: SendAgentMessageInput.shape,
handler: toolHandler(SendAgentMessageInput.shape, async (input, { ctx }) => {
ctx.logger?.log("calling send_agent_message", { input });
const session = activeSessions.get(input.chatId);
if (!session) {
return respondWithError(`No active chat with ID "${input.chatId}". Use start_agent_chat first.`);
}
const msgId = `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const userMessage = {
id: msgId,
role: "user",
parts: [{ type: "text", text: input.message }],
};
// Track the outgoing user message
session.messages.push(userMessage);
// Slim-wire: one delta `message` per trigger. Prior turns live in the
// session.out snapshot+replay; we only ship the new user message.
const wirePayload = {
message: userMessage,
chatId: session.chatId,
trigger: "submit-message",
metadata: session.clientData,
};
// If we have an active run, send via session.in. If that fails
// (run ended, token expired, etc.) fall back to triggering a new
// run on the same session — the new run replays prior turns from the
// snapshot and picks up `message` as turn N's user delta.
if (session.runId) {
try {
await session.apiClient.appendToSessionStream(session.sessionId, "in", serializeInputChunk({ kind: "message", payload: wirePayload }));
}
catch (sendErr) {
ctx.logger?.log("appendToSessionStream failed, falling back to triggerTask", {
chatId: session.chatId,
sessionId: session.sessionId,
error: sendErr?.message ?? String(sendErr),
});
const result = await session.apiClient.triggerTask(session.agentId, {
payload: {
message: userMessage,
chatId: session.chatId,
sessionId: session.sessionId,
trigger: "submit-message",
metadata: session.clientData,
continuation: true,
previousRunId: session.runId,
},
options: {
payloadType: "application/json",
tags: [`chat:${session.chatId}`],
},
});
session.runId = result.id;
// Keep session.lastEventId as-is. The .out stream is per-session, so
// resuming from the last-seen chunk's id skips historical chunks —
// including stale `trigger:turn-complete` markers from prior turns
// that would otherwise break collectAgentResponse's read loop with
// empty/old text. Same reasoning as the trigger:upgrade-required
// path below.
}
}
else {
// No run yet — trigger one (agent opens the session on startup).
const result = await session.apiClient.triggerTask(session.agentId, {
payload: {
...wirePayload,
sessionId: session.sessionId,
},
options: {
payloadType: "application/json",
tags: [`chat:${session.chatId}`],
},
});
session.runId = result.id;
}
// Subscribe to the response stream and collect the full text
const { text, toolCalls, assistantMessage } = await collectAgentResponse(session);
// Track the assistant response for continuation payloads
session.messages.push(assistantMessage);
const formatted = formatAssistantParts(assistantMessage.parts);
const footer = `\n\n---\nRun: ${session.runId}`;
return {
content: [{ type: "text", text: formatted + footer }],
};
}),
};
// ─── Close Agent Chat ─────────────────────────────────────────────
const CloseAgentChatInput = z.object({
chatId: z.string().describe("The chat ID to close."),
});
export const closeAgentChatTool = {
name: toolsMetadata.close_agent_chat.name,
title: toolsMetadata.close_agent_chat.title,
description: toolsMetadata.close_agent_chat.description,
inputSchema: CloseAgentChatInput.shape,
handler: toolHandler(CloseAgentChatInput.shape, async (input, { ctx }) => {
ctx.logger?.log("calling close_agent_chat", { input });
const session = activeSessions.get(input.chatId);
if (!session) {
return respondWithError(`No active chat with ID "${input.chatId}".`);
}
if (session.runId) {
try {
await session.apiClient.appendToSessionStream(session.sessionId, "in", serializeInputChunk({
kind: "message",
payload: {
// `trigger: "close"` carries no message delta — the agent
// looks at `trigger` and exits without touching `message`.
chatId: session.chatId,
trigger: "close",
},
}));
}
catch {
// Best effort — run may already be done
}
}
activeSessions.delete(input.chatId);
return {
content: [
{
type: "text",
text: `Chat ${input.chatId} closed.`,
},
],
};
}),
};
// ─── Stream collector ─────────────────────────────────────────────
// Safety bound on chained upgrades during a single send. A misconfigured
// agent or upgrade-loop bug would otherwise grow the call stack without limit.
const MAX_UPGRADE_RECURSION_DEPTH = 10;
async function collectAgentResponse(session, depth = 0) {
if (depth > MAX_UPGRADE_RECURSION_DEPTH) {
throw new Error(`Agent upgrade recursion depth exceeded (${depth} chained trigger:upgrade-required signals)`);
}
const baseURL = session.apiClient.baseUrl;
const streamUrl = `${baseURL}/realtime/v1/sessions/${encodeURIComponent(session.sessionId)}/out`;
const subscription = new SSEStreamSubscription(streamUrl, {
headers: {
Authorization: `Bearer ${session.apiClient.accessToken}`,
},
timeoutInSeconds: 120,
lastEventId: session.lastEventId,
});
const sseStream = await subscription.subscribe();
const reader = sseStream.getReader();
let text = "";
const toolCalls = [];
const parts = [];
// Track current text part to accumulate deltas
let currentTextId;
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
if (value.id) {
session.lastEventId = value.id;
}
// Trigger control records (turn-complete, upgrade-required) ride
// on headers — see `client-protocol.mdx#records-on-session-out`.
// Data records carry UIMessageChunks on `value.chunk`.
//
// Cross-version bridge: an agent SDK that hasn't been redeployed
// yet still writes turn-complete / upgrade-required as
// `chunk.type` data records. Map those into `controlValue` so the
// existing break / continuation paths fire for both shapes.
let controlValue = controlSubtype(value.headers);
if (!controlValue && value.chunk && typeof value.chunk === "object") {
const chunk = value.chunk;
if (chunk.type === "trigger:turn-complete") {
controlValue = TRIGGER_CONTROL_SUBTYPE.TURN_COMPLETE;
}
else if (chunk.type === "trigger:upgrade-required") {
controlValue = TRIGGER_CONTROL_SUBTYPE.UPGRADE_REQUIRED;
}
else if (typeof chunk.type === "string" && chunk.type.startsWith("trigger:")) {
// Unknown legacy `trigger:*` type — drop so it doesn't reach
// the chunk handler as a UIMessageChunk.
continue;
}
}
if (controlValue === TRIGGER_CONTROL_SUBTYPE.TURN_COMPLETE) {
break;
}
if (controlValue === TRIGGER_CONTROL_SUBTYPE.UPGRADE_REQUIRED) {
// Agent requested upgrade — trigger continuation. Same session,
// new run — reuse sessionId, swap runId. Slim-wire: ship only
// the latest user message as the turn-N delta; prior turns
// come back via snapshot+replay on the new run's boot.
const lastUserMessage = [...session.messages].reverse().find((m) => m.role === "user");
const previousRunId = session.runId;
const result = await session.apiClient.triggerTask(session.agentId, {
payload: {
message: lastUserMessage,
chatId: session.chatId,
sessionId: session.sessionId,
trigger: "submit-message",
metadata: session.clientData,
continuation: true,
previousRunId,
},
options: {
payloadType: "application/json",
tags: [`chat:${session.chatId}`],
},
});
session.runId = result.id;
// Keep session.lastEventId pointing at the upgrade-required
// record's seq (set above when the part arrived). The recursive
// subscribe resumes right after that marker, so we don't replay
// the entire session.out stream — which would hit a historical
// turn-complete and break the loop with empty/old text. The outer
// `finally` block releases the reader before the recursion runs.
return collectAgentResponse(session, depth + 1);
}
// v2 (session) SSE already parses record.body.data, so `chunk` is
// the UIMessageChunk object written by the agent. Any legacy
// `trigger:*` data record was already mapped to `controlValue`
// (and either broke the loop, triggered continuation, or got
// dropped) above; we only see real UIMessageChunks here.
if (value.chunk != null && typeof value.chunk === "object") {
const chunk = value.chunk;
if (chunk.type === "text-delta" && typeof chunk.delta === "string") {
text += chunk.delta;
// Accumulate into a text part
const textId = chunk.id ?? "text";
if (currentTextId !== textId) {
currentTextId = textId;
parts.push({ type: "text", text: chunk.delta });
}
else {
const last = parts[parts.length - 1];
if (last && last.type === "text") {
last.text = last.text + chunk.delta;
}
}
}
if (chunk.type === "tool-input-available" && typeof chunk.toolName === "string") {
toolCalls.push(chunk.toolName);
parts.push({
type: `tool-${chunk.toolName}`,
toolCallId: chunk.toolCallId,
toolName: chunk.toolName,
state: "input-available",
input: chunk.input,
});
}
if (chunk.type === "tool-output-available" && typeof chunk.toolCallId === "string") {
// Update existing tool part with output
const toolPart = parts.find((p) => p.toolCallId === chunk.toolCallId);
if (toolPart) {
toolPart.state = "output-available";
toolPart.output = chunk.output;
}
}
}
}
}
finally {
reader.releaseLock();
}
const assistantMessage = {
id: `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
role: "assistant",
parts: parts.length > 0 ? parts : [{ type: "text", text }],
};
return { text, toolCalls, assistantMessage };
}
// ─── Response formatter ──────────────────────────────────────────
function formatAssistantParts(parts) {
const sections = [];
for (const part of parts) {
if (part.type === "text" && typeof part.text === "string" && part.text) {
sections.push(part.text);
}
else if (part.type.startsWith("tool-") && part.toolName) {
const name = part.toolName;
const input = part.input;
const output = part.output;
let toolSection = `[Tool: ${name}]`;
if (input != null) {
toolSection += `\nInput: ${compactJson(input)}`;
}
if (output != null) {
toolSection += `\nOutput: ${compactJson(output)}`;
}
sections.push(toolSection);
}
}
return sections.join("\n\n");
}
function compactJson(value) {
const str = JSON.stringify(value);
// Keep short values inline, truncate long ones
if (str.length <= 200)
return str;
return str.slice(0, 200) + "…";
}
//# sourceMappingURL=agentChat.js.map