activity.go RawHistoryFolder
1package modes
2
3import (
4 "strconv"
5 "strings"
6 "time"
7 "unicode"
8
9 "github.com/bnema/zut/packages/core"
10)
11
12// activityKind describes a user-visible operation. It is deliberately local
13// to interactive modes: public consumers receive factual core events instead
14// of rendered UI labels.
15type activityKind string
16
17const (
18 activityIdle activityKind = "idle"
19 activityPreparingRequest activityKind = "preparing_request"
20 activitySendingRequest activityKind = "sending_request"
21 activityWaitingForResponse activityKind = "waiting_for_response"
22 activityReceivingResponse activityKind = "receiving_response"
23 activityPreparingTool activityKind = "preparing_tool"
24 activityAwaitingConfirmation activityKind = "awaiting_confirmation"
25 activityRunningTool activityKind = "running_tool"
26 activitySendingToolResults activityKind = "sending_tool_results"
27 activityRetryingRequest activityKind = "retrying_request"
28 activityCompactingHistory activityKind = "compacting_history"
29 activityCondensingHistory activityKind = "condensing_history"
30 activityRunningShellCommand activityKind = "running_shell_command"
31)
32
33type activity struct {
34 kind activityKind
35 provider string
36 model string
37 tool string
38 attempt int
39 maxTries int
40 retryIn time.Duration
41}
42
43func (a activity) label() string {
44 switch a.kind {
45 case activityPreparingRequest:
46 return "Preparing request"
47 case activitySendingRequest:
48 return "Sending request to " + activityText(a.provider, "provider")
49 case activityWaitingForResponse:
50 return "Waiting for " + activityText(a.model, "model") + " to respond"
51 case activityReceivingResponse:
52 return "Receiving response from " + activityText(a.model, "model")
53 case activityPreparingTool:
54 return "Preparing tool: " + activityText(a.tool, "tool")
55 case activityAwaitingConfirmation:
56 return "Waiting for approval: " + activityText(a.tool, "tool")
57 case activityRunningTool:
58 return "Running tool: " + activityText(a.tool, "tool")
59 case activitySendingToolResults:
60 return "Sending tool results to " + activityText(a.provider, "provider")
61 case activityRetryingRequest:
62 if a.attempt <= 0 || a.maxTries <= 0 {
63 return "Retrying request"
64 }
65 return "Retrying request in " + a.retryIn.Round(time.Millisecond).String() + " (attempt " + strconv.Itoa(a.attempt) + "/" + strconv.Itoa(a.maxTries) + ")"
66 case activityCompactingHistory:
67 return "Compacting conversation history"
68 case activityCondensingHistory:
69 return "Condensing conversation history"
70 case activityRunningShellCommand:
71 return "Running shell command"
72 default:
73 return ""
74 }
75}
76
77func activityText(value, fallback string) string {
78 value = strings.Map(func(r rune) rune {
79 if unicode.IsControl(r) {
80 return -1
81 }
82 return r
83 }, value)
84 value = strings.TrimSpace(value)
85 if value == "" {
86 return fallback
87 }
88 return value
89}
90
91// agentActivity reduces agent lifecycle facts into one current activity. It
92// tracks all pending tool calls so a multi-tool round does not claim results
93// are being sent while another tool still needs to run.
94type agentActivity struct {
95 activity
96 pendingToolCalls map[string]string
97 toolOrder []string
98}
99
100func newAgentActivity(provider, model string) agentActivity {
101 return agentActivity{activity: activity{
102 kind: activityPreparingRequest,
103 provider: provider,
104 model: model,
105 }}
106}
107
108func (a *agentActivity) apply(ev core.AgentEvent) {
109 if a == nil {
110 return
111 }
112 switch e := ev.(type) {
113 case core.EvTurnStart:
114 a.kind = activityPreparingRequest
115 a.pendingToolCalls = nil
116 a.toolOrder = nil
117 case core.EvRequestStarted:
118 a.kind = activitySendingRequest
119 if e.Provider != "" {
120 a.provider = e.Provider
121 }
122 if e.Model != "" {
123 a.model = e.Model
124 }
125 case core.EvAssistantStart:
126 a.kind = activityWaitingForResponse
127 case core.EvTextDelta:
128 a.kind = activityReceivingResponse
129 case core.EvToolUseStart:
130 a.kind = activityPreparingTool
131 a.tool = e.Name
132 case core.EvToolCall:
133 if a.pendingToolCalls == nil {
134 a.pendingToolCalls = make(map[string]string)
135 }
136 if _, exists := a.pendingToolCalls[e.ID]; !exists {
137 a.toolOrder = append(a.toolOrder, e.ID)
138 }
139 a.pendingToolCalls[e.ID] = e.Name
140 a.kind = activityPreparingTool
141 a.tool = e.Name
142 case core.EvToolExecutionStarted:
143 a.kind = activityRunningTool
144 a.tool = e.Name
145 case core.EvToolResult:
146 if _, exists := a.pendingToolCalls[e.ID]; !exists {
147 return
148 }
149 delete(a.pendingToolCalls, e.ID)
150 if len(a.pendingToolCalls) == 0 {
151 a.kind = activitySendingToolResults
152 a.tool = ""
153 return
154 }
155 for _, id := range a.toolOrder {
156 if name, ok := a.pendingToolCalls[id]; ok {
157 a.kind = activityPreparingTool
158 a.tool = name
159 return
160 }
161 }
162 case core.EvRetryScheduled:
163 // Agent retries discard the preceding partial assistant message.
164 // Its proposed tool calls cannot run, so they must not affect the
165 // next successful attempt's batch accounting.
166 if e.Scope == core.RetryScopeAgent {
167 a.pendingToolCalls = nil
168 a.toolOrder = nil
169 }
170 a.kind = activityRetryingRequest
171 a.attempt = e.Attempt
172 a.maxTries = e.MaxAttempts
173 a.retryIn = e.Delay
174 }
175}