v0.44.0 efd47b3

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}
87
88// Name returns the shared facade name: this type is an internal
89// implementation and is never registered on its own.
90func (t *SubagentStatusTool) Name() string { return SubagentToolName }
91
92func (t *SubagentStatusTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) {
93	if ctx != nil {
94		select {
95		case <-ctx.Done():
96			return core.ToolResult{}, ctx.Err()
97		default:
98		}
99	}
100	prefix := t.Name()
101	if t.ResidentManager == nil {
102		return protocolToolError(prefix + ": subagent runtime not available in this mode")
103	}
104	if t.Enabled == nil || !t.Enabled() {
105		return protocolToolError(prefix + ": subagent status is unavailable in this mode")
106	}
107
108	var args subagentStatusArgs
109	if err := decodeSubagentArgs(raw, &args); err != nil {
110		return core.ToolResult{}, err
111	}
112	snapshots := t.ResidentManager.Snapshot()
113	id := strings.TrimSpace(args.AgentID)
114	if id == "" {
115		if args.IncludeResult {
116			return protocolToolError(prefix + ": include_result requires agent_id")
117		}
118		if args.Watch != 0 {
119			return protocolToolError(prefix + ": watch requires agent_id")
120		}
121		entries := make([]subagentStatusEntry, 0, len(snapshots))
122		for _, snapshot := range snapshots {
123			entries = append(entries, publicResidentStatus(snapshot))
124		}
125		return renderSubagentStatus(subagentStatusResponse{Agents: entries})
126	}
127	snapshot, ok := findResidentStatusSnapshot(snapshots, id)
128	if !ok {
129		return protocolToolError(fmt.Sprintf("%s: no such agent %q", prefix, id))
130	}
131	if args.Watch < 0 || args.Watch > subagentWatchMaxSeconds {
132		return protocolToolError(fmt.Sprintf("%s: watch must be between 1 and %d seconds", prefix, subagentWatchMaxSeconds))
133	}
134	if args.Watch > 0 && snapshot.OwnedElsewhere {
135		return protocolToolError(prefix + ": watch is unavailable: child is owned by another zut process")
136	}
137	entry := publicResidentStatus(snapshot)
138	response := subagentStatusResponse{Agent: &entry}
139	if args.Watch > 0 {
140		report, err := t.watch(ctx, snapshot.ID, time.Duration(args.Watch)*time.Second)
141		if err != nil {
142			return core.ToolResult{}, err
143		}
144		if report.EndState == "" {
145			report.EndState = entry.State
146		}
147		response.Watch = &report
148		entry.State = report.EndState
149	}
150	if live, ok := t.ResidentManager.Live(snapshot.ID); ok {
151		response.Activity = residentActivity(entry.State, live)
152	}
153	if args.IncludeResult {
154		result, err := t.ResidentManager.Result(snapshot.ID)
155		if err != nil {
156			message := "saved result is unavailable"
157			switch {
158			case snapshot.OwnedElsewhere:
159				message += ": child is owned by another zut process"
160			case os.IsNotExist(err):
161				message += ": no saved result exists yet"
162			case os.IsPermission(err):
163				message += ": permission denied"
164			default:
165				message += ": could not read or decode the saved result"
166			}
167			return protocolToolError(prefix + ": " + message)
168		}
169		response.Result = &result
170	}
171	return renderSubagentStatus(response)
172}
173
174// watch samples the child's live projection until the duration elapses, the
175// child leaves the running/queued states, or ctx is canceled. It records an
176// entry only when the observable activity changes.
177func (t *SubagentStatusTool) watch(ctx context.Context, id string, duration time.Duration) (subagentWatchReport, error) {
178	start := time.Now()
179	deadline := time.NewTimer(duration)
180	defer deadline.Stop()
181	ticker := time.NewTicker(subagentWatchInterval)
182	defer ticker.Stop()
183	report := subagentWatchReport{Timeline: []subagentWatchEntry{}}
184	last := ""
185	if ctx == nil {
186		ctx = context.Background()
187	}
188	for {
189		state, ok := t.ResidentManager.State(id)
190		if !ok {
191			break
192		}
193		report.EndState = state
194		var activity *subagentActivity
195		if live, ok := t.ResidentManager.Live(id); ok {
196			activity = residentActivity(state, live)
197		}
198		entry := subagentWatchEntry{State: state, Activity: activity}
199		if key := watchChangeKey(entry); key != last {
200			last = key
201			if len(report.Timeline) < subagentWatchMaxEntries {
202				entry.At = fmt.Sprintf("+%.1fs", time.Since(start).Seconds())
203				report.Timeline = append(report.Timeline, entry)
204			} else {
205				report.Truncated = true
206			}
207		}
208		if state != subagents.ResidentRunning && state != subagents.ResidentQueued {
209			break
210		}
211		select {
212		case <-ctx.Done():
213			return subagentWatchReport{}, ctx.Err()
214		case <-deadline.C:
215			report.Seconds = roundSeconds(time.Since(start))
216			return report, nil
217		case <-ticker.C:
218		}
219	}
220	report.Seconds = roundSeconds(time.Since(start))
221	return report, nil
222}
223
224// watchChangeKey ignores streamed text growth so the timeline records phase
225// and tool transitions rather than every delta.
226func watchChangeKey(entry subagentWatchEntry) string {
227	var key strings.Builder
228	key.WriteString(string(entry.State))
229	if entry.Activity != nil {
230		key.WriteString("|" + entry.Activity.Phase)
231		for _, tool := range entry.Activity.Tools {
232			key.WriteString("|" + tool.Name + ":" + tool.State)
233		}
234	}
235	return key.String()
236}
237
238func roundSeconds(d time.Duration) float64 {
239	return float64(d.Round(100*time.Millisecond)) / float64(time.Second)
240}
241
242// residentActivity summarizes a live snapshot. It returns nil when the child
243// is not running a turn.
244func residentActivity(state subagents.ResidentState, live subagents.ResidentLiveSnapshot) *subagentActivity {
245	if state != subagents.ResidentRunning {
246		return nil
247	}
248	activity := &subagentActivity{Text: tailRunes(live.AssistantText, subagentActivityText)}
249	for _, tool := range live.Tools {
250		activity.Tools = append(activity.Tools, subagentActivityTool{Name: tool.Name, State: string(tool.State), Args: headRunes(string(tool.Args), subagentActivityArgs)})
251	}
252	switch {
253	case len(activity.Tools) > 0:
254		activity.Phase = "tools"
255	case live.WaitingForModel:
256		activity.Phase = "waiting_for_model"
257	case activity.Text != "":
258		activity.Phase = "writing"
259	default:
260		activity.Phase = "working"
261	}
262	return activity
263}
264
265func headRunes(s string, limit int) string {
266	if utf8.RuneCountInString(s) <= limit {
267		return s
268	}
269	return string([]rune(s)[:limit]) + "…"
270}
271
272func tailRunes(s string, limit int) string {
273	runes := []rune(s)
274	if len(runes) <= limit {
275		return s
276	}
277	return "…" + string(runes[len(runes)-limit:])
278}
279
280func findResidentStatusSnapshot(snapshots []subagents.ResidentSnapshot, id string) (subagents.ResidentSnapshot, bool) {
281	for _, snapshot := range snapshots {
282		if snapshot.ID == id {
283			return snapshot, true
284		}
285	}
286	var match subagents.ResidentSnapshot
287	hits := 0
288	for _, snapshot := range snapshots {
289		if strings.HasPrefix(snapshot.ID, id) {
290			match = snapshot
291			hits++
292		}
293	}
294	return match, hits == 1
295}
296
297func publicResidentStatus(snapshot subagents.ResidentSnapshot) subagentStatusEntry {
298	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}
299}
300
301func renderSubagentStatus(response subagentStatusResponse) (core.ToolResult, error) {
302	data, err := json.Marshal(response)
303	if err != nil {
304		return core.ToolResult{}, fmt.Errorf("%s: encode status: %w", SubagentToolName, err)
305	}
306	return core.ToolResult{
307		Content: []provider.Content{provider.TextBlock{Text: string(data)}},
308		Details: response,
309	}, nil
310}

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 477

↑↓ moveenter openesc close