From fd6d6d8dd0f6e136511f8bbf0cb693c528a746bf Mon Sep 17 00:00:00 2001 From: Oscar Bergqvist Date: Wed, 22 Jul 2026 18:05:17 +0200 Subject: [PATCH] feat: notify main agent when background agents finish --- docs/automation.md | 5 + packages/core/src/tasks/agentNotifications.ts | 67 ++++++++++ packages/core/src/tasks/index.ts | 1 + .../background-agent-notification.test.ts | 118 ++++++++++++++++++ packages/core/src/utils/backgroundTasks.ts | 7 ++ packages/engine/src/message-pipeline.ts | 21 ++++ 6 files changed, 219 insertions(+) create mode 100644 packages/core/src/tasks/agentNotifications.ts create mode 100644 packages/core/src/test/unit/background-agent-notification.test.ts diff --git a/docs/automation.md b/docs/automation.md index c40f586ea..79fba6287 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -56,6 +56,11 @@ Background shell and agent records are reconciled at startup. A shell is only considered tailable with an exact process identity; LLM agent/goal runs are marked interrupted and requeueable rather than falsely reattached. +When a background agent reaches a terminal state, its owning session receives +one task notification at the next main-agent turn. The notification points to +the task output file instead of injecting the full result into context; other +sessions cannot consume it. + The task supervisor is a durable DAG planner over Kode Tasks. It validates missing dependencies/cycles, exposes ready and critical tasks, and persists serial or bounded-parallel plans. It never launches an LLM or modifies task diff --git a/packages/core/src/tasks/agentNotifications.ts b/packages/core/src/tasks/agentNotifications.ts new file mode 100644 index 000000000..599322e00 --- /dev/null +++ b/packages/core/src/tasks/agentNotifications.ts @@ -0,0 +1,67 @@ +import { + listBackgroundAgentTaskSnapshots, + markBackgroundAgentTaskNotified, + type BackgroundAgentStatus, +} from '#core/utils/backgroundTasks' +import { getTaskOutputFilePath } from '#runtime/taskOutputStore' + +export type BackgroundAgentNotification = { + type: 'agent_notification' + taskId: string + taskType: 'local_agent' + description: string + status: Exclude + outputFile: string + error?: string +} + +export function flushBackgroundAgentNotifications( + options: { sessionId?: string } = {}, +): BackgroundAgentNotification[] { + const notifications: BackgroundAgentNotification[] = [] + + for (const task of listBackgroundAgentTaskSnapshots()) { + if (task.status === 'running' || task.notified) continue + if ( + options.sessionId !== undefined && + task.sessionId !== options.sessionId + ) { + continue + } + + notifications.push({ + type: 'agent_notification', + taskId: task.agentId, + taskType: 'local_agent', + description: task.description, + status: task.status, + outputFile: getTaskOutputFilePath(task.agentId), + ...(task.error ? { error: task.error } : {}), + }) + markBackgroundAgentTaskNotified(task.agentId) + } + + return notifications +} + +export function renderBackgroundAgentNotification( + notification: BackgroundAgentNotification, +): string { + const summarySuffix = + notification.status === 'completed' + ? 'completed' + : notification.status === 'failed' + ? 'failed' + : 'was killed' + + return [ + '', + `${notification.taskId}`, + `${notification.taskType}`, + `${notification.outputFile}`, + `${notification.status}`, + `Background agent "${notification.description}" ${summarySuffix}`, + '', + `Read the output file to retrieve the result: ${notification.outputFile}`, + ].join('\n') +} diff --git a/packages/core/src/tasks/index.ts b/packages/core/src/tasks/index.ts index 12e48d07e..8518b81df 100644 --- a/packages/core/src/tasks/index.ts +++ b/packages/core/src/tasks/index.ts @@ -1,4 +1,5 @@ export * from './types' export * from './storage' export * from './backgroundRegistry' +export * from './agentNotifications' export * from './outputPaths' diff --git a/packages/core/src/test/unit/background-agent-notification.test.ts b/packages/core/src/test/unit/background-agent-notification.test.ts new file mode 100644 index 000000000..dcb9e535d --- /dev/null +++ b/packages/core/src/test/unit/background-agent-notification.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, test } from 'bun:test' +import { + flushBackgroundAgentNotifications, + renderBackgroundAgentNotification, +} from '#core/tasks' +import { + upsertBackgroundAgentTask, + type BackgroundAgentTaskRuntime, +} from '#core/utils/backgroundTasks' + +function makeAgentTask( + overrides: Partial = {}, +): BackgroundAgentTaskRuntime { + return { + type: 'async_agent', + agentId: 'notification-agent-1', + parentAgentId: 'main', + description: 'Review the change', + prompt: 'Review it', + status: 'completed', + cwd: '/repo', + sessionId: 'notification-session-1', + startedAt: 100, + completedAt: 200, + resultText: 'done', + messages: [], + abortController: new AbortController(), + done: Promise.resolve(), + ...overrides, + } +} + +describe('background agent notifications', () => { + test('completed task notifies once with an output-file pointer', () => { + upsertBackgroundAgentTask(makeAgentTask()) + + const [notification] = flushBackgroundAgentNotifications({ + sessionId: 'notification-session-1', + }) + expect(notification).toMatchObject({ + taskId: 'notification-agent-1', + taskType: 'local_agent', + status: 'completed', + description: 'Review the change', + }) + + const text = renderBackgroundAgentNotification(notification!) + expect(text).toContain('') + expect(text).toContain('local_agent') + expect(text).toContain('completed') + expect(text).toContain( + `Read the output file to retrieve the result: ${notification!.outputFile}`, + ) + + expect( + flushBackgroundAgentNotifications({ + sessionId: 'notification-session-1', + }), + ).toEqual([]) + }) + + test('does not consume another session task', () => { + upsertBackgroundAgentTask( + makeAgentTask({ + agentId: 'notification-agent-2', + sessionId: 'notification-session-2', + status: 'failed', + error: 'check failed', + }), + ) + + expect( + flushBackgroundAgentNotifications({ + sessionId: 'notification-session-other', + }), + ).toEqual([]) + + const [notification] = flushBackgroundAgentNotifications({ + sessionId: 'notification-session-2', + }) + expect(notification).toMatchObject({ + taskId: 'notification-agent-2', + status: 'failed', + error: 'check failed', + }) + expect(renderBackgroundAgentNotification(notification!)).toContain( + 'Background agent "Review the change" failed', + ) + }) + + test('running task remains pending until it reaches a terminal status', () => { + const task = makeAgentTask({ + agentId: 'notification-agent-3', + sessionId: 'notification-session-3', + status: 'running', + completedAt: undefined, + }) + upsertBackgroundAgentTask(task) + + expect( + flushBackgroundAgentNotifications({ + sessionId: 'notification-session-3', + }), + ).toEqual([]) + + task.status = 'killed' + task.completedAt = 300 + upsertBackgroundAgentTask(task) + + const [notification] = flushBackgroundAgentNotifications({ + sessionId: 'notification-session-3', + }) + expect(notification?.status).toBe('killed') + expect(renderBackgroundAgentNotification(notification!)).toContain( + 'was killed', + ) + }) +}) diff --git a/packages/core/src/utils/backgroundTasks.ts b/packages/core/src/utils/backgroundTasks.ts index 3abb92d69..2158e8973 100644 --- a/packages/core/src/utils/backgroundTasks.ts +++ b/packages/core/src/utils/backgroundTasks.ts @@ -23,6 +23,7 @@ export type BackgroundAgentTask = { resultText?: string messages: ConversationMessage[] retrieved?: boolean + notified?: boolean } export type BackgroundAgentTaskRuntime = BackgroundAgentTask & { @@ -68,6 +69,12 @@ export function markBackgroundAgentTaskRetrieved(agentId: string): void { task.retrieved = true } +export function markBackgroundAgentTaskNotified(agentId: string): void { + const task = backgroundTasks.get(agentId) + if (!task) return + task.notified = true +} + export function killBackgroundAgentTask(agentId: string): boolean { const task = backgroundTasks.get(agentId) if (!task) return false diff --git a/packages/engine/src/message-pipeline.ts b/packages/engine/src/message-pipeline.ts index f21816aba..18586742d 100644 --- a/packages/engine/src/message-pipeline.ts +++ b/packages/engine/src/message-pipeline.ts @@ -30,6 +30,10 @@ import { } from '#runtime/shell' import { getCwd } from '#core/utils/state' import { getEffectiveSessionId } from '#core/utils/sessionId' +import { + flushBackgroundAgentNotifications, + renderBackgroundAgentNotification, +} from '#core/tasks' import { extractLongTermMemories, formatMemoryContext, @@ -303,6 +307,23 @@ async function* messagePipelineCore( if (toolUseContext.agentId === 'main') { const shell = BunShell.getInstance() + const agentNotifications = flushBackgroundAgentNotifications({ + sessionId: getEffectiveSessionId(), + }) + for (const notification of agentNotifications) { + addNotification({ + title: 'Background agent', + message: `${notification.description} — ${notification.status}. Output: ${notification.outputFile}`, + source: 'system', + kind: notification.status === 'failed' ? 'error' : 'info', + }) + + const text = renderBackgroundAgentNotification(notification) + const msg = createAssistantMessage(text) + messages = [...messages, msg] + yield msg + } + const notifications = shell.flushBashNotifications() for (const notification of notifications) { const status = notification.status