v0.50.1 a637010

subagent_status.go RawHistoryFolder

1package tools
2
3import (
4	"context"
5	"encoding/json"
6	"fmt"
7	"os"
8	"strings"
9	"time"
10	"unicode/utf8"
11
12	"github.com/bnema/zut/packages/agent/subagents"
13	"github.com/bnema/zut/packages/core"
14	"github.com/bnema/zut/packages/provider"
15)
16
17// SubagentStatusTool reports live state for background sub-agents without
18// waiting for a child to finish. An omitted agent_id lists resident children;
19// an agent_id queries one child.
20// By default the result contains metadata only. Explicit include_result reads
21// the bounded durable outcome/handoff for one child without model execution.
22type SubagentStatusTool struct {
23	ResidentManager *subagents.ResidentManager
24	Enabled         func() bool
25}
26
27type subagentStatusArgs struct {
28	AgentID       string `json:"agent_id,omitempty"`
29	IncludeResult bool   `json:"include_result,omitempty"`
30	Watch         int    `json:"watch,omitempty"`
31}
32
33const (
34	subagentWatchMaxSeconds = 60
35	subagentWatchInterval   = 250 * time.Millisecond
36	subagentWatchMaxEntries = 40
37	subagentActivityText    = 300
38	subagentActivityArgs    = 200
39)
40
41type subagentStatusResponse struct {
42	Agent    *subagentStatusEntry      `json:"agent,omitempty"`
43	Agents   []subagentStatusEntry     `json:"agents"`
44	Activity *subagentActivity         `json:"activity,omitempty"`
45	Watch    *subagentWatchReport      `json:"watch,omitempty"`
46	Result   *subagents.ResidentResult `json:"result,omitempty"`
47}
48
49// subagentActivity is a bounded view of the child's unfinished turn: what it
50// is doing now, the tail of visible text it is writing, and active tools.
51// Hidden reasoning is never exposed.
52type subagentActivity struct {
53	Phase string                 `json:"phase"`
54	Text  string                 `json:"text,omitempty"`
55	Tools []subagentActivityTool `json:"tools,omitempty"`
56}
57
58type subagentActivityTool struct {
59	Name  string `json:"name"`
60	State string `json:"state"`
61	Args  string `json:"args,omitempty"`
62}
63
64type subagentWatchReport struct {
65	Seconds   float64                 `json:"observed_seconds"`
66	EndState  subagents.ResidentState `json:"end_state"`
67	Timeline  []subagentWatchEntry    `json:"timeline"`
68	Truncated bool                    `json:"truncated,omitempty"`
69}
70
71type subagentWatchEntry struct {
72	At       string                  `json:"at"`
73	State    subagents.ResidentState `json:"state"`
74	Activity *subagentActivity       `json:"activity,omitempty"`
75}
76
77type subagentStatusEntry struct {
78	ID             string                  `json:"agent_id"`
79	State          subagents.ResidentState `json:"state"`
80	Profile        string                  `json:"profile,omitempty"`
81	Provider       string                  `json:"provider"`
82	Model          string                  `json:"model"`
83	Workspace      subagents.WorkspaceMode `json:"workspace_mode,omitempty"`
84	Required       bool                    `json:"required,omitempty"`
85	OwnedElsewhere bool                    `json:"owned_elsewhere,omitempty"`
86	// PendingFollowUps counts accepted follow-ups the child has not read yet.
87	PendingFollowUps int `json:"pending_followups,omitempty"`
88}
89
90// Name returns the shared facade name: this type is an internal
91// implementation and is never registered on its own.
92func (t *SubagentStatusTool) Name() string { return SubagentToolName }
93
94func (t *SubagentStatusTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) {
95	if ctx != nil {
96		select {
97		case <-ctx.Done():
98			return core.ToolResult{}, ctx.Err()
99		default:
100		}
101	}
102	prefix := t.Name()
103	if t.ResidentManager == nil {
104		return protocolToolError(prefix + ": subagent runtime not available in this mode")
105	}
106	if t.Enabled == nil || !t.Enabled() {
107		return protocolToolError(prefix + ": subagent status is unavailable in this mode")
108	}
109
110	var args subagentStatusArgs
111	if err := decodeSubagentArgs(raw, &args); err != nil {
112		return core.ToolResult{}, err
113	}
114	snapshots := t.ResidentManager.Snapshot()
115	id := strings.TrimSpace(args.AgentID)
116	if id == "" {
117		if args.IncludeResult {
118			return protocolToolError(prefix + ": include_result requires agent_id")
119		}
120		if args.Watch != 0 {
121			return protocolToolError(prefix + ": watch requires agent_id")
122		}
123		entries := make([]subagentStatusEntry, 0, len(snapshots))
124		for _, snapshot := range snapshots {
125			entries = append(entries, publicResidentStatus(snapshot))
126		}
127		return renderSubagentStatus(subagentStatusResponse{Agents: entries})
128	}
129	snapshot, ok := findResidentStatusSnapshot(snapshots, id)
130	if !ok {
131		return protocolToolError(fmt.Sprintf("%s: no such agent %q", prefix, id))
132	}
133	if args.Watch < 0 || args.Watch > subagentWatchMaxSeconds {
134		return protocolToolError(fmt.Sprintf("%s: watch must be between 1 and %d seconds", prefix, subagentWatchMaxSeconds))
135	}
136	if args.Watch > 0 && snapshot.OwnedElsewhere {
137		return protocolToolError(prefix + ": watch is unavailable: child is owned by another zut process")
138	}
139	entry := publicResidentStatus(snapshot)
140	response := subagentStatusResponse{Agent: &entry}
141	if args.Watch > 0 {
142		report, err := t.watch(ctx, snapshot.ID, time.Duration(args.Watch)*time.Second)
143		if err != nil {
144			return core.ToolResult{}, err
145		}
146		if report.EndState == "" {
147			report.EndState = entry.State
148		}
149		response.Watch = &report
150		entry.State = report.EndState
151	}
152	if live, ok := t.ResidentManager.Live(snapshot.ID); ok {
153		response.Activity = residentActivity(entry.State, live)
154	}
155	if args.IncludeResult {
156		result, err := t.ResidentManager.Result(snapshot.ID)
157		if err != nil {
158			message := "saved result is unavailable"
159			switch {
160			case snapshot.OwnedElsewhere:
161				message += ": child is owned by another zut process"
162			case os.IsNotExist(err):
163				message += ": no saved result exists yet"
164			case os.IsPermission(err):
165				message += ": permission denied"
166			default:
167				message += ": could not read or decode the saved result"
168			}
169			return protocolToolError(prefix + ": " + message)
170		}
171		response.Result = &result
172	}
173	return renderSubagentStatus(response)
174}
175
176// watch samples the child's live projection until the duration elapses, the
177// child leaves the running/queued states, or ctx is canceled. It records an
178// entry only when the observable activity changes.
179func (t *SubagentStatusTool) watch(ctx context.Context, id string, duration time.Duration) (subagentWatchReport, error) {
180	start := time.Now()
181	deadline := time.NewTimer(duration)
182	defer deadline.Stop()
183	ticker := time.NewTicker(subagentWatchInterval)
184	defer ticker.Stop()
185	report := subagentWatchReport{Timeline: []subagentWatchEntry{}}
186	last := ""
187	if ctx == nil {
188		ctx = context.Background()
189	}
190	for {
191		state, ok := t.ResidentManager.State(id)
192		if !ok {
193			break
194		}
195		report.EndState = state
196		var activity *subagentActivity
197		if live, ok := t.ResidentManager.Live(id); ok {
198			activity = residentActivity(state, live)
199		}
200		entry := subagentWatchEntry{State: state, Activity: activity}
201		if key := watchChangeKey(entry); key != last {
202			last = key
203			if len(report.Timeline) < subagentWatchMaxEntries {
204				entry.At = fmt.Sprintf("+%.1fs", time.Since(start).Seconds())
205				report.Timeline = append(report.Timeline, entry)
206			} else {
207				report.Truncated = true
208			}
209		}
210		if state != subagents.ResidentRunning && state != subagents.ResidentQueued {
211			break
212		}
213		select {
214		case <-ctx.Done():
215			return subagentWatchReport{}, ctx.Err()
216		case <-deadline.C:
217			report.Seconds = roundSeconds(time.Since(start))
218			return report, nil
219		case <-ticker.C:
220		}
221	}
222	report.Seconds = roundSeconds(time.Since(start))
223	return report, nil
224}
225
226// watchChangeKey ignores streamed text growth so the timeline records phase
227// and tool transitions rather than every delta.
228func watchChangeKey(entry subagentWatchEntry) string {
229	var key strings.Builder
230	key.WriteString(string(entry.State))
231	if entry.Activity != nil {
232		key.WriteString("|" + entry.Activity.Phase)
233		for _, tool := range entry.Activity.Tools {
234			key.WriteString("|" + tool.Name + ":" + tool.State)
235		}
236	}
237	return key.String()
238}
239
240func roundSeconds(d time.Duration) float64 {
241	return float64(d.Round(100*time.Millisecond)) / float64(time.Second)
242}
243
244// residentActivity summarizes a live snapshot. It returns nil when the child
245// is not running a turn.
246func residentActivity(state subagents.ResidentState, live subagents.ResidentLiveSnapshot) *subagentActivity {
247	if state != subagents.ResidentRunning {
248		return nil
249	}
250	activity := &subagentActivity{Text: tailRunes(live.AssistantText, subagentActivityText)}
251	for _, tool := range live.Tools {
252		activity.Tools = append(activity.Tools, subagentActivityTool{Name: tool.Name, State: string(tool.State), Args: headRunes(string(tool.Args), subagentActivityArgs)})
253	}
254	switch {
255	case len(activity.Tools) > 0:
256		activity.Phase = "tools"
257	case live.WaitingForModel:
258		activity.Phase = "waiting_for_model"
259	case activity.Text != "":
260		activity.Phase = "writing"
261	default:
262		activity.Phase = "working"
263	}
264	return activity
265}
266
267func headRunes(s string, limit int) string {
268	if utf8.RuneCountInString(s) <= limit {
269		return s
270	}
271	return string([]rune(s)[:limit]) + "…"
272}
273
274func tailRunes(s string, limit int) string {
275	runes := []rune(s)
276	if len(runes) <= limit {
277		return s
278	}
279	return "…" + string(runes[len(runes)-limit:])
280}
281
282func findResidentStatusSnapshot(snapshots []subagents.ResidentSnapshot, id string) (subagents.ResidentSnapshot, bool) {
283	for _, snapshot := range snapshots {
284		if snapshot.ID == id {
285			return snapshot, true
286		}
287	}
288	var match subagents.ResidentSnapshot
289	hits := 0
290	for _, snapshot := range snapshots {
291		if strings.HasPrefix(snapshot.ID, id) {
292			match = snapshot
293			hits++
294		}
295	}
296	return match, hits == 1
297}
298
299func publicResidentStatus(snapshot subagents.ResidentSnapshot) subagentStatusEntry {
300	return subagentStatusEntry{ID: snapshot.ID, State: snapshot.State, Profile: snapshot.Profile, Provider: snapshot.Provider, Model: snapshot.Model, Workspace: snapshot.WorkspaceMode, Required: snapshot.Required, OwnedElsewhere: snapshot.OwnedElsewhere, PendingFollowUps: snapshot.PendingFollowUps}
301}
302
303func renderSubagentStatus(response subagentStatusResponse) (core.ToolResult, error) {
304	data, err := json.Marshal(response)
305	if err != nil {
306		return core.ToolResult{}, fmt.Errorf("%s: encode status: %w", SubagentToolName, err)
307	}
308	return core.ToolResult{
309		Content: []provider.Content{provider.TextBlock{Text: string(data)}},
310		Details: response,
311	}, nil
312}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close