Skip to content
Open
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
4 changes: 4 additions & 0 deletions docs/tools/background-agents/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ The background agents tool lets an orchestrator dispatch work to sub-agents conc

`list_background_agents` takes no parameters.

## Live status in the TUI

While background tasks are running, the TUI sidebar shows a live **Background agents (N)** panel — one row per running task with a colored activity dot, the sub-agent's name in its accent color, and the elapsed run time. The panel appears only while at least one task is running and clears automatically as tasks finish, so an idle session shows nothing. The same live count is surfaced as a muted `+N background` suffix on the delegation breadcrumb when the orchestrator is simultaneously delegating with `transfer_task`.

## Configuration

```yaml
Expand Down
19 changes: 19 additions & 0 deletions pkg/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import (
"github.com/docker/docker-agent/pkg/shellpath"
"github.com/docker/docker-agent/pkg/skills"
"github.com/docker/docker-agent/pkg/tools"
agenttool "github.com/docker/docker-agent/pkg/tools/builtin/agent"
skillstool "github.com/docker/docker-agent/pkg/tools/builtin/skills"
mcptools "github.com/docker/docker-agent/pkg/tools/mcp"
"github.com/docker/docker-agent/pkg/tui/messages"
Expand Down Expand Up @@ -236,6 +237,24 @@ func (a *App) CurrentAgentSkills() []skills.Skill {
return st.Skills()
}

// backgroundAgentLister is implemented by runtimes that run background agents
// in-process (the local runtime). Remote/client runtimes don't, so the App
// reports no background agents for them.
type backgroundAgentLister interface {
BackgroundAgents() []agenttool.TaskInfo
}

// BackgroundAgents returns a read-only snapshot of in-flight background agent
// tasks, or nil when the runtime doesn't run them in-process (e.g. remote
// runtimes). Mirrors the other read-only accessors that surface runtime state
// to the TUI, like CurrentAgentSkills.
func (a *App) BackgroundAgents() []agenttool.TaskInfo {
if l, ok := a.runtime.(backgroundAgentLister); ok {
return l.BackgroundAgents()
}
return nil
}

// ResolveSkillCommand checks if the input matches a skill slash command (e.g. /skill-name args).
// If matched, it reads the skill content and returns the resolved prompt. Otherwise returns "".
//
Expand Down
12 changes: 12 additions & 0 deletions pkg/runtime/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -855,6 +855,18 @@ func (r *LocalRuntime) CurrentAgentSkillsToolset() *skills.ToolSet {
return nil
}

// BackgroundAgents returns a read-only snapshot of the background agent tasks
// spawned via the agent toolset, used by the TUI's live background-agent
// surface. It is exposed only on the local runtime (which runs background
// agents in-process); the App reaches it through an optional interface so
// remote runtimes report none.
func (r *LocalRuntime) BackgroundAgents() []agenttool.TaskInfo {
if r.bgAgents == nil {
return nil
}
return r.bgAgents.Snapshot()
}

// ExecuteMCPPrompt executes an MCP prompt with provided arguments and returns the content.
func (r *LocalRuntime) ExecuteMCPPrompt(ctx context.Context, promptName string, arguments map[string]string) (string, error) {
currentAgent := r.CurrentAgent()
Expand Down
50 changes: 46 additions & 4 deletions pkg/tools/builtin/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,17 +89,27 @@ const (
taskFailed
)

// Status string values surfaced via TaskInfo.Status (returned by
// taskStatus.String). Exported so callers such as the TUI can match on a task's
// status without depending on the unexported taskStatus enum.
const (
StatusRunning = "running"
StatusCompleted = "completed"
StatusStopped = "stopped"
StatusFailed = "failed"
)

// String returns a human-readable name for the status.
func (s taskStatus) String() string {
switch s {
case taskRunning:
return "running"
return StatusRunning
case taskCompleted:
return "completed"
return StatusCompleted
case taskStopped:
return "stopped"
return StatusStopped
case taskFailed:
return "failed"
return StatusFailed
default:
return "unknown"
}
Expand Down Expand Up @@ -225,6 +235,38 @@ func NewHandler(runner Runner) *Handler {
}
}

// TaskInfo is a lock-safe, read-only view of a background agent task. It exposes
// only fields fixed when the task is created plus the atomically-loaded status,
// so it can be read while the task runs without exposing the internal *task
// (whose result and output fields mutate concurrently).
type TaskInfo struct {
ID string
Agent string
Task string
Status string
StartedAt time.Time
}

// Snapshot returns a read-only view of every tracked background agent task,
// running and finished. It reads only fields fixed at task creation plus the
// atomic status, so it is safe to call concurrently with up to
// maxConcurrentTasks running task goroutines and never exposes the internal
// *task. This is a pure read model: it does not alter task state or lifecycle.
func (h *Handler) Snapshot() []TaskInfo {
var out []TaskInfo
h.tasks.Range(func(id string, t *task) bool {
out = append(out, TaskInfo{
ID: id,
Agent: t.agentName,
Task: t.taskDesc,
Status: t.loadStatus().String(),
StartedAt: t.startTime,
})
return true
})
return out
}

func newTaskID() string {
return "agent_task_" + uuid.New().String()
}
Expand Down
64 changes: 64 additions & 0 deletions pkg/tools/builtin/agent/agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,62 @@ func TestStatusToString(t *testing.T) {
}
}

// --- Snapshot ---

func TestSnapshot_StatusPerTask(t *testing.T) {
cases := []struct {
name string
status taskStatus
want string
}{
{"running", taskRunning, StatusRunning},
{"completed", taskCompleted, StatusCompleted},
{"stopped", taskStopped, StatusStopped},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
h := newTestHandler()
start := time.Now()
tk := insertTask(h, "t1", "researcher", tc.status)
tk.startTime = start

got := h.Snapshot()
require.Len(t, got, 1)
assert.Equal(t, TaskInfo{
ID: "t1",
Agent: "researcher",
Task: "test task",
Status: tc.want,
StartedAt: start,
}, got[0])
})
}
}

func TestSnapshot_AllTasks(t *testing.T) {
h := newTestHandler()
insertTask(h, "t1", "researcher", taskRunning)
insertTask(h, "t2", "writer", taskCompleted)
insertTask(h, "t3", "editor", taskStopped)

got := h.Snapshot()
require.Len(t, got, 3, "Snapshot must return one entry per task, finished tasks included")

statuses := make(map[string]string, len(got))
for _, ti := range got {
statuses[ti.ID] = ti.Status
}
assert.Equal(t, map[string]string{
"t1": StatusRunning,
"t2": StatusCompleted,
"t3": StatusStopped,
}, statuses)
}

func TestSnapshot_Empty(t *testing.T) {
assert.Empty(t, newTestHandler().Snapshot())
}

// --- runningTaskCount / totalTaskCount ---

func TestTaskCounts(t *testing.T) {
Expand Down Expand Up @@ -569,6 +625,14 @@ func TestHandler_ConcurrentAccess(t *testing.T) {
})
}

// Snapshot reads immutable task fields plus the atomic status concurrently
// with the HandleStop CAS writes below; -race must stay clean.
for range 5 {
wg.Go(func() {
_ = h.Snapshot()
})
}

for i := range 5 {
wg.Add(1)
go func(tc tools.ToolCall) {
Expand Down
138 changes: 138 additions & 0 deletions pkg/tui/components/sidebar/background_agents_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package sidebar

import (
"strings"
"testing"
"time"

"github.com/charmbracelet/x/ansi"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

agenttool "github.com/docker/docker-agent/pkg/tools/builtin/agent"
"github.com/docker/docker-agent/pkg/tui/service"
)

func runningTask(id, agent string, startedAt time.Time) agenttool.TaskInfo {
return agenttool.TaskInfo{
ID: id,
Agent: agent,
Task: agent + " task",
Status: agenttool.StatusRunning,
StartedAt: startedAt,
}
}

func TestBackgroundAgentsSection_HiddenWhenEmpty(t *testing.T) {
t.Parallel()

m := New(&service.SessionState{}).(*model)
assert.Empty(t, m.backgroundAgentsSection(40))
}

func TestBackgroundAgentsSection_RendersRunningRoster(t *testing.T) {
t.Parallel()

m := New(&service.SessionState{}).(*model)
now := time.Now()
m.SetBackgroundAgents([]agenttool.TaskInfo{
runningTask("t1", "researcher", now.Add(-90*time.Second)),
runningTask("t2", "writer", now.Add(-5*time.Second)),
})

out := ansi.Strip(m.backgroundAgentsSection(40))

assert.Contains(t, out, "Background agents (2)")
assert.Contains(t, out, "●")
assert.Contains(t, out, "researcher")
assert.Contains(t, out, "writer")
// Elapsed run time is shown per row (reusing the shared duration formatter).
assert.Contains(t, out, "1m30s")
}

// TestSetBackgroundAgents_FiltersToRunningAndSorts verifies the read-model seam:
// finished tasks linger in the runtime snapshot but are dropped from the panel,
// and the surviving running tasks are ordered by start time so rows stay put
// across polls.
func TestSetBackgroundAgents_FiltersToRunningAndSorts(t *testing.T) {
t.Parallel()

m := New(&service.SessionState{}).(*model)
now := time.Now()
m.SetBackgroundAgents([]agenttool.TaskInfo{
{ID: "done", Agent: "old", Status: agenttool.StatusCompleted, StartedAt: now.Add(-time.Hour)},
runningTask("late", "writer", now.Add(-5*time.Second)),
runningTask("early", "researcher", now.Add(-90*time.Second)),
{ID: "stopped", Agent: "cancelled", Status: agenttool.StatusStopped, StartedAt: now},
})

require.Len(t, m.backgroundAgents, 2)
assert.Equal(t, "researcher", m.backgroundAgents[0].Agent)
assert.Equal(t, "writer", m.backgroundAgents[1].Agent)
}

// TestSetBackgroundAgents_DrainsToEmpty verifies the panel hides again once the
// snapshot no longer contains running tasks.
func TestSetBackgroundAgents_DrainsToEmpty(t *testing.T) {
t.Parallel()

m := New(&service.SessionState{}).(*model)
m.SetBackgroundAgents([]agenttool.TaskInfo{runningTask("t1", "researcher", time.Now())})
require.Len(t, m.backgroundAgents, 1)

m.SetBackgroundAgents([]agenttool.TaskInfo{
{ID: "t1", Agent: "researcher", Status: agenttool.StatusCompleted, StartedAt: time.Now()},
})
assert.Empty(t, m.backgroundAgents)
assert.Empty(t, m.backgroundAgentsSection(40))
}

func TestBackgroundAgentsSection_InRenderSections(t *testing.T) {
t.Parallel()

m := New(&service.SessionState{}).(*model)
m.SetSize(40, 100)

without := strings.Join(m.renderSections(35), "\n")
assert.NotContains(t, without, "Background agents")

m.SetBackgroundAgents([]agenttool.TaskInfo{runningTask("t1", "researcher", time.Now())})
with := ansi.Strip(strings.Join(m.renderSections(35), "\n"))
assert.Contains(t, with, "Background agents (1)")
assert.Contains(t, with, "researcher")
}

// TestDelegationBreadcrumb_AppendsBackgroundCount covers wiring the live
// background-agent count into the Phase 2 delegation breadcrumb.
func TestDelegationBreadcrumb_AppendsBackgroundCount(t *testing.T) {
t.Parallel()

t.Run("appends +N background while delegating", func(t *testing.T) {
t.Parallel()
m := New(&service.SessionState{}).(*model)
m.agentChain = []string{"root", "librarian"}
m.SetBackgroundAgents([]agenttool.TaskInfo{
runningTask("t1", "researcher", time.Now()),
runningTask("t2", "writer", time.Now()),
})

out := ansi.Strip(m.delegationBreadcrumb(80))
assert.Contains(t, out, "root ⏵ librarian")
assert.Contains(t, out, "+2 background")
})

t.Run("no addendum without background agents", func(t *testing.T) {
t.Parallel()
m := New(&service.SessionState{}).(*model)
m.agentChain = []string{"root", "librarian"}
assert.NotContains(t, ansi.Strip(m.delegationBreadcrumb(80)), "background")
})

t.Run("no breadcrumb at depth <= 1 even with background agents", func(t *testing.T) {
t.Parallel()
m := New(&service.SessionState{}).(*model)
m.agentChain = []string{"root"}
m.SetBackgroundAgents([]agenttool.TaskInfo{runningTask("t1", "researcher", time.Now())})
assert.Empty(t, m.delegationBreadcrumb(80))
})
}
Loading