返回 DeepSeek-Reasonix
status.go
根目录 / internal / acp / status.go
1 package acp
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "math"
8 "runtime"
9 "slices"
10 "strings"
11 "sync"
12 "time"
13
14 "reasonix/internal/agent"
15 "reasonix/internal/billing"
16 "reasonix/internal/control"
17 "reasonix/internal/event"
18 "reasonix/internal/provider"
19 )
20
21 const (
22 reasonixStatusSchemaVersion = 1
23 sessionStatusMethod = "_reasonix.io/session/status"
24 sessionStatusUpdateMethod = "_reasonix.io/session/status_update"
25 )
26
27 // ReasonixSchemaCapability advertises one versioned vendor extension in
28 // agentCapabilities._meta. The method name is the map key so clients can fail
29 // closed before opening a session.
30 type ReasonixSchemaCapability struct {
31 SchemaVersion int `json:"schemaVersion"`
32 }
33
34 // SessionStatusParams addresses one live ACP session.
35 type SessionStatusParams struct {
36 SessionID string `json:"sessionId"`
37 }
38
39 // SessionSandboxState is the effective sandbox, after all CLI hard overrides
40 // have been applied. It deliberately reports no credentials or environment.
41 type SessionSandboxState struct {
42 Mode string `json:"mode"`
43 Engine string `json:"engine"`
44 Available bool `json:"available"`
45 WorkspaceRoot string `json:"workspaceRoot"`
46 WriteRoots []string `json:"writeRoots"`
47 NetworkEnabled bool `json:"networkEnabled"`
48 }
49
50 // SessionRuntimeState is supplied by the composition root because only it can
51 // truthfully report config-derived sandbox and planner state.
52 type SessionRuntimeState struct {
53 PlannerMode string `json:"plannerMode"`
54 Sandbox SessionSandboxState `json:"sandbox"`
55 }
56
57 // SessionRuntimeStateParams identifies the exact controller configuration whose
58 // effective policy is being reported. Model/profile switches rebuild the
59 // controller, so status must be recomputed from the same resolved inputs.
60 type SessionRuntimeStateParams struct {
61 Cwd string
62 Model string
63 RuntimeProfile string
64 }
65
66 // SessionRuntimeStateProvider exposes effective process/session policy without
67 // coupling the ACP adapter to Reasonix configuration internals.
68 type SessionRuntimeStateProvider interface {
69 SessionRuntimeState(ctx context.Context, p SessionRuntimeStateParams) (SessionRuntimeState, error)
70 }
71
72 type ReasonixStatusGoal struct {
73 Status string `json:"status"`
74 Objective string `json:"objective,omitempty"`
75 // Runtime is the optional Goal usage/runtime summary; absent for old
76 // hosts or when no goal is active.
77 Runtime *ReasonixGoalRuntime `json:"runtime,omitempty"`
78 }
79
80 type ReasonixGoalRuntime struct {
81 TurnsUsed int `json:"turnsUsed"`
82 TurnsLimit int `json:"turnsLimit"` // Deprecated: always 0.
83 TokensUsed int `json:"tokensUsed"`
84 RequestsUsed int `json:"requestsUsed,omitempty"`
85 WorkDurationMs int64 `json:"workDurationMs,omitempty"`
86 TokensLimit int `json:"tokensLimit"` // Deprecated: always 0; retained for protocol compatibility.
87 NoProgressTurns int `json:"noProgressTurns"`
88 NoProgressLimit int `json:"noProgressLimit"` // Deprecated: always 0.
89 LastReason string `json:"lastReason,omitempty"`
90 StopCause string `json:"stopCause,omitempty"`
91 BudgetExtensions int `json:"budgetExtensions"` // Deprecated: always 0.
92 }
93
94 type ReasonixTurnOutcome struct {
95 Diagnostic *provider.FailureDiagnostic `json:"diagnostic,omitempty"`
96 Kind string `json:"kind"`
97 Reason string `json:"reason,omitempty"`
98 }
99
100 type ReasonixFinalReadiness struct {
101 ReadyForReview bool `json:"readyForReview"`
102 Summary string `json:"summary"`
103 Risks []string `json:"risks"`
104 }
105
106 // ReasonixSessionStatus is the stable schemaVersion=1 recovery snapshot.
107 // Reasoning text and unbounded terminal output are intentionally absent.
108 type ReasonixSessionStatus struct {
109 ProtocolRecovery *provider.ProtocolRecoveryAction `json:"protocolRecovery,omitempty"`
110 SchemaVersion int `json:"schemaVersion"`
111 Sequence uint64 `json:"sequence"`
112 SessionID string `json:"sessionId"`
113 State string `json:"state"`
114 Model string `json:"model"`
115 Effort string `json:"effort"`
116 Mode string `json:"mode"`
117 WorkMode string `json:"workMode"`
118 PlannerMode string `json:"plannerMode"`
119 Goal ReasonixStatusGoal `json:"goal"`
120 Phase string `json:"phase"`
121 TurnOutcome ReasonixTurnOutcome `json:"turnOutcome"`
122 FinalReadiness ReasonixFinalReadiness `json:"finalReadiness"`
123 Sandbox SessionSandboxState `json:"sandbox"`
124 Usage ReasonixStatusUsage `json:"usage"`
125 }
126
127 type ReasonixStatusUpdate struct {
128 SchemaVersion int `json:"schemaVersion"`
129 Sequence uint64 `json:"sequence"`
130 SessionID string `json:"sessionId"`
131 Event string `json:"event"`
132 Status ReasonixSessionStatus `json:"status"`
133 }
134
135 type usageAccumulator struct {
136 promptTokens int
137 completionTokens int
138 reasoningTokens int
139 cacheHitTokens int
140 cacheMissTokens int
141 estimated bool
142 events int
143 pricedEvents int
144 estimatedCost float64
145 currency string
146 source string
147 costComplete bool
148 quoteEvents int
149 quoteLedger *billing.Ledger
150 }
151
152 func (a *usageAccumulator) addQuoted(u *provider.Usage, pricing *provider.Pricing, quote *billing.CostQuote, source string) {
153 if u == nil {
154 return
155 }
156 a.promptTokens += u.PromptTokens
157 a.completionTokens += u.CompletionTokens
158 a.reasoningTokens += u.ReasoningTokens
159 a.cacheHitTokens += u.CacheHitTokens
160 a.cacheMissTokens += u.CacheMissTokens
161 a.estimated = a.estimated || u.Estimated
162 a.events++
163 source = strings.TrimSpace(source)
164 if source == "" {
165 source = event.UsageSourceExecutor
166 }
167 if a.source == "" {
168 a.source = source
169 } else if a.source != source {
170 a.source = "mixed"
171 }
172 if quote == nil && pricing != nil {
173 quote = event.EnsureCostQuote(event.Event{Kind: event.Usage, Usage: u, Pricing: pricing, UsageSource: source}, nil)
174 }
175 if quote != nil {
176 a.pricedEvents++
177 a.quoteEvents++
178 a.estimated = true
179 if a.quoteLedger == nil {
180 a.quoteLedger = billing.NewLedger()
181 }
182 a.quoteLedger.Add(*quote, billing.UsageTokens{
183 PromptTokens: u.PromptTokens,
184 CompletionTokens: u.CompletionTokens,
185 CacheHitTokens: u.CacheHitTokens,
186 CacheMissTokens: u.CacheMissTokens,
187 CacheWriteTokens: u.CacheWriteTokens,
188 CacheWriteBilledTokens: u.CacheWriteBilledTokens,
189 Estimated: u.Estimated,
190 }, time.Time{})
191 if quote.Selected != nil {
192 cur := quote.LegacyCurrencyCode()
193 if a.pricedEvents == 1 {
194 a.currency = cur
195 a.costComplete = quote.Complete
196 } else if a.currency != cur {
197 // Different selected currencies — re-aggregate later via quotes.
198 a.currency = cur
199 }
200 if !quote.Complete {
201 a.costComplete = false
202 }
203 a.estimatedCost += quote.Selected.Float64()
204 } else if pricing != nil {
205 // Incomplete display valuation: keep original, mark incomplete.
206 a.costComplete = false
207 a.estimatedCost += quote.Original.Float64()
208 if a.currency == "" {
209 a.currency = billing.NormalizeCurrency(quote.Original.Currency)
210 }
211 }
212 return
213 }
214 }
215
216 func (a usageAccumulator) wire() ReasonixUsage {
217 usage := ReasonixUsage{
218 TotalTokens: a.promptTokens + a.completionTokens,
219 PromptTokens: a.promptTokens,
220 CompletionTokens: a.completionTokens,
221 ReasoningTokens: a.reasoningTokens,
222 CacheHitTokens: a.cacheHitTokens,
223 CacheMissTokens: a.cacheMissTokens,
224 Estimated: a.estimated,
225 UsageSource: a.source,
226 }
227 if usage.UsageSource == "" {
228 usage.UsageSource = event.UsageSourceExecutor
229 }
230 if total := a.cacheHitTokens + a.cacheMissTokens; total > 0 {
231 ratio := float64(a.cacheHitTokens) / float64(total)
232 usage.CacheHitRatio = &ratio
233 }
234 if a.quoteLedger != nil && a.quoteEvents == a.pricedEvents && len(a.quoteLedger.Entries) > 0 {
235 agg := a.quoteLedger.Total("")
236 usage.CostQuote = &agg
237 costComplete := agg.CostComplete
238 displayComplete := agg.DisplayComplete
239 usage.CostComplete = &costComplete
240 usage.DisplayComplete = &displayComplete
241 usage.DisplayStatus = agg.DisplayStatus
242 usage.AggregateMode = agg.AggregateMode
243 usage.OriginalTotals = append([]billing.Money(nil), agg.OriginalTotals...)
244 if agg.Selected != nil && !math.IsNaN(agg.Selected.Float64()) && !math.IsInf(agg.Selected.Float64(), 0) {
245 cost := agg.Selected.Float64()
246 currency := agg.LegacyCurrencyCode()
247 usage.EstimatedCost = &cost
248 usage.Currency = &currency
249 }
250 return usage
251 }
252 if a.events > 0 && a.pricedEvents == a.events && a.currency != "" && !math.IsNaN(a.estimatedCost) && !math.IsInf(a.estimatedCost, 0) {
253 cost := a.estimatedCost
254 currency := a.currency
255 usage.EstimatedCost = &cost
256 usage.Currency = &currency
257 complete := a.costComplete
258 usage.CostComplete = &complete
259 }
260 return usage
261 }
262
263 type statusTelemetry struct {
264 mu sync.Mutex
265 sequence uint64
266 state string
267 phase string
268 turnOutcome ReasonixTurnOutcome
269 finalReadiness ReasonixFinalReadiness
270 turnUsage usageAccumulator
271 cumulative usageAccumulator
272 goalOverride string
273 }
274
275 func newStatusTelemetry() *statusTelemetry {
276 return &statusTelemetry{
277 state: "idle",
278 phase: "idle",
279 turnOutcome: ReasonixTurnOutcome{Kind: "none"},
280 finalReadiness: ReasonixFinalReadiness{
281 Risks: []string{},
282 },
283 }
284 }
285
286 func (t *statusTelemetry) mutate(fn func(*statusTelemetry)) uint64 {
287 t.mu.Lock()
288 defer t.mu.Unlock()
289 fn(t)
290 t.sequence++
291 return t.sequence
292 }
293
294 func (t *statusTelemetry) beginTurn() {
295 t.mutate(func(t *statusTelemetry) {
296 t.state = "running"
297 t.phase = "starting"
298 t.turnOutcome = ReasonixTurnOutcome{Kind: "none"}
299 t.finalReadiness = ReasonixFinalReadiness{Risks: []string{}}
300 t.turnUsage = usageAccumulator{}
301 t.goalOverride = ""
302 })
303 }
304
305 func (t *statusTelemetry) onEvent(e event.Event) (string, bool) {
306 switch e.Kind {
307 case event.Phase:
308 t.mutate(func(t *statusTelemetry) {
309 t.phase = normalizeStatusPhase(e)
310 })
311 return "phase", true
312 case event.Usage:
313 t.mutate(func(t *statusTelemetry) {
314 t.turnUsage.addQuoted(e.Usage, e.Pricing, e.CostQuote, e.UsageSource)
315 t.cumulative.addQuoted(e.Usage, e.Pricing, e.CostQuote, e.UsageSource)
316 })
317 return "usage", true
318 case event.ApprovalRequest:
319 t.mutate(func(t *statusTelemetry) { t.phase = "waiting_permission" })
320 return "phase", true
321 case event.AskRequest:
322 t.mutate(func(t *statusTelemetry) { t.phase = "waiting_input" })
323 return "phase", true
324 case event.ToolDispatch:
325 t.mutate(func(t *statusTelemetry) { t.phase = "implementing" })
326 case event.Notice:
327 if e.Code == event.NoticeCodeFinalReadiness {
328 t.mutate(func(t *statusTelemetry) { t.phase = "checking_readiness" })
329 return "phase", true
330 }
331 }
332 return "", false
333 }
334
335 func (t *statusTelemetry) finishTurn(runErr error, cancelled bool, goalStatus, summary string) string {
336 eventName := "completion"
337 t.mutate(func(t *statusTelemetry) {
338 t.state = "idle"
339 t.finalReadiness.Summary = clipStatusText(summary, 16_384)
340 t.finalReadiness.Risks = []string{}
341 t.goalOverride = ""
342 switch {
343 case cancelled:
344 t.phase = "cancelled"
345 t.turnOutcome = ReasonixTurnOutcome{Kind: "cancelled"}
346 t.goalOverride = "cancelled"
347 eventName = "completion"
348 case runErr == nil && goalStatus == control.GoalStatusComplete:
349 t.phase = "review_ready"
350 t.turnOutcome = ReasonixTurnOutcome{Kind: "completed"}
351 t.finalReadiness.ReadyForReview = true
352 case runErr == nil && (goalStatus == "" || goalStatus == control.GoalStatusStopped):
353 t.phase = "completed"
354 t.turnOutcome = ReasonixTurnOutcome{Kind: "completed"}
355 t.finalReadiness.ReadyForReview = true
356 case goalStatus == control.GoalStatusBlocked:
357 t.phase = "paused"
358 t.turnOutcome = ReasonixTurnOutcome{Kind: "paused", Reason: "goal blocked"}
359 eventName = "pause"
360 default:
361 var readinessErr *agent.FinalReadinessError
362 var recoveryPause *agent.RecoveryPauseError
363 var completionPause *agent.CompletionUncertainError
364 _, runPause := agent.InspectRunPause(runErr)
365 switch {
366 case errors.As(runErr, &readinessErr):
367 t.phase = "readiness_paused"
368 t.turnOutcome = ReasonixTurnOutcome{Kind: "paused", Reason: clipStatusError(readinessErr, 2_048)}
369 t.finalReadiness.Risks = redactStatusTexts(readinessErr.Missing, 2_048)
370 eventName = "pause"
371 case errors.As(runErr, &recoveryPause):
372 t.phase = "recovery_paused"
373 t.turnOutcome = ReasonixTurnOutcome{Kind: "paused", Reason: clipStatusText(recoveryPause.Error(), 2_048)}
374 eventName = "pause"
375 case errors.As(runErr, &completionPause):
376 t.phase = "completion_uncertain"
377 t.turnOutcome = ReasonixTurnOutcome{Kind: "paused", Reason: clipStatusText(completionPause.Error(), 2_048)}
378 eventName = "pause"
379 case runPause:
380 t.phase = "paused"
381 t.turnOutcome = ReasonixTurnOutcome{Kind: "paused", Reason: clipStatusError(runErr, 2_048)}
382 eventName = "pause"
383 case runErr != nil:
384 t.phase = "error"
385 t.turnOutcome = ReasonixTurnOutcome{Kind: "error", Reason: clipStatusError(runErr, 2_048), Diagnostic: provider.DiagnoseFailure(runErr)}
386 t.goalOverride = "failed"
387 eventName = "error"
388 default:
389 t.phase = "paused"
390 t.turnOutcome = ReasonixTurnOutcome{Kind: "paused", Reason: "goal is not complete"}
391 eventName = "pause"
392 }
393 }
394 })
395 return eventName
396 }
397
398 type statusTelemetrySnapshot struct {
399 sequence uint64
400 state string
401 phase string
402 turnOutcome ReasonixTurnOutcome
403 finalReadiness ReasonixFinalReadiness
404 turnUsage ReasonixUsage
405 cumulative ReasonixUsage
406 goalOverride string
407 }
408
409 type persistedUsageAccumulator struct {
410 PromptTokens int `json:"promptTokens"`
411 CompletionTokens int `json:"completionTokens"`
412 ReasoningTokens int `json:"reasoningTokens"`
413 CacheHitTokens int `json:"cacheHitTokens"`
414 CacheMissTokens int `json:"cacheMissTokens"`
415 Estimated bool `json:"estimated,omitempty"`
416 Events int `json:"events"`
417 PricedEvents int `json:"pricedEvents"`
418 EstimatedCost float64 `json:"estimatedCost"`
419 Currency string `json:"currency,omitempty"`
420 Source string `json:"source,omitempty"`
421 CostComplete *bool `json:"costComplete,omitempty"`
422 }
423
424 type persistedStatusTelemetry struct {
425 Sequence uint64 `json:"sequence"`
426 State string `json:"state"`
427 Phase string `json:"phase"`
428 TurnOutcome ReasonixTurnOutcome `json:"turnOutcome"`
429 FinalReadiness ReasonixFinalReadiness `json:"finalReadiness"`
430 TurnUsage persistedUsageAccumulator `json:"turnUsage"`
431 Cumulative persistedUsageAccumulator `json:"cumulative"`
432 GoalOverride string `json:"goalOverride,omitempty"`
433 }
434
435 func persistUsage(a usageAccumulator) persistedUsageAccumulator {
436 var costComplete *bool
437 if a.pricedEvents > 0 {
438 complete := a.costComplete
439 costComplete = &complete
440 }
441 return persistedUsageAccumulator{
442 PromptTokens: a.promptTokens, CompletionTokens: a.completionTokens,
443 ReasoningTokens: a.reasoningTokens, CacheHitTokens: a.cacheHitTokens,
444 CacheMissTokens: a.cacheMissTokens, Estimated: a.estimated, Events: a.events,
445 PricedEvents: a.pricedEvents, EstimatedCost: a.estimatedCost,
446 Currency: a.currency, Source: a.source, CostComplete: costComplete,
447 }
448 }
449
450 func restoreUsage(a persistedUsageAccumulator) usageAccumulator {
451 costComplete := a.PricedEvents > 0 && a.Currency != ""
452 if a.CostComplete != nil {
453 costComplete = *a.CostComplete
454 }
455 return usageAccumulator{
456 promptTokens: a.PromptTokens, completionTokens: a.CompletionTokens,
457 reasoningTokens: a.ReasoningTokens, cacheHitTokens: a.CacheHitTokens,
458 cacheMissTokens: a.CacheMissTokens, estimated: a.Estimated, events: a.Events,
459 pricedEvents: a.PricedEvents, estimatedCost: a.EstimatedCost,
460 currency: a.Currency, source: a.Source, costComplete: costComplete,
461 }
462 }
463
464 func (t *statusTelemetry) persisted() *persistedStatusTelemetry {
465 if t == nil {
466 return nil
467 }
468 t.mu.Lock()
469 defer t.mu.Unlock()
470 return &persistedStatusTelemetry{
471 Sequence: t.sequence, State: t.state, Phase: t.phase,
472 TurnOutcome: redactTurnOutcome(t.turnOutcome),
473 FinalReadiness: ReasonixFinalReadiness{
474 ReadyForReview: t.finalReadiness.ReadyForReview,
475 Summary: clipStatusText(t.finalReadiness.Summary, 16_384),
476 Risks: redactStatusTexts(t.finalReadiness.Risks, 2_048),
477 },
478 TurnUsage: persistUsage(t.turnUsage), Cumulative: persistUsage(t.cumulative),
479 GoalOverride: t.goalOverride,
480 }
481 }
482
483 func restoreStatusTelemetry(saved *persistedStatusTelemetry) *statusTelemetry {
484 t := newStatusTelemetry()
485 if saved == nil {
486 return t
487 }
488 interrupted := saved.State == "running"
489 t.sequence = saved.Sequence
490 // A restored process never owns the turn that wrote a running snapshot.
491 // Publish a new, terminal recovery state instead of leaving supervisors
492 // waiting on work that no longer exists in this runtime.
493 t.state = "idle"
494 t.phase = normalizePersistedStatusPhase(saved.Phase)
495 t.turnOutcome = redactTurnOutcome(saved.TurnOutcome)
496 if t.turnOutcome.Kind == "" {
497 t.turnOutcome.Kind = "none"
498 }
499 t.finalReadiness = ReasonixFinalReadiness{
500 ReadyForReview: saved.FinalReadiness.ReadyForReview,
501 Summary: clipStatusText(saved.FinalReadiness.Summary, 16_384),
502 Risks: redactStatusTexts(saved.FinalReadiness.Risks, 2_048),
503 }
504 t.turnUsage = restoreUsage(saved.TurnUsage)
505 t.cumulative = restoreUsage(saved.Cumulative)
506 t.goalOverride = saved.GoalOverride
507 if interrupted {
508 t.sequence++
509 t.phase = "recovery_paused"
510 t.turnOutcome = ReasonixTurnOutcome{Kind: "paused", Reason: "previous turn interrupted"}
511 t.finalReadiness.ReadyForReview = false
512 }
513 return t
514 }
515
516 func (t *statusTelemetry) snapshot() statusTelemetrySnapshot {
517 t.mu.Lock()
518 defer t.mu.Unlock()
519 return statusTelemetrySnapshot{
520 sequence: t.sequence,
521 state: t.state,
522 phase: t.phase,
523 turnOutcome: ReasonixTurnOutcome{
524 Kind: t.turnOutcome.Kind, Reason: clipStatusCredentialText(t.turnOutcome.Reason, 2_048), Diagnostic: t.turnOutcome.Diagnostic,
525 },
526 finalReadiness: ReasonixFinalReadiness{
527 ReadyForReview: t.finalReadiness.ReadyForReview,
528 Summary: clipStatusText(t.finalReadiness.Summary, 16_384),
529 Risks: redactStatusTexts(t.finalReadiness.Risks, 2_048),
530 },
531 turnUsage: t.turnUsage.wire(),
532 cumulative: t.cumulative.wire(),
533 goalOverride: t.goalOverride,
534 }
535 }
536
537 func redactTurnOutcome(outcome ReasonixTurnOutcome) ReasonixTurnOutcome {
538 outcome.Reason = clipStatusCredentialText(outcome.Reason, 2_048)
539 return outcome
540 }
541
542 func defaultSessionRuntimeState(cwd string) SessionRuntimeState {
543 engine := "bubblewrap"
544 if runtime.GOOS == "darwin" {
545 engine = "seatbelt"
546 }
547 return SessionRuntimeState{
548 PlannerMode: "on",
549 Sandbox: SessionSandboxState{
550 Mode: "enforce",
551 Engine: engine,
552 Available: true,
553 WorkspaceRoot: cwd,
554 WriteRoots: []string{cwd},
555 },
556 }
557 }
558
559 func normalizeStatusEffort(value *string) string {
560 if value == nil || strings.TrimSpace(*value) == "" {
561 return "auto"
562 }
563 return strings.TrimSpace(*value)
564 }
565
566 func normalizeGoalStatus(value string) string {
567 switch value {
568 case control.GoalStatusRunning, control.GoalStatusComplete, control.GoalStatusBlocked:
569 return value
570 default:
571 return "none"
572 }
573 }
574
575 func redactStatusTexts(values []string, limit int) []string {
576 out := make([]string, 0, len(values))
577 for _, value := range values {
578 out = append(out, clipStatusText(value, limit))
579 }
580 return out
581 }
582
583 func normalizeStatusPhase(e event.Event) string {
584 switch strings.TrimSpace(e.Source) {
585 case event.UsageSourcePlanner:
586 return "planning"
587 case event.UsageSourceExecutor:
588 return "implementing"
589 }
590 lower := strings.ToLower(strings.TrimSpace(e.Text))
591 switch {
592 case strings.Contains(lower, "planning"):
593 return "planning"
594 case strings.Contains(lower, "executing"), strings.Contains(lower, "implementing"):
595 return "implementing"
596 default:
597 return "working"
598 }
599 }
600
601 func normalizePersistedStatusPhase(value string) string {
602 value = strings.TrimSpace(value)
603 switch value {
604 case "idle", "starting", "planning", "working", "implementing",
605 "waiting_permission", "waiting_input", "checking_readiness",
606 "cancelled", "review_ready", "completed", "paused",
607 "readiness_paused", "recovery_paused", "error":
608 return value
609 default:
610 return normalizeStatusPhase(event.Event{Kind: event.Phase, Text: value})
611 }
612 }
613
614 func (s *service) sessionRuntimeState(ctx context.Context, p SessionRuntimeStateParams) (SessionRuntimeState, error) {
615 if provider, ok := s.factory.(SessionRuntimeStateProvider); ok {
616 state, err := provider.SessionRuntimeState(ctx, p)
617 if err != nil {
618 return SessionRuntimeState{}, err
619 }
620 if strings.TrimSpace(state.PlannerMode) == "" {
621 state.PlannerMode = "on"
622 }
623 if state.Sandbox.WriteRoots == nil {
624 state.Sandbox.WriteRoots = []string{}
625 }
626 return state, nil
627 }
628 return defaultSessionRuntimeState(p.Cwd), nil
629 }
630
631 func (s *service) bindStatusEvents(sess *acpSession) {
632 if sess == nil || sess.sink == nil {
633 return
634 }
635 if sess.status == nil {
636 sess.status = newStatusTelemetry()
637 }
638 sess.sink.bindStatus(func(e event.Event) {
639 eventName, publish := sess.status.onEvent(e)
640 if publish {
641 s.publishStatus(sess, eventName)
642 }
643 })
644 }
645
646 func (s *service) sessionStatus(_ context.Context, raw json.RawMessage) (any, error) {
647 var p SessionStatusParams
648 if err := json.Unmarshal(raw, &p); err != nil {
649 return nil, &RPCError{Code: ErrInvalidParams, Message: sessionStatusMethod + ": " + err.Error()}
650 }
651 sess := s.session(p.SessionID)
652 if sess == nil {
653 return nil, &RPCError{Code: ErrInvalidParams, Message: sessionStatusMethod + ": unknown session " + p.SessionID}
654 }
655 return sess.statusSnapshot(), nil
656 }
657
658 func (s *service) publishStatus(sess *acpSession, eventName string) {
659 if sess == nil {
660 return
661 }
662 status := sess.statusSnapshot()
663 _ = s.conn.Notify(sessionStatusUpdateMethod, ReasonixStatusUpdate{
664 SchemaVersion: reasonixStatusSchemaVersion,
665 Sequence: status.Sequence,
666 SessionID: status.SessionID,
667 Event: eventName,
668 Status: status,
669 })
670 }
671
672 func (s *acpSession) statusSnapshot() ReasonixSessionStatus {
673 s.mu.Lock()
674 id := s.id
675 ctrl := s.ctrl
676 model := s.model
677 effort := cloneStringPtr(s.effortOverride)
678 mode := s.modeID
679 runtimeState := s.runtimeState
680 telemetry := s.status
681 s.mu.Unlock()
682
683 if telemetry == nil {
684 telemetry = newStatusTelemetry()
685 }
686 t := telemetry.snapshot()
687 goalStatus := "none"
688 goalObjective := ""
689 var goalRuntime *ReasonixGoalRuntime
690 if ctrl != nil {
691 goalStatus = normalizeGoalStatus(ctrl.GoalStatus())
692 goalObjective = clipStatusText(ctrl.Goal(), 16_384)
693 if strings.TrimSpace(goalObjective) != "" {
694 rt := ctrl.GoalRuntime()
695 goalRuntime = &ReasonixGoalRuntime{
696 TurnsUsed: rt.TurnsUsed,
697 TurnsLimit: rt.TurnsLimit,
698 TokensUsed: rt.TokensUsed,
699 RequestsUsed: rt.RequestsUsed,
700 WorkDurationMs: rt.WorkDurationMs,
701 TokensLimit: rt.TokensLimit,
702 NoProgressTurns: rt.NoProgressTurns,
703 NoProgressLimit: rt.NoProgressLimit,
704 LastReason: rt.LastReason,
705 StopCause: rt.StopCause,
706 BudgetExtensions: rt.BudgetExtensions,
707 }
708 }
709 }
710 if t.goalOverride != "" {
711 goalStatus = t.goalOverride
712 }
713 mode = normalizeACPCollaborationMode(mode)
714 // WorkMode is a deprecated wire-compat field pinned to the historical
715 // default; execution modes no longer exist at runtime.
716 workMode := "standard"
717 if runtimeState.PlannerMode != "off" {
718 runtimeState.PlannerMode = "on"
719 }
720 if runtimeState.Sandbox.WriteRoots == nil {
721 runtimeState.Sandbox.WriteRoots = []string{}
722 }
723 phase := strings.TrimSpace(t.phase)
724 if phase == "" {
725 phase = "idle"
726 }
727 state := t.state
728 if state != "running" {
729 state = "idle"
730 }
731 var protocolRecovery *provider.ProtocolRecoveryAction
732 if pending, ok := ctrl.(interface {
733 PendingProtocolRecovery() *provider.ProtocolRecoveryAction
734 }); ok {
735 protocolRecovery = pending.PendingProtocolRecovery()
736 }
737 return ReasonixSessionStatus{
738 ProtocolRecovery: protocolRecovery,
739 SchemaVersion: reasonixStatusSchemaVersion,
740 Sequence: t.sequence,
741 SessionID: id,
742 State: state,
743 Model: strings.TrimSpace(model),
744 Effort: normalizeStatusEffort(effort),
745 Mode: mode,
746 WorkMode: workMode,
747 PlannerMode: runtimeState.PlannerMode,
748 Goal: ReasonixStatusGoal{
749 Status: goalStatus,
750 Objective: goalObjective,
751 Runtime: goalRuntime,
752 },
753 Phase: phase,
754 TurnOutcome: t.turnOutcome,
755 FinalReadiness: t.finalReadiness,
756 Sandbox: runtimeState.Sandbox,
757 Usage: ReasonixStatusUsage{
758 Turn: t.turnUsage,
759 Cumulative: t.cumulative,
760 },
761 }
762 }
763
764 func finalAssistantSummary(ctrl acpController) string {
765 if ctrl == nil {
766 return ""
767 }
768 history := ctrl.History()
769 for _, v := range slices.Backward(history) {
770 if v.Role == provider.RoleAssistant && strings.TrimSpace(v.Content) != "" {
771 return v.Content
772 }
773 }
774 return ""
775 }
776
776 lines GO