feat: add background task notification system (#827)

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
Charles Packer
2026-02-04 22:45:16 -08:00
committed by GitHub
parent 84e9a6d744
commit 48ccd8f220
44 changed files with 2244 additions and 234 deletions

View File

@@ -7,6 +7,7 @@ import type {
} from "@letta-ai/letta-client/resources/agents/messages";
import { SYSTEM_REMINDER_CLOSE, SYSTEM_REMINDER_OPEN } from "../../constants";
import type { Buffers } from "./accumulator";
import { extractTaskNotificationsForDisplay } from "./taskNotifications";
/**
* Extract displayable text from tool return content.
@@ -178,9 +179,28 @@ export function backfillBuffers(buffers: Buffers, history: Message[]): void {
// user message - content parts may include text and image parts
case "user_message": {
const rawText = renderUserContentParts(msg.content);
const { notifications, cleanedText } =
extractTaskNotificationsForDisplay(rawText);
if (notifications.length > 0) {
let notifIndex = 0;
for (const summary of notifications) {
const notifId = `${lineId}-task-${notifIndex++}`;
const exists = buffers.byId.has(notifId);
buffers.byId.set(notifId, {
kind: "event",
id: notifId,
eventType: "task_notification",
eventData: {},
phase: "finished",
summary,
});
if (!exists) buffers.order.push(notifId);
}
}
// Check if this is a compaction summary message (old format embedded in user_message)
const compactionSummary = extractCompactionSummary(rawText);
const compactionSummary = extractCompactionSummary(cleanedText);
if (compactionSummary) {
// Render as a finished compaction event
const exists = buffers.byId.has(lineId);
@@ -196,13 +216,15 @@ export function backfillBuffers(buffers: Buffers, history: Message[]): void {
break;
}
const exists = buffers.byId.has(lineId);
buffers.byId.set(lineId, {
kind: "user",
id: lineId,
text: rawText,
});
if (!exists) buffers.order.push(lineId);
if (cleanedText) {
const exists = buffers.byId.has(lineId);
buffers.byId.set(lineId, {
kind: "user",
id: lineId,
text: cleanedText,
});
if (!exists) buffers.order.push(lineId);
}
break;
}

View File

@@ -0,0 +1,63 @@
/**
* Message Queue Bridge
*
* Allows non-React code (like Task.ts) to add messages to the messageQueue.
* The queue adder function is set by App.tsx on mount.
*
* This enables background tasks to queue their notification XML directly
* into messageQueue, where the existing dequeue logic handles auto-firing.
*/
export type QueuedMessage = {
kind: "user" | "task_notification";
text: string;
};
type QueueAdder = (message: QueuedMessage) => void;
let queueAdder: QueueAdder | null = null;
const pendingMessages: QueuedMessage[] = [];
const MAX_PENDING_MESSAGES = 10;
/**
* Set the queue adder function. Called by App.tsx on mount.
*/
export function setMessageQueueAdder(fn: QueueAdder | null): void {
queueAdder = fn;
if (queueAdder && pendingMessages.length > 0) {
for (const message of pendingMessages) {
queueAdder(message);
}
pendingMessages.length = 0;
}
}
/**
* Add a message to the messageQueue.
* Called from Task.ts when a background task completes.
* If queue adder not set (App not mounted), message is dropped.
*/
export function addToMessageQueue(message: QueuedMessage): void {
if (queueAdder) {
queueAdder(message);
return;
}
if (pendingMessages.length >= MAX_PENDING_MESSAGES) {
pendingMessages.shift();
}
pendingMessages.push(message);
}
/**
* Check if the queue bridge is connected.
*/
export function isQueueBridgeConnected(): boolean {
return queueAdder !== null;
}
/**
* Clear any pending messages (for testing).
*/
export function clearPendingMessages(): void {
pendingMessages.length = 0;
}

View File

@@ -0,0 +1,44 @@
import type { MessageCreate } from "@letta-ai/letta-client/resources/agents/agents";
import type { QueuedMessage } from "./messageQueueBridge";
import { buildMessageContentFromDisplay } from "./pasteRegistry";
import { extractTaskNotificationsForDisplay } from "./taskNotifications";
export function getQueuedNotificationSummaries(
queued: QueuedMessage[],
): string[] {
const summaries: string[] = [];
for (const item of queued) {
if (item.kind !== "task_notification") continue;
const parsed = extractTaskNotificationsForDisplay(item.text);
summaries.push(...parsed.notifications);
}
return summaries;
}
export function buildQueuedContentParts(
queued: QueuedMessage[],
): MessageCreate["content"] {
const parts: MessageCreate["content"] = [];
let isFirst = true;
for (const item of queued) {
if (!isFirst) {
parts.push({ type: "text", text: "\n" });
}
isFirst = false;
if (item.kind === "task_notification") {
parts.push({ type: "text", text: item.text });
continue;
}
const userParts = buildMessageContentFromDisplay(item.text);
parts.push(...userParts);
}
return parts;
}
export function buildQueuedUserText(queued: QueuedMessage[]): string {
return queued
.filter((item) => item.kind === "user")
.map((item) => item.text)
.filter((text) => text.length > 0)
.join("\n");
}

View File

@@ -5,7 +5,7 @@
import type { StaticSubagent } from "../components/SubagentGroupStatic.js";
import type { Line } from "./accumulator.js";
import { getSubagentByToolCallId } from "./subagentState.js";
import { getSubagentByToolCallId, getSubagents } from "./subagentState.js";
import { isTaskTool } from "./toolNameMapping.js";
/**
@@ -31,16 +31,37 @@ export interface SubagentGroupItem {
export function hasInProgressTaskToolCalls(
order: string[],
byId: Map<string, Line>,
emittedIds: Set<string>,
_emittedIds: Set<string>,
): boolean {
// If any foreground subagent is running, treat Task tools as in-progress.
// Background subagents shouldn't block grouping into the static area.
const hasForegroundRunning = getSubagents().some(
(agent) =>
!agent.isBackground &&
(agent.status === "pending" || agent.status === "running"),
);
if (hasForegroundRunning) {
return true;
}
for (const id of order) {
const ln = byId.get(id);
if (!ln) continue;
if (ln.kind === "tool_call" && isTaskTool(ln.name ?? "")) {
if (emittedIds.has(id)) continue;
if (ln.phase !== "finished") {
return true;
}
if (ln.toolCallId) {
const subagent = getSubagentByToolCallId(ln.toolCallId);
if (subagent) {
if (
!subagent.isBackground &&
(subagent.status === "pending" || subagent.status === "running")
) {
return true;
}
}
}
}
}
return false;
@@ -75,7 +96,13 @@ export function collectFinishedTaskToolCalls(
) {
// Check if we have subagent data in the state store
const subagent = getSubagentByToolCallId(ln.toolCallId);
if (subagent) {
if (
subagent &&
(subagent.status === "completed" ||
subagent.status === "error" ||
(subagent.isBackground &&
(subagent.status === "pending" || subagent.status === "running")))
) {
finished.push({
lineId: id,
toolCallId: ln.toolCallId,
@@ -103,12 +130,15 @@ export function createSubagentGroupItem(
id: subagent.id,
type: subagent.type,
description: subagent.description,
status: subagent.status as "completed" | "error",
status: subagent.isBackground
? "running"
: (subagent.status as "completed" | "error"),
toolCount: subagent.toolCalls.length,
totalTokens: subagent.totalTokens,
agentURL: subagent.agentURL,
error: subagent.error,
model: subagent.model,
isBackground: subagent.isBackground,
});
}
}

View File

@@ -29,6 +29,7 @@ export interface SubagentState {
model?: string;
startTime: number;
toolCallId?: string; // Links this subagent to its parent Task tool call
isBackground?: boolean; // True if running in background (fire-and-forget)
}
interface SubagentStore {
@@ -106,6 +107,7 @@ export function registerSubagent(
type: string,
description: string,
toolCallId?: string,
isBackground?: boolean,
): void {
// Capitalize type for display (explore -> Explore)
const displayType = type.charAt(0).toUpperCase() + type.slice(1);
@@ -121,6 +123,7 @@ export function registerSubagent(
durationMs: 0,
startTime: Date.now(),
toolCallId,
isBackground,
};
store.agents.set(id, agent);

View File

@@ -0,0 +1,121 @@
/**
* Task Notification Formatting
*
* Formats background task completion notifications as XML.
* The actual queueing is handled by messageQueueBridge.ts.
*/
// ============================================================================
// Types
// ============================================================================
export interface TaskNotification {
taskId: string;
status: "completed" | "failed";
summary: string;
result: string;
outputFile: string;
usage?: {
totalTokens?: number;
toolUses?: number;
durationMs?: number;
};
}
// ============================================================================
// XML Escaping
// ============================================================================
/**
* Escape special XML characters to prevent breaking the XML structure.
*/
function escapeXml(str: string): string {
return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function unescapeXml(str: string): string {
return str.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
}
// ============================================================================
// Public API
// ============================================================================
/**
* Format a single notification as XML string for queueing.
*/
export function formatTaskNotification(notification: TaskNotification): string {
// Escape summary and result to prevent XML injection
const escapedSummary = escapeXml(notification.summary);
const escapedResult = escapeXml(notification.result);
const usageLines: string[] = [];
if (notification.usage?.totalTokens !== undefined) {
usageLines.push(`total_tokens: ${notification.usage.totalTokens}`);
}
if (notification.usage?.toolUses !== undefined) {
usageLines.push(`tool_uses: ${notification.usage.toolUses}`);
}
if (notification.usage?.durationMs !== undefined) {
usageLines.push(`duration_ms: ${notification.usage.durationMs}`);
}
const usageBlock = usageLines.length
? `\n<usage>${usageLines.join("\n")}</usage>`
: "";
return `<task-notification>
<task-id>${notification.taskId}</task-id>
<status>${notification.status}</status>
<summary>${escapedSummary}</summary>
<result>${escapedResult}</result>${usageBlock}
</task-notification>
Full transcript available at: ${notification.outputFile}`;
}
export function extractTaskNotificationsForDisplay(message: string): {
notifications: string[];
cleanedText: string;
} {
if (!message.includes("<task-notification>")) {
return { notifications: [], cleanedText: message };
}
const notificationRegex =
/<task-notification>[\s\S]*?(?:<\/task-notification>|$)(?:\s*Full transcript available at:[^\n]*\n?)?/g;
const notifications: string[] = [];
let match: RegExpExecArray | null = notificationRegex.exec(message);
while (match !== null) {
const xml = match[0];
const summaryMatch = xml.match(/<summary>([\s\S]*?)<\/summary>/);
const statusMatch = xml.match(/<status>([\s\S]*?)<\/status>/);
const status = statusMatch?.[1]?.trim();
let summary = summaryMatch?.[1]?.trim() || "";
summary = unescapeXml(summary);
const display = summary || `Agent task ${status || "completed"}`;
notifications.push(display);
match = notificationRegex.exec(message);
}
const cleanedText = message
.replace(notificationRegex, "")
.replace(/^\s*Full transcript available at:[^\n]*\n?/gm, "")
.replace(/\n{3,}/g, "\n\n")
.trim();
return { notifications, cleanedText };
}
/**
* Format multiple notifications as XML string.
* @deprecated Use formatTaskNotification and queue individually
*/
export function formatTaskNotifications(
notifications: TaskNotification[],
): string {
if (notifications.length === 0) {
return "";
}
return notifications.map(formatTaskNotification).join("\n\n");
}