Skip to content

Commit cfe64ae

Browse files
authored
Merge pull request #3486 from docker/feat/tui-per-agent-context
feat(tui): per-sub-agent context accounting in the TUI
2 parents fc1a88f + 1863750 commit cfe64ae

21 files changed

Lines changed: 690 additions & 25 deletions

docs/features/tui/index.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ Slash commands (both built-in and named) execute immediately when entered. Regul
100100

101101
### Agents Panel
102102

103-
The sidebar's **Agents** section lists every agent in the team. The current agent is shown as a focus **card** (rendered in place at its position in the list) with its name, a wrapped description, its full `provider/model`, and a thinking line. Every other agent is shown as a compact **two-line row** — line 1 is the shortcut/spinner, the agent name (in its accent color), and a right-aligned thinking **gauge**; line 2 is the indented full `provider/model` — so a large team stays scannable while still showing each model. Agents are separated by a blank line so the two-line rows stay visually distinct. The effort **gauge** is the only visual language for thinking; the focus card and the Agent Inspector spell out the exact level alongside it. Left-click any agent to switch to it.
103+
The sidebar's **Agents** section lists every agent in the team. The current agent is shown as a focus **card** (rendered in place at its position in the list) with its name, a wrapped description, its full `provider/model`, and a thinking line. Every other agent is shown as a compact **two-line row** — line 1 is the shortcut/spinner, the agent name (in its accent color), and a right-aligned thinking **gauge**; line 2 is the indented full `provider/model` — so a large team stays scannable while still showing each model. Once an agent has run (in the main session, as a delegated sub-agent, or as a background agent task), line 2 also carries its latest **context usage** as a right-aligned percentage of its context window, so per-agent context accounting is visible at a glance across the whole team. Agents are separated by a blank line so the two-line rows stay visually distinct. The effort **gauge** is the only visual language for thinking; the focus card and the Agent Inspector spell out the exact level alongside it. Left-click any agent to switch to it.
104104

105105
#### Agent inspector
106106

@@ -115,6 +115,7 @@ The title is rendered in the agent's accent color. Sections appear in this order
115115
- **Description** — the agent's wrapped description.
116116
- **Live state** — a `● current agent` line when the inspected agent is the one currently running.
117117
- **Model / Fallback / Thinking** — the `provider/model`, any fallback models, and the gauge + value thinking line (omitted for models with no selectable thinking, e.g. harness-backed agents).
118+
- **Context** — the agent's latest known context usage, e.g. `Context: 12.8K of 128.0K tokens (10%)` (a bare token count when the context limit is unknown; omitted until the agent has run). Sub-agent and background-agent runs are accounted for.
118119
- **Sub-agents (N) / Handoffs (N) / Skills (N)** — compact, inline, comma-separated lists wrapped to the dialog width.
119120
- **Limits** — the configured per-agent limits that are set, e.g. `Limits: max-iter 50 · history 40 · max-tool-calls 5`.
120121
- **Options** — the enabled option flags, e.g. `Options: add-date · add-environment-info · redact-secrets`.

docs/tools/background-agents/index.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ agents:
7676
>
7777
> Use `background_agents` when your orchestrator needs to fan out work to multiple specialists in parallel — for example, researching several topics simultaneously or running independent code analyses side by side.
7878

79+
In the TUI, each background task's token usage is accounted for live: the sidebar's Agents panel shows the sub-agent's context usage percentage on its roster row, the Agent Inspector shows its exact token counts, and the task's cost joins the session total.
80+
7981
## Using Harness Sub-Agents
8082

8183
Background agents work equally well with [harness-backed sub-agents](../../features/harnesses/index.md) — sub-agents driven by external coding CLIs such as Claude Code or Codex. This lets you dispatch multiple independent coding tasks in parallel:

e2e/tui/background_agent_test.go

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
package tui_test
2+
3+
import (
4+
"context"
5+
"io"
6+
"os"
7+
"sync"
8+
"testing"
9+
10+
tea "charm.land/bubbletea/v2"
11+
"github.com/stretchr/testify/require"
12+
13+
"github.com/docker/docker-agent/pkg/agent"
14+
"github.com/docker/docker-agent/pkg/app"
15+
"github.com/docker/docker-agent/pkg/chat"
16+
"github.com/docker/docker-agent/pkg/config/latest"
17+
"github.com/docker/docker-agent/pkg/model/provider/base"
18+
"github.com/docker/docker-agent/pkg/modelsdev"
19+
"github.com/docker/docker-agent/pkg/runtime"
20+
"github.com/docker/docker-agent/pkg/session"
21+
"github.com/docker/docker-agent/pkg/team"
22+
"github.com/docker/docker-agent/pkg/tools"
23+
agenttool "github.com/docker/docker-agent/pkg/tools/builtin/agent"
24+
"github.com/docker/docker-agent/pkg/tui"
25+
"github.com/docker/docker-agent/pkg/tui/tuitest"
26+
)
27+
28+
// This file covers the background-agent journey end to end through the real
29+
// TUI: a root agent dispatches a run_background_agent task, the worker's
30+
// sub-session runs on a detached goroutine (its events are dropped from the
31+
// live stream), and only the runtime's out-of-band forwarding can bring its
32+
// token usage back to the sidebar. Unlike the VCR-based scenarios in this
33+
// package, the model responses are scripted in-process: driving a
34+
// deterministic multi-agent tool-call flow through cassettes would couple the
35+
// test to HTTP wire details it does not care about.
36+
37+
// scriptedStream replays a fixed sequence of streaming chunks.
38+
type scriptedStream struct {
39+
responses []chat.MessageStreamResponse
40+
idx int
41+
}
42+
43+
func (s *scriptedStream) Recv() (chat.MessageStreamResponse, error) {
44+
if s.idx >= len(s.responses) {
45+
return chat.MessageStreamResponse{}, io.EOF
46+
}
47+
resp := s.responses[s.idx]
48+
s.idx++
49+
return resp, nil
50+
}
51+
52+
func (s *scriptedStream) Close() {}
53+
54+
// scriptedProvider returns one scripted stream per model call, repeating the
55+
// last script if called more often. contextSize is exposed through
56+
// provider_opts so the runtime resolves a context limit without the
57+
// models.dev catalogue, which is what makes percentages computable.
58+
type scriptedProvider struct {
59+
id string
60+
contextSize int64
61+
scripts [][]chat.MessageStreamResponse
62+
63+
mu sync.Mutex
64+
call int
65+
}
66+
67+
func (p *scriptedProvider) ID() modelsdev.ID { return modelsdev.ParseIDOrZero(p.id) }
68+
69+
func (p *scriptedProvider) CreateChatCompletionStream(context.Context, []chat.Message, []tools.Tool) (chat.MessageStream, error) {
70+
p.mu.Lock()
71+
defer p.mu.Unlock()
72+
i := min(p.call, len(p.scripts)-1)
73+
p.call++
74+
return &scriptedStream{responses: p.scripts[i]}, nil
75+
}
76+
77+
func (p *scriptedProvider) BaseConfig() base.Config {
78+
return base.Config{ModelConfig: latest.ModelConfig{
79+
ProviderOpts: map[string]any{"context_size": p.contextSize},
80+
}}
81+
}
82+
83+
// toolCallScript scripts one turn that calls a single tool and stops with the
84+
// given usage.
85+
func toolCallScript(callID, toolName, args string, in, out int64) []chat.MessageStreamResponse {
86+
return []chat.MessageStreamResponse{
87+
{Choices: []chat.MessageStreamChoice{{Delta: chat.MessageDelta{ToolCalls: []tools.ToolCall{{
88+
ID: callID, Type: "function", Function: tools.FunctionCall{Name: toolName},
89+
}}}}}},
90+
{Choices: []chat.MessageStreamChoice{{Delta: chat.MessageDelta{ToolCalls: []tools.ToolCall{{
91+
ID: callID, Type: "function", Function: tools.FunctionCall{Arguments: args},
92+
}}}}}},
93+
{
94+
Choices: []chat.MessageStreamChoice{{FinishReason: chat.FinishReasonToolCalls}},
95+
Usage: &chat.Usage{InputTokens: in, OutputTokens: out},
96+
},
97+
}
98+
}
99+
100+
// contentScript scripts one plain-content turn that stops with the given usage.
101+
func contentScript(content string, in, out int64) []chat.MessageStreamResponse {
102+
return []chat.MessageStreamResponse{
103+
{Choices: []chat.MessageStreamChoice{{Delta: chat.MessageDelta{Content: content}}}},
104+
{
105+
Choices: []chat.MessageStreamChoice{{FinishReason: chat.FinishReasonStop}},
106+
Usage: &chat.Usage{InputTokens: in, OutputTokens: out},
107+
},
108+
}
109+
}
110+
111+
// stubModelStore keeps the runtime off the network: no models.dev lookups, no
112+
// pricing. Context limits come from provider_opts instead.
113+
type stubModelStore struct{}
114+
115+
func (stubModelStore) GetModel(context.Context, modelsdev.ID) (*modelsdev.Model, error) {
116+
return nil, nil
117+
}
118+
func (stubModelStore) GetDatabase(context.Context) (*modelsdev.Database, error) { return nil, nil }
119+
120+
// newBackgroundAgentTUI builds the real TUI over a real LocalRuntime whose
121+
// team is assembled programmatically: a root orchestrator with the
122+
// background_agents toolset and a worker sub-agent, both backed by scripted
123+
// providers. Tools are pre-approved so the dispatch needs no confirmation
124+
// dialog.
125+
func newBackgroundAgentTUI(t *testing.T, width, height int) *tuitest.Driver {
126+
t.Helper()
127+
128+
isolateState(t)
129+
130+
rootProv := &scriptedProvider{
131+
id: "test/fake-root",
132+
contextSize: 1000,
133+
scripts: [][]chat.MessageStreamResponse{
134+
toolCallScript("call-1", agenttool.ToolNameRunBackgroundAgent,
135+
`{"agent":"worker","task":"count the files"}`, 40, 10),
136+
// Session context tracks the LAST call's usage (its input already
137+
// spans the whole prompt), so the root settles at 100/1000 = 10%.
138+
contentScript("Dispatched the worker.", 80, 20),
139+
},
140+
}
141+
workerProv := &scriptedProvider{
142+
id: "test/fake-worker",
143+
contextSize: 1000,
144+
scripts: [][]chat.MessageStreamResponse{
145+
contentScript("worker finished the count", 300, 150),
146+
},
147+
}
148+
149+
worker := agent.New("worker", "Count things when asked.",
150+
agent.WithModel(workerProv),
151+
agent.WithDescription("Background worker"),
152+
)
153+
root := agent.New("root", "Dispatch work to the worker in the background.",
154+
agent.WithModel(rootProv),
155+
agent.WithDescription("Orchestrator"),
156+
agent.WithSubAgents(worker),
157+
agent.WithToolSets(agenttool.New()),
158+
)
159+
160+
rt, err := runtime.New(t.Context(), team.New(team.WithAgents(root, worker)),
161+
runtime.WithCurrentAgent("root"),
162+
runtime.WithSessionCompaction(false),
163+
runtime.WithModelStore(stubModelStore{}),
164+
)
165+
require.NoError(t, err)
166+
t.Cleanup(func() { _ = rt.Close() })
167+
168+
application := app.New(t.Context(), rt, session.New(session.WithToolsApproved(true)))
169+
170+
wd, _ := os.Getwd()
171+
model := tui.New(t.Context(), nil /* no spawner: single tab */, application, wd, func() {})
172+
return tuitest.New(t, model, width, height)
173+
}
174+
175+
// TestBackgroundAgent_PerAgentContextInSidebar drives the full journey: the
176+
// worker's usage (450 of 1000 tokens) can only reach the TUI through the
177+
// runtime's out-of-band background-event forwarding, so the 45% appearing on
178+
// the worker's roster line proves the whole chain — tool call, detached
179+
// sub-session, OnBackgroundEvent, app event stream, sidebar accounting.
180+
// The root's own line independently settles at 10% (its final turn consumed
181+
// 100 of 1000 tokens).
182+
func TestBackgroundAgent_PerAgentContextInSidebar(t *testing.T) {
183+
d := newBackgroundAgentTUI(t, 120, 40)
184+
185+
// Both agents are listed before anything runs, with no context percent.
186+
d.WaitFor(tuitest.ContainsAll("root", "worker")).
187+
Assert(tuitest.Absent("%"))
188+
189+
d.Type("Please dispatch the worker.").
190+
Enter().
191+
WaitFor(tuitest.Contains("Dispatched the worker.")).
192+
WaitFor(tuitest.Contains("45%")).
193+
WaitFor(tuitest.Contains("10%"))
194+
}
195+
196+
// TestBackgroundAgent_InspectorShowsWorkerContext opens the worker's Agent
197+
// Inspector (right-click on its roster line) after the background task
198+
// completed and checks the exact per-agent context line.
199+
func TestBackgroundAgent_InspectorShowsWorkerContext(t *testing.T) {
200+
d := newBackgroundAgentTUI(t, 120, 40)
201+
202+
d.Type("Please dispatch the worker.").
203+
Enter().
204+
WaitFor(tuitest.Contains("Dispatched the worker.")).
205+
WaitFor(tuitest.Contains("45%"))
206+
207+
// "45%" renders only on the worker's roster line, so its coordinates are
208+
// a reliable right-click target for that agent (either of the entry's two
209+
// lines opens the inspector).
210+
x, y := d.MustFindText("45%")
211+
d.Send(tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseRight}).
212+
Send(tea.MouseReleaseMsg{X: x, Y: y, Button: tea.MouseRight}).
213+
WaitFor(tuitest.Contains("Context: 450 of 1.0K tokens (45%)"))
214+
}

pkg/app/app.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,16 @@ func (a *App) Start(ctx context.Context) {
168168
case <-ctx.Done():
169169
}
170170
})
171+
172+
// Forward events surfaced from detached background work (token usage
173+
// from background agent tasks) so the sidebar and agent inspector can
174+
// account for background agents' context usage.
175+
a.runtime.OnBackgroundEvent(func(event runtime.Event) {
176+
select {
177+
case a.events <- event:
178+
case <-ctx.Done():
179+
}
180+
})
171181
})
172182
}
173183

pkg/app/app_test.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ func (m *mockRuntime) SetAgentThinkingLevel(context.Context, string, effort.Leve
105105
func (m *mockRuntime) AvailableModels(context.Context) []runtime.ModelChoice { return nil }
106106
func (m *mockRuntime) SupportsModelSwitching() bool { return false }
107107
func (m *mockRuntime) OnToolsChanged(func(runtime.Event)) {}
108+
func (m *mockRuntime) OnBackgroundEvent(func(runtime.Event)) {}
108109

109110
// Verify mockRuntime implements runtime.Runtime
110111
var _ runtime.Runtime = (*mockRuntime)(nil)
@@ -172,6 +173,49 @@ func TestApp_Retry_SuppressesReEmittedUserMessage(t *testing.T) {
172173
"only the post-StreamStarted user message should be forwarded")
173174
}
174175

176+
// backgroundEventMockRuntime captures the handler App.Start registers via
177+
// OnBackgroundEvent so tests can emit background events through it.
178+
type backgroundEventMockRuntime struct {
179+
mockRuntime
180+
181+
handler func(runtime.Event)
182+
}
183+
184+
func (m *backgroundEventMockRuntime) OnBackgroundEvent(handler func(runtime.Event)) {
185+
m.handler = handler
186+
}
187+
188+
// TestApp_Start_ForwardsBackgroundEvents verifies Start wires the runtime's
189+
// out-of-band background-event hook into the app's event stream, so token
190+
// usage from background agent tasks reaches the TUI subscribers.
191+
func TestApp_Start_ForwardsBackgroundEvents(t *testing.T) {
192+
t.Parallel()
193+
194+
rt := &backgroundEventMockRuntime{}
195+
events := make(chan tea.Msg, 16)
196+
app := &App{
197+
runtime: rt,
198+
session: session.New(),
199+
events: events,
200+
}
201+
202+
app.Start(t.Context())
203+
require.NotNil(t, rt.handler, "Start must register the background-event handler")
204+
205+
usage := runtime.NewTokenUsageEvent("bg-session", "worker", &runtime.Usage{
206+
ContextLength: 150,
207+
ContextLimit: 1000,
208+
})
209+
rt.handler(usage)
210+
211+
select {
212+
case msg := <-events:
213+
assert.Equal(t, usage, msg, "the background event must reach the app's event stream unchanged")
214+
case <-time.After(2 * time.Second):
215+
t.Fatal("timed out waiting for the forwarded background event")
216+
}
217+
}
218+
175219
// stubSnapshotController is a tiny SnapshotController used by the app
176220
// tests to drive /undo without spinning up a real shadow-git
177221
// repository. enabled gates SnapshotsEnabled(), and the (files, ok,

pkg/cli/runner_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ func (m *mockRuntime) SetAgentThinkingLevel(context.Context, string, effort.Leve
107107
func (m *mockRuntime) AvailableModels(context.Context) []runtime.ModelChoice { return nil }
108108
func (m *mockRuntime) SupportsModelSwitching() bool { return false }
109109
func (m *mockRuntime) OnToolsChanged(func(runtime.Event)) {}
110+
func (m *mockRuntime) OnBackgroundEvent(func(runtime.Event)) {}
110111
func (m *mockRuntime) RegenerateTitle(context.Context, *session.Session, chan runtime.Event) {}
111112

112113
func (m *mockRuntime) Resume(_ context.Context, req runtime.ResumeRequest) {

pkg/leantui/update_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ func (r *cycleThinkingRuntime) SetAgentThinkingLevel(_ context.Context, _ string
118118
func (r *cycleThinkingRuntime) AvailableModels(context.Context) []runtime.ModelChoice { return nil }
119119
func (r *cycleThinkingRuntime) SupportsModelSwitching() bool { return r.supports }
120120
func (r *cycleThinkingRuntime) OnToolsChanged(func(runtime.Event)) {}
121+
func (r *cycleThinkingRuntime) OnBackgroundEvent(func(runtime.Event)) {}
121122

122123
var _ runtime.Runtime = (*cycleThinkingRuntime)(nil)
123124

pkg/runtime/agent_delegation.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,12 @@ func (r *LocalRuntime) runCollecting(ctx context.Context, parent *session.Sessio
367367
onContent(choice.Content)
368368
}
369369
}
370+
// Token usage is the one event a background sub-session surfaces
371+
// out-of-band: it carries the sub-session id and agent name, so the
372+
// UI can keep per-agent context accounting for background agents.
373+
if usage, ok := event.(*TokenUsageEvent); ok {
374+
r.emitBackgroundEvent(usage)
375+
}
370376
if errEvt, ok := event.(*ErrorEvent); ok {
371377
errMsg = errEvt.Error
372378
break

pkg/runtime/commands_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ func (m *mockRuntime) SetAgentThinkingLevel(context.Context, string, effort.Leve
9292
func (m *mockRuntime) AvailableModels(context.Context) []ModelChoice { return nil }
9393
func (m *mockRuntime) SupportsModelSwitching() bool { return false }
9494
func (m *mockRuntime) OnToolsChanged(func(Event)) {}
95+
func (m *mockRuntime) OnBackgroundEvent(func(Event)) {}
9596

9697
func (m *mockRuntime) RegenerateTitle(context.Context, *session.Session, chan Event) {
9798
}

pkg/runtime/remote_runtime.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -702,6 +702,10 @@ func (r *RemoteRuntime) TogglePause(ctx context.Context) (bool, error) {
702702
// than via an out-of-band callback.
703703
func (r *RemoteRuntime) OnToolsChanged(func(Event)) {}
704704

705+
// OnBackgroundEvent is a no-op for remote runtimes; background agent tasks
706+
// run server-side and their events are not forwarded out-of-band.
707+
func (r *RemoteRuntime) OnBackgroundEvent(func(Event)) {}
708+
705709
// Close is a no-op for remote runtimes.
706710
func (r *RemoteRuntime) Close() error {
707711
return nil

0 commit comments

Comments
 (0)