feat: add background task notification system (#827)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
63
src/cli/helpers/messageQueueBridge.ts
Normal file
63
src/cli/helpers/messageQueueBridge.ts
Normal 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;
|
||||
}
|
||||
44
src/cli/helpers/queuedMessageParts.ts
Normal file
44
src/cli/helpers/queuedMessageParts.ts
Normal 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");
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
121
src/cli/helpers/taskNotifications.ts
Normal file
121
src/cli/helpers/taskNotifications.ts
Normal 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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function unescapeXml(str: string): string {
|
||||
return str.replace(/</g, "<").replace(/>/g, ">").replace(/&/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");
|
||||
}
|
||||
Reference in New Issue
Block a user