Skip to main content

forge_primitives/
runtime.rs

1//! Runtime status - mode/model state, available models/commands/agents,
2//! rate-limit views, retry classification, session status, terminal
3//! reason, monitor records. Wire-shape state the agent โ†” UI channel
4//! passes around to describe "what's the live session doing right now".
5
6use serde::{Deserialize, Serialize};
7
8use crate::messages::{RateLimitStatus, TaskUsage};
9
10/// One sub-agent instance as a view draws its card: a `Task`/`Agent`
11/// dispatch joined with the frames that ran under it.
12///
13/// The join itself is the core's (`forge_workspace::subagent_cards`); this
14/// is the shape it crosses in, so a client draws the instance list rather
15/// than folding one of its own from the raw frames.
16#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
17pub struct SubagentCard {
18    /// What the instance is called: the roster's description for it, else
19    /// the dispatch's own.
20    pub name: String,
21    /// The `tool_use` id of the dispatch that opened it, which is what lets
22    /// a view join its cards to the chat row the call itself drew.
23    pub dispatch_id: String,
24    /// The agent type the dispatch named (e.g. `"general-purpose"`), which
25    /// is the head's own word for what ran. `None` for a dispatch that
26    /// named no type.
27    pub agent_type: Option<String>,
28    /// Whether the instance is still working.
29    pub running: bool,
30    /// Whether it ended badly: the row answering its dispatch errored, or
31    /// the roster's own word for the ending was a failure. A view draws its
32    /// cross from this rather than from the absence of `running`.
33    pub failed: bool,
34    /// Whether it runs in the background, outliving the turn that spawned
35    /// it. From `is_backgrounded` on the roster frame, falling back to the
36    /// dispatch's own `run_in_background`.
37    pub backgrounded: bool,
38    /// When it settled, in Unix milliseconds, when a frame stated an
39    /// instant. `None` while it runs and for any ending that named none.
40    pub ended_at_ms: Option<u64>,
41    /// Every call the instance fired, of which [`Self::tail`] holds the
42    /// most recent.
43    pub calls: usize,
44    /// The instance's most recent calls, oldest first.
45    pub tail: Vec<SubagentCall>,
46    /// The usage its last progress or notification frame reported.
47    pub usage: Option<TaskUsage>,
48}
49
50/// One call in a card's tail: the tool, what it named, and how it ended.
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52pub struct SubagentCall {
53    /// The CLI's own name for the tool (e.g. `"Bash"`).
54    pub name: String,
55    /// What the call names: the command it ran, the file it read.
56    pub title: String,
57    /// How the call ended. `Pending` while its result is out.
58    pub status: SubagentCallStatus,
59}
60
61/// How one of an instance's calls ended.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(rename_all = "snake_case")]
64pub enum SubagentCallStatus {
65    Pending,
66    InProgress,
67    Completed,
68    Failed,
69    Killed,
70}
71
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73pub struct ModeInfo {
74    pub id: String,
75    pub name: String,
76    pub description: Option<String>,
77}
78
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80pub struct ModeState {
81    pub current_mode_id: String,
82    pub current_mode_name: String,
83    pub available_modes: Vec<ModeInfo>,
84}
85
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87pub struct AvailableCommand {
88    pub name: String,
89    pub description: String,
90    pub input_hint: Option<String>,
91}
92
93impl AvailableCommand {
94    pub fn new(name: impl Into<String>, description: impl Into<String>) -> Self {
95        Self { name: name.into(), description: description.into(), input_hint: None }
96    }
97
98    pub fn input_hint(mut self, input_hint: impl Into<String>) -> Self {
99        self.input_hint = Some(input_hint.into());
100        self
101    }
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
105pub struct AvailableAgent {
106    pub name: String,
107    pub description: String,
108    pub model: Option<String>,
109}
110
111impl AvailableAgent {
112    pub fn new(name: impl Into<String>, description: impl Into<String>) -> Self {
113        Self { name: name.into(), description: description.into(), model: None }
114    }
115
116    pub fn model(mut self, model: impl Into<String>) -> Self {
117        self.model = Some(model.into());
118        self
119    }
120}
121
122#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
123pub enum EffortLevel {
124    #[serde(rename = "low")]
125    Low,
126    #[serde(rename = "medium")]
127    Medium,
128    #[serde(rename = "high")]
129    High,
130    #[serde(rename = "xhigh")]
131    Xhigh,
132    #[serde(rename = "max")]
133    Max,
134}
135
136impl EffortLevel {
137    pub const fn as_stored(self) -> &'static str {
138        match self {
139            Self::Low => "low",
140            Self::Medium => "medium",
141            Self::High => "high",
142            Self::Xhigh => "xhigh",
143            Self::Max => "max",
144        }
145    }
146
147    pub const fn label(self) -> &'static str {
148        match self {
149            Self::Low => "Low",
150            Self::Medium => "Medium",
151            Self::High => "High",
152            Self::Xhigh => "Extra High",
153            Self::Max => "Max",
154        }
155    }
156
157    pub const fn description(self) -> &'static str {
158        match self {
159            Self::Low => "Fastest responses",
160            Self::Medium => "Balanced speed and depth",
161            Self::High => "Deeper reasoning",
162            Self::Xhigh => "Extra-high reasoning",
163            Self::Max => "Maximum reasoning",
164        }
165    }
166
167    pub fn from_stored(value: &str) -> Option<Self> {
168        match value {
169            "low" => Some(Self::Low),
170            "medium" => Some(Self::Medium),
171            "high" => Some(Self::High),
172            "xhigh" | "extra_high" => Some(Self::Xhigh),
173            "max" => Some(Self::Max),
174            _ => None,
175        }
176    }
177}
178
179#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
180pub struct AvailableModel {
181    pub id: String,
182    pub display_name: String,
183    pub description: Option<String>,
184    pub supports_effort: bool,
185    #[serde(default)]
186    pub supported_effort_levels: Vec<EffortLevel>,
187    pub supports_adaptive_thinking: Option<bool>,
188    pub supports_auto_mode: Option<bool>,
189}
190
191impl AvailableModel {
192    pub fn new(id: impl Into<String>, display_name: impl Into<String>) -> Self {
193        Self {
194            id: id.into(),
195            display_name: display_name.into(),
196            description: None,
197            supports_effort: false,
198            supported_effort_levels: Vec::new(),
199            supports_adaptive_thinking: None,
200            supports_auto_mode: None,
201        }
202    }
203
204    pub fn description(mut self, description: impl Into<String>) -> Self {
205        self.description = Some(description.into());
206        self
207    }
208
209    pub fn supports_effort(mut self, supports_effort: bool) -> Self {
210        self.supports_effort = supports_effort;
211        self
212    }
213
214    pub fn supported_effort_levels(mut self, supported_effort_levels: Vec<EffortLevel>) -> Self {
215        self.supported_effort_levels = supported_effort_levels;
216        self
217    }
218
219    pub fn supports_adaptive_thinking(mut self, supports_adaptive_thinking: Option<bool>) -> Self {
220        self.supports_adaptive_thinking = supports_adaptive_thinking;
221        self
222    }
223
224    pub fn supports_auto_mode(mut self, supports_auto_mode: Option<bool>) -> Self {
225        self.supports_auto_mode = supports_auto_mode;
226        self
227    }
228}
229
230#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
231pub struct CurrentModel {
232    pub requested_id: Option<String>,
233    pub resolved_id: String,
234    pub display_name_short: String,
235    pub display_name_long: String,
236    pub catalog_id: Option<String>,
237    pub supports_effort: bool,
238    #[serde(default)]
239    pub supported_effort_levels: Vec<EffortLevel>,
240    pub supports_auto_mode: Option<bool>,
241    pub supports_adaptive_thinking: Option<bool>,
242    pub is_authoritative: bool,
243}
244
245impl CurrentModel {
246    pub fn new(
247        resolved_id: impl Into<String>,
248        display_name_short: impl Into<String>,
249        display_name_long: impl Into<String>,
250    ) -> Self {
251        Self {
252            requested_id: None,
253            resolved_id: resolved_id.into(),
254            display_name_short: display_name_short.into(),
255            display_name_long: display_name_long.into(),
256            catalog_id: None,
257            supports_effort: false,
258            supported_effort_levels: Vec::new(),
259            supports_auto_mode: None,
260            supports_adaptive_thinking: None,
261            is_authoritative: false,
262        }
263    }
264
265    pub fn requested_id(mut self, requested_id: impl Into<String>) -> Self {
266        self.requested_id = Some(requested_id.into());
267        self
268    }
269
270    pub fn catalog_id(mut self, catalog_id: impl Into<String>) -> Self {
271        self.catalog_id = Some(catalog_id.into());
272        self
273    }
274
275    pub fn supports_effort(mut self, supports_effort: bool) -> Self {
276        self.supports_effort = supports_effort;
277        self
278    }
279
280    pub fn supported_effort_levels(mut self, supported_effort_levels: Vec<EffortLevel>) -> Self {
281        self.supported_effort_levels = supported_effort_levels;
282        self
283    }
284
285    pub fn supports_adaptive_thinking(mut self, supports_adaptive_thinking: Option<bool>) -> Self {
286        self.supports_adaptive_thinking = supports_adaptive_thinking;
287        self
288    }
289
290    pub fn supports_auto_mode(mut self, supports_auto_mode: Option<bool>) -> Self {
291        self.supports_auto_mode = supports_auto_mode;
292        self
293    }
294
295    pub fn authoritative(mut self, is_authoritative: bool) -> Self {
296        self.is_authoritative = is_authoritative;
297        self
298    }
299}
300
301#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
302#[serde(rename_all = "snake_case")]
303pub enum ApiRetryError {
304    AuthenticationFailed,
305    BillingError,
306    RateLimit,
307    InvalidRequest,
308    ServerError,
309    MaxOutputTokens,
310    #[serde(other)]
311    Unknown,
312}
313
314#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
315#[serde(rename_all = "snake_case")]
316pub enum RuntimeSessionState {
317    Idle,
318    Running,
319    RequiresAction,
320}
321
322/// The machine turn running on a seat, which takes its composer away.
323///
324/// Both are turns the person did not start and cannot type into: forge's own
325/// judgment prompt, and the CLI's compaction. A handoff outranks a compaction,
326/// because it is the one that replaces the seat.
327#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
328#[serde(rename_all = "snake_case")]
329pub enum MachineTurn {
330    Handoff,
331    Compacting,
332}
333
334#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
335pub struct SettingsParseErrorUpdate {
336    pub file: Option<String>,
337    pub path: String,
338    pub message: String,
339}
340
341#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
342pub struct RateLimitUpdate {
343    pub status: RateLimitStatus,
344    pub resets_at: Option<f64>,
345    pub utilization: Option<f64>,
346    pub rate_limit_type: Option<String>,
347    pub overage_status: Option<RateLimitStatus>,
348    pub overage_resets_at: Option<f64>,
349    pub overage_disabled_reason: Option<String>,
350    pub is_using_overage: Option<bool>,
351    pub surpassed_threshold: Option<f64>,
352}
353
354#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
355pub struct ApiRetryUpdate {
356    pub attempt: u64,
357    pub max_retries: u64,
358    pub retry_delay_ms: u64,
359    pub error_status: Option<u16>,
360    pub error: ApiRetryError,
361}
362
363/// Why a turn ended - surfaced by `AgentEvent::TurnComplete` /
364/// `TurnError` to the UI for status-line classification.
365#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
366#[serde(rename_all = "snake_case")]
367pub enum TerminalReason {
368    BlockingLimit,
369    RapidRefillBreaker,
370    PromptTooLong,
371    ImageError,
372    ModelError,
373    AbortedStreaming,
374    AbortedTools,
375    StopHookPrevented,
376    HookStopped,
377    ToolDeferred,
378    MaxTurns,
379    Completed,
380    #[serde(other)]
381    Unknown,
382}
383
384impl TerminalReason {
385    pub const fn as_stored(self) -> &'static str {
386        match self {
387            Self::BlockingLimit => "blocking_limit",
388            Self::RapidRefillBreaker => "rapid_refill_breaker",
389            Self::PromptTooLong => "prompt_too_long",
390            Self::ImageError => "image_error",
391            Self::ModelError => "model_error",
392            Self::AbortedStreaming => "aborted_streaming",
393            Self::AbortedTools => "aborted_tools",
394            Self::StopHookPrevented => "stop_hook_prevented",
395            Self::HookStopped => "hook_stopped",
396            Self::ToolDeferred => "tool_deferred",
397            Self::MaxTurns => "max_turns",
398            Self::Completed => "completed",
399            Self::Unknown => "unknown",
400        }
401    }
402}
403
404/// Lifecycle state of a session. `forge-workspace` derives one per
405/// session for the roster a view reads, and one per worker for
406/// `WorkerStatus::activity`. The two derivations are
407/// independent and need not agree.
408#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
409pub enum SessionLifecycleState {
410    /// No subprocess yet; lead exists conceptually but has never
411    /// been spawned (or has been freed).
412    #[default]
413    Sleeping,
414    /// Subprocess spawn in flight - between user click and first
415    /// `Connected` event from the bridge.
416    Spawning,
417    /// Subprocess is alive and idle (no turn in progress).
418    Idle,
419    /// Subprocess is mid-turn or actively streaming.
420    Running,
421    /// Cannot proceed without a human - `forge-workspace` derives it
422    /// for a session holding a pending interaction (permission prompt,
423    /// `AskUserQuestion`, elicitation).
424    Attention,
425    /// Bridge is waiting on `/login` to complete before the session
426    /// can be spawned.
427    AuthRequired,
428    /// Setup (or running) hit a fatal error - `ConnectionFailed`
429    /// drove this. Bucket is dead but kept in the roster so the
430    /// user can see the error banner.
431    Failed,
432    /// The seat's subprocess died while it was supposed to be up and
433    /// the supervisor is between attempts. Nothing occupies the slot
434    /// until one lands.
435    Restarting,
436    /// User triggered `/logout`. The bridge is down; user can start
437    /// a new session via /new or by clicking another project.
438    LoggedOut,
439}
440
441/// Per-session SDK turn state - model-resolution cache, mode
442/// capability state, MCP per-server cooldowns, and the auth/error
443/// flags that survive across messages. Held authoritatively by
444/// `forge-workspace`; a view projects from there.
445#[derive(Debug, Default)]
446pub struct SessionTurnState {
447    /// Live tool-call store keyed by `tool_use_id` for cross-message
448    /// `tool_use โ†” tool_result` pairing.
449    pub tool_calls: std::collections::HashMap<String, crate::session_update::ToolCall>,
450    /// Maps task-tool `task_id` โ†’ `tool_use_id` so `TaskProgress` /
451    /// `TaskNotification` messages can resolve back to the originating
452    /// tool call for `ToolCallUpdate` emission.
453    pub task_tool_use_ids: std::collections::HashMap<String, String>,
454
455    /// Raw model id from the CLI's session-init payload.
456    pub model_id: String,
457    /// Model id explicitly requested via `/model`.
458    pub requested_model_id: Option<String>,
459    /// Resolved model id after runtime fallback from the requested id.
460    pub resolved_runtime_model_id: Option<String>,
461
462    /// Active permission mode (typed enum, not the wire string).
463    /// Populated from System(init).permissionMode and the `SetMode`
464    /// command path.
465    pub mode: Option<crate::permission::PermissionMode>,
466    /// Permission modes the runtime currently supports.
467    pub supported_mode_ids: Vec<crate::permission::PermissionMode>,
468    /// Permission modes recognised but currently unavailable.
469    pub runtime_unavailable_mode_ids: Vec<crate::permission::PermissionMode>,
470    /// Current mode resolution alongside the human-readable label.
471    pub mode_state: Option<ModeState>,
472
473    /// Whether the agents catalogue has been emitted for this turn. The first
474    /// `system/init` of a turn carries it; subsequent re-fires within the same
475    /// turn are no-ops.
476    pub agents_emitted_this_turn: bool,
477
478    /// Last assistant error subtype seen on the wire - survives
479    /// across messages so a subsequent `Result` can classify the
480    /// turn correctly.
481    pub last_assistant_error: Option<String>,
482}
483
484/// Lifecycle status of a Monitor (`Monitor` tool_use).
485///
486/// A view keeps a monitor section while any of its monitors is running:
487/// once every one of them is terminal the set is dropped rather than left
488/// standing empty.
489#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
490#[serde(rename_all = "snake_case")]
491pub enum MonitorStatus {
492    /// Monitor is active. Persistent monitors stay `Running` until
493    /// TaskStop or session end; non-persistent monitors run until
494    /// their `timeout_ms` expires or the watched command exits.
495    Running,
496    /// Monitor terminated via TaskStop / killed / clean exit.
497    Stopped,
498    /// Monitor completed cleanly (synonym for Stopped on the
499    /// renderer; preserved as a distinct variant in case downstream
500    /// callers want to disambiguate normal-exit from explicit-kill).
501    Completed,
502    /// Monitor's `timeout_ms` fired. Renderer surfaces a distinct
503    /// `ยท timed out` badge so users see the failure mode at a glance.
504    TimedOut,
505}
506
507impl MonitorStatus {
508    /// Whether the monitor has finished, whichever way it ended.
509    pub const fn is_terminal(self) -> bool {
510        !matches!(self, Self::Running)
511    }
512}
513
514/// One Monitor this session has running or has finished: the tool
515/// call that started it, the CLI's task id once the wire names one,
516/// and where it got to.
517#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
518pub struct MonitorRecord {
519    /// `tool_use_id` of the `Monitor` tool call - the id every
520    /// monitor surface is keyed by.
521    pub tool_use_id: String,
522    /// Task id the CLI assigned, from `task_started` or a
523    /// `task_*` lifecycle message. `None` until one arrives, which
524    /// is why a view's transitions key on it rather than the
525    /// tool_use id.
526    pub task_id: Option<String>,
527    /// `tool_input.description` - the headline label.
528    pub description: String,
529    /// `tool_input.command` - the watched shell command.
530    pub command: String,
531    /// `tool_input.persistent` - whether the monitor outlives the
532    /// event that triggered it.
533    pub persistent: bool,
534    /// `tool_input.timeout_ms` - zero when no explicit timeout.
535    pub timeout_ms: u64,
536    pub status: MonitorStatus,
537    /// Path the watched command writes to, once `task_notification`
538    /// names one. A view reads the file for the monitor's tail.
539    pub output_file: Option<String>,
540    /// When the task ended, from the `end_time` a `task_updated` patch
541    /// carries. `None` while it runs and for any transition that
542    /// stated no instant, which is why a view draws an age only when
543    /// one is here rather than counting from the status.
544    pub ended_at: Option<std::time::SystemTime>,
545}