v0.16.0 042538b

subagent_status.go RawHistoryFolder

1package tools
2
3import (
4	"context"
5	"encoding/json"
6	"fmt"
7	"strings"
8	"time"
9	"unicode/utf8"
10
11	"github.com/bnema/zut/packages/agent/subagents"
12	"github.com/bnema/zut/packages/core"
13	"github.com/bnema/zut/packages/provider"
14)
15
16// SubagentStatusTool reports live state for background sub-agents without
17// waiting for a worker to finish. An omitted agent_id lists the workers
18// visible to the current supervisor session; an agent_id queries one worker.
19// The result deliberately contains metadata only: it does not expose
20// transcripts, result output, credentials, provider settings, or filesystem
21// paths.
22type SubagentStatusTool struct {
23	Supervisor *subagents.Supervisor
24	Enabled    func() bool
25}
26
27type subagentStatusArgs struct {
28	AgentID string `json:"agent_id,omitempty"`
29}
30
31type subagentStatusResponse struct {
32	Agent  *subagentStatusEntry  `json:"agent,omitempty"`
33	Agents []subagentStatusEntry `json:"agents"`
34}
35
36type subagentStatusEntry struct {
37	ID               string                `json:"agent_id"`
38	PrimaryOperation *statusOperation      `json:"primary_operation,omitempty"`
39	Terminal         string                `json:"terminal,omitempty"`
40	LastObservation  *statusLastEvent      `json:"last_observation,omitempty"`
41	LastEvent        *statusLastEvent      `json:"last_event,omitempty"`
42	StartedAt        time.Time             `json:"started_at"`
43	LifetimeTurns    int                   `json:"lifetime_turns"`
44	CurrentRunTurns  int                   `json:"current_run_turns"`
45	TaskSummary      string                `json:"task_summary,omitempty"`
46	Requirement      *statusRequirement    `json:"requirement,omitempty"`
47	Result           *subagentStatusResult `json:"result,omitempty"`
48}
49
50type statusOperation struct {
51	Type      string    `json:"type"`
52	StartedAt time.Time `json:"started_at"`
53}
54
55type statusLastEvent struct {
56	Type string    `json:"type"`
57	At   time.Time `json:"at"`
58}
59
60type statusRequirement struct {
61	State      string `json:"state"`
62	TargetTurn int    `json:"target_turn"`
63	Unmet      bool   `json:"unmet"`
64	ErrorCode  string `json:"error_code,omitempty"`
65}
66
67type subagentStatusResult struct {
68	State          string `json:"state"`
69	Available      bool   `json:"available"`
70	Ref            string `json:"ref,omitempty"`
71	Delivered      bool   `json:"delivered"`
72	DeliveryFailed bool   `json:"delivery_failed"`
73}
74
75const subagentStatusSchema = `{
76  "type": "object",
77  "properties": {
78    "agent_id": {
79      "type": "string",
80      "description": "Optional worker id or unique id prefix. Omit it to list all visible background sub-agents."
81    }
82  }
83}`
84
85func (t *SubagentStatusTool) Name() string { return SubagentStatusToolName }
86
87func (t *SubagentStatusTool) Description() string {
88	return "Query live status for one background sub-agent or list all visible workers without waiting for completion."
89}
90
91func (t *SubagentStatusTool) Schema() json.RawMessage {
92	return json.RawMessage(subagentStatusSchema)
93}
94
95func (t *SubagentStatusTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) {
96	if ctx != nil {
97		select {
98		case <-ctx.Done():
99			return core.ToolResult{}, ctx.Err()
100		default:
101		}
102	}
103	prefix := t.Name()
104	if t.Supervisor == nil {
105		return protocolToolError(prefix + ": subagent supervisor not available in this mode")
106	}
107	if t.Enabled == nil || !t.Enabled() {
108		return protocolToolError(prefix + ": subagent status is unavailable in this mode")
109	}
110
111	var args subagentStatusArgs
112	if err := json.Unmarshal(raw, &args); err != nil {
113		return core.ToolResult{}, fmt.Errorf("invalid args: %w", err)
114	}
115
116	// SnapshotAll provides identity, session scope, and durable result facts.
117	// The execution indication itself comes solely from the trace projection.
118	snapshots := t.Supervisor.SnapshotAll()
119	views := t.Supervisor.TraceViews()
120	id := strings.TrimSpace(args.AgentID)
121	if id == "" {
122		entries := make([]subagentStatusEntry, 0, len(snapshots))
123		for _, snapshot := range snapshots {
124			entries = append(entries, publicSubagentStatus(snapshot, views[snapshot.ID]))
125		}
126		return renderSubagentStatus(subagentStatusResponse{Agents: entries})
127	}
128
129	snapshot, ok := findSubagentStatusSnapshot(snapshots, id)
130	if !ok {
131		return protocolToolError(fmt.Sprintf("%s: no such agent %q", prefix, id))
132	}
133	entry := publicSubagentStatus(snapshot, views[snapshot.ID])
134	return renderSubagentStatus(subagentStatusResponse{Agent: &entry})
135}
136
137func findSubagentStatusSnapshot(snapshots []subagents.AgentSnapshot, id string) (subagents.AgentSnapshot, bool) {
138	for _, snapshot := range snapshots {
139		if snapshot.ID == id {
140			return snapshot, true
141		}
142	}
143	var match subagents.AgentSnapshot
144	hits := 0
145	for _, snapshot := range snapshots {
146		if strings.HasPrefix(snapshot.ID, id) {
147			match = snapshot
148			hits++
149		}
150	}
151	return match, hits == 1
152}
153
154func publicSubagentStatus(snapshot subagents.AgentSnapshot, view subagents.AgentTraceView) subagentStatusEntry {
155	entry := subagentStatusEntry{
156		ID:              snapshot.ID,
157		Terminal:        view.Terminal,
158		StartedAt:       snapshot.Started,
159		LifetimeTurns:   snapshot.LifetimeTurns,
160		CurrentRunTurns: snapshot.CurrentRunTurns,
161		TaskSummary:     summarizeSubagentTask(snapshot.Task),
162	}
163	if primary, ok := view.Primary(); ok {
164		entry.PrimaryOperation = &statusOperation{Type: primary.Type, StartedAt: primary.StartedAt}
165	}
166	if view.LastObservation != nil {
167		entry.LastObservation = &statusLastEvent{Type: view.LastObservation.Type, At: view.LastObservation.At}
168	}
169	if view.LastEvent.Type != "" {
170		entry.LastEvent = &statusLastEvent{Type: view.LastEvent.Type, At: view.LastEvent.Timestamp}
171	}
172	if snapshot.Requirement.Required {
173		entry.Requirement = &statusRequirement{
174			State:      string(snapshot.Requirement.State),
175			TargetTurn: snapshot.Requirement.TargetTurn,
176			Unmet:      snapshot.Requirement.Unmet(),
177			ErrorCode:  snapshot.Requirement.ErrorCode,
178		}
179	}
180	if snapshot.Result != nil {
181		entry.Result = &subagentStatusResult{
182			State:     publicResultState(snapshot.Result.Status),
183			Available: snapshot.ResultRef != "",
184			Ref:       snapshot.ResultRef,
185			Delivered: snapshot.Requirement.Notified,
186		}
187	}
188	if view.Result != nil {
189		if entry.Result == nil {
190			entry.Result = &subagentStatusResult{State: "unknown"}
191		}
192		entry.Result.Available = view.Result.Available
193		if view.Result.Ref != "" {
194			entry.Result.Ref = view.Result.Ref
195		}
196		entry.Result.Delivered = entry.Result.Delivered || view.Result.Delivered
197		entry.Result.DeliveryFailed = entry.Result.DeliveryFailed || view.Result.Failed
198	}
199	return entry
200}
201
202func publicResultState(status subagents.TurnStatus) string {
203	switch status {
204	case subagents.ResultSucceeded:
205		return "completed"
206	case subagents.ResultFailed:
207		return "failed"
208	case subagents.ResultCanceled:
209		return "cancelled"
210	default:
211		return "unknown"
212	}
213}
214
215// summarizeSubagentTask exposes only the first logical line and a small bound.
216// This keeps the status surface useful for identifying a worker without
217// returning the full prompt or any following context.
218func summarizeSubagentTask(task string) string {
219	firstLine := strings.TrimSpace(strings.SplitN(task, "\n", 2)[0])
220	firstLine = strings.Join(strings.Fields(firstLine), " ")
221	return truncateSubagentStatus(firstLine, 200)
222}
223
224func truncateSubagentStatus(value string, maxRunes int) string {
225	if maxRunes <= 0 {
226		return ""
227	}
228	if utf8.RuneCountInString(value) <= maxRunes {
229		return value
230	}
231	runes := []rune(value)
232	if maxRunes <= 3 {
233		return string(runes[:maxRunes])
234	}
235	return string(runes[:maxRunes-3]) + "..."
236}
237
238func renderSubagentStatus(response subagentStatusResponse) (core.ToolResult, error) {
239	data, err := json.Marshal(response)
240	if err != nil {
241		return core.ToolResult{}, fmt.Errorf("%s: encode status: %w", "subagent_status", err)
242	}
243	return core.ToolResult{
244		Content: []provider.Content{provider.TextBlock{Text: string(data)}},
245		Details: response,
246	}, nil
247}

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