Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/automation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
67 changes: 67 additions & 0 deletions packages/core/src/tasks/agentNotifications.ts
Original file line number Diff line number Diff line change
@@ -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<BackgroundAgentStatus, 'running'>
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 [
'<task-notification>',
`<task-id>${notification.taskId}</task-id>`,
`<task-type>${notification.taskType}</task-type>`,
`<output-file>${notification.outputFile}</output-file>`,
`<status>${notification.status}</status>`,
`<summary>Background agent "${notification.description}" ${summarySuffix}</summary>`,
'</task-notification>',
`Read the output file to retrieve the result: ${notification.outputFile}`,
].join('\n')
}
1 change: 1 addition & 0 deletions packages/core/src/tasks/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './types'
export * from './storage'
export * from './backgroundRegistry'
export * from './agentNotifications'
export * from './outputPaths'
118 changes: 118 additions & 0 deletions packages/core/src/test/unit/background-agent-notification.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {},
): 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('<task-notification>')
expect(text).toContain('<task-type>local_agent</task-type>')
expect(text).toContain('<status>completed</status>')
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',
)
})
})
7 changes: 7 additions & 0 deletions packages/core/src/utils/backgroundTasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export type BackgroundAgentTask = {
resultText?: string
messages: ConversationMessage[]
retrieved?: boolean
notified?: boolean
}

export type BackgroundAgentTaskRuntime = BackgroundAgentTask & {
Expand Down Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions packages/engine/src/message-pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
Loading