Skip to main content

halter_protocol/
lib.rs

1//! Shared wire types and runtime contracts for the halter workspace.
2//!
3//! This crate contains the serializable protocol structs that the runtime,
4//! providers, hooks, tools, and session stores exchange. It intentionally
5//! stays dependency-light and mostly data-oriented so higher-level crates can
6//! agree on event, message, provider, and resource shapes without depending on
7//! each other.
8// pattern: Functional Core
9
10use std::fmt;
11use std::path::PathBuf;
12
13use bytes::Bytes;
14use chrono::{DateTime, Utc};
15use indexmap::IndexMap;
16use schemars::JsonSchema;
17use serde::{Deserialize, Serialize};
18use serde_json::Value;
19use thiserror::Error;
20use uuid::Uuid;
21
22/// Shared string payloads stay as `String` for now; this is the swap point for any future `Arc<str>` migration.
23pub type SharedStr = String;
24
25/// Historical sampling temperature used by older config resolution. Provider
26/// requests now omit temperature unless `[providers.<name>].temperature` is
27/// configured explicitly.
28pub const DEFAULT_TEMPERATURE: f32 = 0.7;
29/// MIME/media type label used for binary message parts.
30pub type MediaType = String;
31/// Provider-issued signature attached to replayable reasoning blocks.
32pub type ReplaySignature = String;
33/// Stable hash of prompt, resource, file-view, or context content.
34pub type ContentHash = String;
35/// UTC timestamp used throughout session and event records.
36pub type Timestamp = DateTime<Utc>;
37
38macro_rules! id_type {
39    ($name:ident) => {
40        #[derive(
41            Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
42        )]
43        #[doc = concat!("Opaque identifier for a protocol `", stringify!($name), "`.")]
44        pub struct $name(pub String);
45
46        impl $name {
47            /// Generate a new random identifier.
48            #[must_use]
49            pub fn new() -> Self {
50                Self(Uuid::new_v4().to_string())
51            }
52        }
53
54        impl Default for $name {
55            fn default() -> Self {
56                Self::new()
57            }
58        }
59
60        impl fmt::Display for $name {
61            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62                f.write_str(&self.0)
63            }
64        }
65
66        impl From<&str> for $name {
67            fn from(value: &str) -> Self {
68                Self(value.to_owned())
69            }
70        }
71
72        impl From<String> for $name {
73            fn from(value: String) -> Self {
74                Self(value)
75            }
76        }
77    };
78}
79
80macro_rules! string_wrapper {
81    ($name:ident) => {
82        #[derive(
83            Debug,
84            Clone,
85            PartialEq,
86            Eq,
87            PartialOrd,
88            Ord,
89            Hash,
90            Default,
91            Serialize,
92            Deserialize,
93            JsonSchema,
94        )]
95        #[doc = concat!("String newtype for a protocol `", stringify!($name), "`.")]
96        pub struct $name(pub String);
97
98        impl fmt::Display for $name {
99            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
100                f.write_str(&self.0)
101            }
102        }
103
104        impl From<&str> for $name {
105            fn from(value: &str) -> Self {
106                Self(value.to_owned())
107            }
108        }
109
110        impl From<String> for $name {
111            fn from(value: String) -> Self {
112                Self(value)
113            }
114        }
115    };
116}
117
118id_type!(MessageId);
119id_type!(BlockId);
120id_type!(ToolCallId);
121id_type!(PromptId);
122id_type!(PromptSegmentId);
123id_type!(SessionId);
124id_type!(TurnId);
125id_type!(SkillId);
126id_type!(PluginId);
127id_type!(AgentId);
128
129string_wrapper!(Revision);
130string_wrapper!(ModelId);
131string_wrapper!(ToolName);
132string_wrapper!(ToolAlias);
133string_wrapper!(SkillName);
134string_wrapper!(AgentName);
135string_wrapper!(ProviderName);
136
137#[derive(
138    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
139)]
140#[serde(rename_all = "snake_case")]
141/// Logical model slot selected by a turn or subagent request.
142pub enum ModelRole {
143    /// General model used for normal turn execution.
144    Default,
145    /// Planning model.
146    Plan,
147    /// Model used by spawned subagents.
148    Subagent,
149    /// Cheaper or faster model for small supporting tasks.
150    Small,
151}
152
153impl ModelRole {
154    /// Role used when no role-specific override is requested.
155    #[must_use]
156    pub const fn default_role() -> Self {
157        Self::Default
158    }
159
160    /// Planning role.
161    #[must_use]
162    pub const fn plan() -> Self {
163        Self::Plan
164    }
165
166    /// Subagent role.
167    #[must_use]
168    pub const fn subagent() -> Self {
169        Self::Subagent
170    }
171
172    /// Small-task role.
173    #[must_use]
174    pub const fn small() -> Self {
175        Self::Small
176    }
177
178    /// Stable config and wire-format spelling for the role.
179    #[must_use]
180    pub const fn as_str(&self) -> &'static str {
181        match self {
182            Self::Default => "default",
183            Self::Plan => "plan",
184            Self::Subagent => "subagent",
185            Self::Small => "small",
186        }
187    }
188}
189
190impl Default for ModelRole {
191    fn default() -> Self {
192        Self::default_role()
193    }
194}
195
196impl std::str::FromStr for ModelRole {
197    type Err = String;
198
199    fn from_str(value: &str) -> Result<Self, Self::Err> {
200        match value {
201            "default" => Ok(Self::Default),
202            "plan" => Ok(Self::Plan),
203            "subagent" => Ok(Self::Subagent),
204            "small" => Ok(Self::Small),
205            other => Err(format!(
206                "unknown ModelRole '{other}'; expected one of: default, plan, subagent, small"
207            )),
208        }
209    }
210}
211
212impl fmt::Display for ModelRole {
213    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
214        f.write_str(self.as_str())
215    }
216}
217
218#[derive(
219    Debug,
220    Clone,
221    Copy,
222    Default,
223    PartialEq,
224    Eq,
225    PartialOrd,
226    Ord,
227    Hash,
228    Serialize,
229    Deserialize,
230    JsonSchema,
231)]
232#[serde(rename_all = "snake_case")]
233/// Controls whether child subagent events are forwarded into the parent stream.
234pub enum SubagentEventForwarding {
235    /// Keep subagent events in the subagent session only.
236    #[default]
237    Off,
238    /// Forward subagent events into the parent session event stream.
239    All,
240}
241
242impl SubagentEventForwarding {
243    /// Whether forwarding is active.
244    #[must_use]
245    pub const fn is_enabled(self) -> bool {
246        matches!(self, Self::All)
247    }
248}
249
250#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Hash)]
251#[serde(rename_all = "snake_case")]
252/// Provider family used for capability selection and registry lookup.
253pub enum ProviderKind {
254    /// Anthropic Messages API.
255    Anthropic,
256    /// OpenAI APIs.
257    OpenAi,
258    /// OpenRouter passthrough APIs.
259    OpenRouter,
260    /// Deterministic local test provider.
261    Fake,
262}
263
264#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
265#[serde(rename_all = "snake_case")]
266/// Wire API shape a resolved model expects its provider to use.
267pub enum ApiKind {
268    /// Anthropic `/v1/messages`.
269    AnthropicMessages,
270    /// OpenAI-compatible Responses API.
271    OpenAiResponses,
272    /// OpenAI-compatible Chat Completions API.
273    OpenAiChat,
274    /// Local fake provider.
275    Fake,
276}
277
278#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
279#[serde(rename_all = "snake_case")]
280/// Provider reasoning budget requested for a model.
281pub enum ReasoningEffort {
282    /// Low reasoning budget.
283    Low,
284    /// Medium reasoning budget.
285    Medium,
286    /// High reasoning budget.
287    High,
288    /// Extra-high reasoning budget, for providers that expose it.
289    Xhigh,
290}
291
292#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
293/// Token accounting reported by providers and accumulated by sessions.
294pub struct Usage {
295    pub input_tokens: u64,
296    pub output_tokens: u64,
297    pub cache_creation_input_tokens: u64,
298    pub cache_read_input_tokens: u64,
299}
300
301#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
302#[serde(rename_all = "snake_case")]
303/// Why an assistant message ended.
304pub enum StopReason {
305    /// The model completed the turn normally.
306    EndTurn,
307    /// The model requested tool execution.
308    ToolUse,
309    /// The turn was interrupted before natural completion.
310    Interrupted,
311    /// The provider stopped after reaching the output-token limit.
312    MaxTokens,
313    /// The provider or runtime reported an error.
314    Error,
315}
316
317#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
318/// Provider metadata preserved on assistant messages for replay and diagnostics.
319pub struct ReplayMeta {
320    pub provider_name: Option<ProviderName>,
321    pub model: Option<ModelId>,
322}
323
324#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
325#[serde(rename_all = "snake_case")]
326/// Severity for non-fatal hook loading problems.
327pub enum HookWarningSeverity {
328    /// Warning that does not block resource compilation.
329    #[default]
330    Warning,
331}
332
333#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
334/// Warning emitted while loading plugin hook files.
335pub struct HookWarning {
336    pub severity: HookWarningSeverity,
337    pub category: SharedStr,
338    pub plugin_id: Option<PluginId>,
339    pub plugin_name: Option<SharedStr>,
340    pub source_path: Option<PathBuf>,
341    pub message: SharedStr,
342}
343
344#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
345/// System instruction message carried in the transcript.
346pub struct SystemMessage {
347    pub id: MessageId,
348    pub created_at: Timestamp,
349    pub text: SharedStr,
350}
351
352#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
353/// User message, including text and optional media parts.
354pub struct UserMessage {
355    pub id: MessageId,
356    pub created_at: Timestamp,
357    pub parts: Vec<UserPart>,
358}
359
360impl UserMessage {
361    /// Build a text-only user message with a fresh id and current timestamp.
362    #[must_use]
363    pub fn text(text: impl Into<String>) -> Self {
364        Self {
365            id: MessageId::new(),
366            created_at: Utc::now(),
367            parts: vec![UserPart::Text { text: text.into() }],
368        }
369    }
370
371    /// Concatenate text parts with newlines, ignoring image and document parts.
372    #[must_use]
373    pub fn plain_text(&self) -> String {
374        self.parts
375            .iter()
376            .filter_map(|part| match part {
377                UserPart::Text { text } => Some(text.as_str()),
378                UserPart::Image { .. } | UserPart::Document { .. } => None,
379            })
380            .collect::<Vec<_>>()
381            .join("\n")
382    }
383}
384
385#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
386#[serde(tag = "kind", rename_all = "snake_case")]
387/// Part of a user message.
388pub enum UserPart {
389    /// Plain text input.
390    Text { text: SharedStr },
391    /// Binary image payload plus media type.
392    Image {
393        media_type: MediaType,
394        #[schemars(with = "Vec<u8>")]
395        data: Bytes,
396    },
397    /// Binary document payload plus media type.
398    Document {
399        media_type: MediaType,
400        #[schemars(with = "Vec<u8>")]
401        data: Bytes,
402    },
403}
404
405#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
406/// Assistant message assembled from provider stream events.
407pub struct AssistantMessage {
408    pub id: MessageId,
409    pub created_at: Timestamp,
410    pub parts: Vec<AssistantPart>,
411    pub stop_reason: Option<StopReason>,
412    pub usage: Option<Usage>,
413    pub replay_meta: ReplayMeta,
414}
415
416#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
417#[serde(tag = "kind", rename_all = "snake_case")]
418/// Part of an assistant message.
419pub enum AssistantPart {
420    /// Text visible to the user.
421    Text { text: SharedStr },
422    /// Reasoning or thinking content, optionally replay-signed.
423    Thinking(ThinkingBlock),
424    /// Tool invocation requested by the model.
425    ToolCall(ToolCall),
426}
427
428#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
429/// Provider thinking block with optional replay signature.
430pub struct ThinkingBlock {
431    pub text: SharedStr,
432    pub signature: Option<ReplaySignature>,
433}
434
435#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
436/// Tool invocation requested by an assistant message.
437pub struct ToolCall {
438    pub id: ToolCallId,
439    pub name: ToolName,
440    pub arguments: Value,
441}
442
443#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
444/// Result message that answers a prior [`ToolCall`].
445pub struct ToolResultMessage {
446    pub id: MessageId,
447    pub call_id: ToolCallId,
448    pub content: ToolResult,
449    pub error: Option<ToolError>,
450    pub created_at: Timestamp,
451}
452
453#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
454#[serde(tag = "role", rename_all = "snake_case")]
455/// A transcript item visible to providers and session stores.
456pub enum Message {
457    /// System instructions.
458    System(SystemMessage),
459    /// User input.
460    User(UserMessage),
461    /// Assistant output.
462    Assistant(AssistantMessage),
463    /// Tool result.
464    Tool(ToolResultMessage),
465}
466
467#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
468#[serde(tag = "kind", rename_all = "snake_case")]
469/// Incremental provider output event.
470pub enum StreamEvent {
471    /// Start of an assistant message.
472    MessageStart { id: MessageId },
473    /// Start of a text block.
474    TextStart { id: BlockId },
475    /// Text block delta.
476    TextDelta { id: BlockId, delta: SharedStr },
477    /// End of a text block.
478    TextEnd { id: BlockId },
479    /// Start of a thinking block.
480    ThinkingStart { id: BlockId },
481    /// Thinking block delta.
482    ThinkingDelta { id: BlockId, delta: SharedStr },
483    /// End of a thinking block.
484    ThinkingEnd {
485        id: BlockId,
486        signature: Option<ReplaySignature>,
487    },
488    /// Start of a tool call block.
489    ToolCallStart {
490        id: BlockId,
491        tool_call_id: ToolCallId,
492        name: ToolName,
493    },
494    /// Tool arguments delta, usually a JSON fragment.
495    ToolArgsDelta { id: BlockId, delta: SharedStr },
496    /// End of a tool call block.
497    ToolCallEnd { id: BlockId },
498    /// Provider token usage update.
499    UsageUpdate { usage: Usage },
500    /// End of an assistant message.
501    MessageEnd {
502        id: MessageId,
503        stop_reason: StopReason,
504        /// The provider's response ID, used for `previous_response_id` chaining.
505        #[serde(default, skip_serializing_if = "Option::is_none")]
506        response_id: Option<String>,
507    },
508    /// Non-fatal warning surfaced by the provider adapter.
509    ProviderWarning { message: SharedStr },
510    /// Provider error surfaced through the stream.
511    Error { error: ProviderError },
512}
513
514#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
515/// User-submitted work unit for a session.
516pub struct Turn {
517    pub id: TurnId,
518    pub user_message: UserMessage,
519    #[serde(default, skip_serializing_if = "Option::is_none")]
520    pub default_model: Option<ModelId>,
521    #[serde(default, skip_serializing_if = "Option::is_none")]
522    pub subagent_model: Option<ModelId>,
523}
524
525impl Turn {
526    /// Build a turn from a text-only user message.
527    #[must_use]
528    pub fn user(text: impl Into<String>) -> Self {
529        Self {
530            id: TurnId::new(),
531            user_message: UserMessage::text(text),
532            default_model: None,
533            subagent_model: None,
534        }
535    }
536
537    /// Override the default model for this turn.
538    #[must_use]
539    pub fn with_default_model(mut self, model: impl Into<ModelId>) -> Self {
540        self.default_model = Some(model.into());
541        self
542    }
543
544    /// Override the subagent model for subagents spawned during this turn.
545    #[must_use]
546    pub fn with_subagent_model(mut self, model: impl Into<ModelId>) -> Self {
547        self.subagent_model = Some(model.into());
548        self
549    }
550}
551
552#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
553#[serde(rename_all = "snake_case")]
554/// Runtime state of a spawned subagent.
555pub enum SubagentState {
556    /// The subagent is still executing.
557    Running,
558    /// The subagent finished successfully.
559    Completed,
560    /// The subagent failed.
561    Failed,
562    /// The subagent was cancelled.
563    Cancelled,
564    /// The subagent was closed by the parent.
565    Closed,
566}
567
568impl SubagentState {
569    /// Whether the state cannot transition back to running.
570    #[must_use]
571    pub fn is_terminal(self) -> bool {
572        matches!(
573            self,
574            Self::Completed | Self::Failed | Self::Cancelled | Self::Closed
575        )
576    }
577}
578
579#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
580/// Request payload for the `spawn_subagent` tool.
581pub struct SpawnSubagentRequest {
582    pub message: String,
583    #[serde(default, skip_serializing_if = "Option::is_none")]
584    pub agent_type: Option<AgentName>,
585    #[serde(default)]
586    pub fork_context: bool,
587    #[serde(default, skip_serializing_if = "Option::is_none")]
588    pub model: Option<ModelId>,
589}
590
591#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
592/// Request payload for sending additional input to a subagent.
593pub struct SendSubagentInputRequest {
594    pub target: AgentId,
595    pub message: String,
596}
597
598#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
599/// Request payload for waiting on one or more subagents.
600pub struct WaitSubagentRequest {
601    pub targets: Vec<AgentId>,
602    #[serde(default, skip_serializing_if = "Option::is_none")]
603    pub timeout_ms: Option<u64>,
604}
605
606#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
607/// Request payload for closing a subagent.
608pub struct CloseSubagentRequest {
609    pub target: AgentId,
610}
611
612#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
613/// Snapshot of a subagent's visible state.
614pub struct SubagentStatus {
615    pub agent_id: AgentId,
616    pub session_id: SessionId,
617    #[serde(default, skip_serializing_if = "Option::is_none")]
618    pub agent_type: Option<AgentName>,
619    pub task: String,
620    pub state: SubagentState,
621    #[serde(default, skip_serializing_if = "Option::is_none")]
622    pub last_message: Option<String>,
623    #[serde(default, skip_serializing_if = "Option::is_none")]
624    pub usage: Option<Usage>,
625    #[serde(default, skip_serializing_if = "Option::is_none")]
626    pub error: Option<String>,
627}
628
629impl SubagentStatus {
630    /// Whether the subagent is no longer running.
631    #[must_use]
632    pub fn is_terminal(&self) -> bool {
633        self.state.is_terminal()
634    }
635}
636
637#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
638/// Response from a subagent wait operation.
639pub struct WaitSubagentResponse {
640    #[serde(default, skip_serializing_if = "Option::is_none")]
641    pub status: Option<SubagentStatus>,
642    pub timed_out: bool,
643}
644
645#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
646/// Response returned after closing a subagent.
647pub struct CloseSubagentResponse {
648    pub previous_status: SubagentStatus,
649}
650
651#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
652/// Minimal subagent spec used by session commands.
653pub struct SubagentSpecWire {
654    pub role: Option<ModelRole>,
655    pub task: String,
656}
657
658#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
659#[serde(tag = "kind", rename_all = "snake_case")]
660/// Command accepted by a session control plane.
661pub enum SessionCommand {
662    /// Submit a new turn.
663    SubmitTurn { turn: Turn },
664    /// Interrupt the active turn.
665    InterruptTurn,
666    /// Append session-scoped system guidance.
667    AppendSystemPrompt { id: PromptId, text: SharedStr },
668    /// Switch the active model role.
669    SetModelRole { role: ModelRole },
670    /// Switch to a concrete model id.
671    SetModel { model: ModelId },
672    /// Spawn a subagent.
673    SpawnSubagent { spec: SubagentSpecWire },
674    /// Reload resources before continuing.
675    ReloadResources,
676    /// Shut down the session.
677    Shutdown,
678}
679
680#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
681/// Delivery semantics for committed session events.
682pub enum Delivery {
683    /// Must be persisted and delivered in order.
684    Lossless,
685    /// May be dropped under pressure.
686    BestEffort,
687}
688
689#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
690/// Text delta emitted into the public session event stream.
691pub struct DeltaItem {
692    pub text: String,
693}
694
695#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
696/// Completed tool execution paired with the original call.
697pub struct ToolExecutionOutcome {
698    pub call: ToolCall,
699    pub result: Result<ToolResult, ToolError>,
700}
701
702#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
703#[serde(rename_all = "snake_case")]
704/// Backend used to execute a configured hook handler.
705pub enum HookHandlerType {
706    Command,
707    Http,
708    Prompt,
709    Agent,
710    Callback,
711    Function,
712}
713
714#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
715#[serde(rename_all = "snake_case")]
716/// Lifecycle state of one hook run.
717pub enum HookRunStatus {
718    Running,
719    Completed,
720    Failed,
721    Blocked,
722    Stopped,
723    Cancelled,
724}
725
726#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
727#[serde(rename_all = "snake_case")]
728/// Category assigned to a hook output entry.
729pub enum HookOutputKind {
730    Warning,
731    Stop,
732    Feedback,
733    Context,
734    Error,
735}
736
737#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
738/// Human-readable hook output shown on a run summary.
739pub struct HookOutputEntry {
740    pub kind: HookOutputKind,
741    pub text: String,
742}
743
744#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
745#[serde(rename_all = "snake_case")]
746/// Source reason passed to session-start hooks.
747pub enum HookSessionStartSource {
748    Startup,
749    Resume,
750    Clear,
751    Compact,
752}
753
754#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
755/// Public record of one hook handler execution.
756pub struct HookRunSummary {
757    pub run_id: String,
758    pub event_name: String,
759    pub handler_type: HookHandlerType,
760    pub plugin_id: PluginId,
761    pub plugin_root: PathBuf,
762    pub status: HookRunStatus,
763    pub status_message: Option<String>,
764    pub started_at: Timestamp,
765    pub completed_at: Option<Timestamp>,
766    pub duration_ms: Option<u64>,
767    pub entries: Vec<HookOutputEntry>,
768}
769
770#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
771#[serde(tag = "kind", rename_all = "snake_case")]
772/// Event payload emitted by the session runtime.
773pub enum SessionEventPayload {
774    SessionStarted,
775    Warning {
776        message: String,
777    },
778    TurnStarted {
779        turn_id: TurnId,
780    },
781    MessageItem {
782        message: Message,
783    },
784    DeltaItem {
785        delta: DeltaItem,
786    },
787    ToolExecutionStarted {
788        call: ToolCall,
789    },
790    ToolOutput {
791        call_id: ToolCallId,
792        tool_name: ToolName,
793        chunk: SharedStr,
794    },
795    HookStarted {
796        run: HookRunSummary,
797    },
798    HookCompleted {
799        run: HookRunSummary,
800    },
801    ToolExecutionCompleted {
802        outcome: ToolExecutionOutcome,
803    },
804    ApprovalRequested {
805        tool_name: ToolName,
806        reason: String,
807    },
808    ContextCompacted {
809        summary: String,
810    },
811    TurnCompleted {
812        turn_id: TurnId,
813        usage: Usage,
814    },
815    TurnFailed {
816        turn_id: TurnId,
817        error: String,
818        /// Whether the failure came from explicit user/runtime cancellation.
819        #[serde(default)]
820        cancelled: bool,
821        /// Whether the underlying provider error advertised itself as
822        /// retryable. Defaults to `false` so historical replays without this
823        /// field deserialize cleanly.
824        #[serde(default)]
825        retryable: bool,
826    },
827    Lagged {
828        dropped_events: u64,
829    },
830    SessionShutdownComplete,
831}
832
833/// An event that has been committed to the session store and therefore has
834/// been assigned a monotonic `sequence` by the commit boundary. Construct a
835/// `SessionEvent` only via `PendingEvent::into_committed`, `SessionEvent::new_committed`,
836/// or deserialization — the `sequence` field is intentionally not publicly
837/// settable.
838#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
839pub struct SessionEvent {
840    pub session_id: SessionId,
841    pub(crate) sequence: u64,
842    pub delivery: Delivery,
843    pub payload: SessionEventPayload,
844}
845
846impl SessionEvent {
847    /// Construct a committed event with an explicit sequence. This is the
848    /// only public constructor that sets the `sequence` field; call sites
849    /// outside commit boundaries must use `PendingEvent`.
850    #[must_use]
851    pub fn new_committed(
852        session_id: SessionId,
853        sequence: u64,
854        delivery: Delivery,
855        payload: SessionEventPayload,
856    ) -> Self {
857        Self {
858            session_id,
859            sequence,
860            delivery,
861            payload,
862        }
863    }
864
865    /// Monotonic sequence assigned by the session store.
866    #[must_use]
867    pub fn sequence(&self) -> u64 {
868        self.sequence
869    }
870}
871
872/// An event produced during turn execution, before the session store has
873/// assigned a sequence. Convert to `SessionEvent` via `into_committed` once
874/// the store has allocated a sequence number. Holding `sequence`-less events
875/// until commit makes the commit-then-publish invariant type-enforced.
876#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
877pub struct PendingEvent {
878    pub session_id: SessionId,
879    pub delivery: Delivery,
880    pub payload: SessionEventPayload,
881}
882
883impl PendingEvent {
884    /// Build an uncommitted event. The session store assigns its sequence.
885    #[must_use]
886    pub fn new(session_id: SessionId, delivery: Delivery, payload: SessionEventPayload) -> Self {
887        Self {
888            session_id,
889            delivery,
890            payload,
891        }
892    }
893
894    /// Attach the commit sequence and convert into a committed event.
895    #[must_use]
896    pub fn into_committed(self, sequence: u64) -> SessionEvent {
897        SessionEvent {
898            session_id: self.session_id,
899            sequence,
900            delivery: self.delivery,
901            payload: self.payload,
902        }
903    }
904}
905
906#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
907/// Scheduler hint for how tool calls may be batched.
908pub enum ToolConcurrency {
909    /// Run alone and preserve strict ordering.
910    Exclusive,
911    /// Can run with other non-mutating tools.
912    ReadOnly,
913    /// Can run concurrently with any other parallel-safe tool.
914    ParallelSafe,
915}
916
917#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
918/// Capabilities exposed by a tool specification.
919pub struct ToolCapabilities {
920    pub mutating: bool,
921    pub requires_approval: bool,
922    pub cancellable: bool,
923    pub long_running: bool,
924}
925
926#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
927/// Provider-visible tool declaration.
928pub struct ToolSpec {
929    pub name: ToolName,
930    pub description: SharedStr,
931    pub input_schema: Value,
932    pub concurrency: ToolConcurrency,
933    pub capabilities: ToolCapabilities,
934    pub provider_aliases: IndexMap<ProviderKind, ToolAlias>,
935}
936
937#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
938#[serde(tag = "kind", rename_all = "snake_case")]
939/// Output returned by a tool execution.
940pub enum ToolResult {
941    /// No content.
942    Empty,
943    /// Plain text content.
944    Text { text: String },
945    /// Structured JSON content.
946    Json { value: Value },
947}
948
949#[derive(Debug, Clone, Error, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
950#[error("{message}")]
951/// Error returned by a tool execution.
952pub struct ToolError {
953    pub message: String,
954}
955
956impl ToolError {
957    /// Build a tool error from a displayable message.
958    #[must_use]
959    pub fn new(message: impl Into<String>) -> Self {
960        Self {
961            message: message.into(),
962        }
963    }
964}
965
966#[derive(Debug, Clone, Error, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
967#[error("{message}")]
968/// Error surfaced by a provider adapter.
969pub struct ProviderError {
970    pub message: String,
971    pub retryable: bool,
972}
973
974impl ProviderError {
975    /// Sentinel message produced by `ProviderError::cancelled` and recognized
976    /// by `is_cancelled`. New consumers should prefer the constructor /
977    /// predicate over inline message comparison.
978    pub const CANCELLED_MESSAGE: &str = "failed to execute provider request: request cancelled";
979
980    /// Build a provider error with an explicit retryability flag.
981    #[must_use]
982    pub fn new(message: impl Into<String>, retryable: bool) -> Self {
983        Self {
984            message: message.into(),
985            retryable,
986        }
987    }
988
989    /// Construct a non-retryable cancellation error with the canonical
990    /// message. Existing consumers that match on message text continue to
991    /// work; new consumers should use `is_cancelled()` to distinguish.
992    #[must_use]
993    pub fn cancelled() -> Self {
994        Self {
995            message: Self::CANCELLED_MESSAGE.to_owned(),
996            retryable: false,
997        }
998    }
999
1000    /// Whether this error is the canonical cancellation sentinel.
1001    #[must_use]
1002    pub fn is_cancelled(&self) -> bool {
1003        self.message == Self::CANCELLED_MESSAGE
1004    }
1005}
1006
1007#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1008/// Prompt fragment assembled before provider encoding.
1009pub struct PromptSegment {
1010    pub id: PromptSegmentId,
1011    pub text: SharedStr,
1012    pub volatility: Volatility,
1013    pub cache_scope: CacheScope,
1014    pub content_hash: ContentHash,
1015    /// Logical section the segment belongs to. The prompt assembler groups
1016    /// segments by kind so the wire layout (system, then skills, then the
1017    /// turn) is independent of insertion order, and so codecs can emit
1018    /// cache breakpoints on stable boundaries.
1019    #[serde(default)]
1020    pub kind: PromptSegmentKind,
1021}
1022
1023#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
1024#[serde(rename_all = "snake_case")]
1025/// Logical prompt section for cache breakpoint placement.
1026pub enum PromptSegmentKind {
1027    /// System prompt section.
1028    #[default]
1029    System,
1030    /// Loaded skill section.
1031    Skill,
1032    /// Runtime-appended context section.
1033    Append,
1034}
1035
1036#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1037/// How often a prompt segment is expected to change.
1038pub enum Volatility {
1039    /// Stable across all sessions for a given build/config.
1040    Static,
1041    /// Stable for the lifetime of a session.
1042    SessionStable,
1043    /// May change every turn.
1044    TurnDynamic,
1045    /// Always treated as dynamic.
1046    AlwaysDynamic,
1047}
1048
1049#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1050/// Whether a segment can participate in prefix caching.
1051pub enum CacheScope {
1052    /// Eligible for provider prefix-cache placement.
1053    PrefixCacheable,
1054    /// Not eligible for prefix caching.
1055    Dynamic,
1056}
1057
1058/// Marks the four section boundaries the runtime asks codecs to expose as
1059/// cache breakpoints when the underlying provider supports them.
1060///
1061/// The order is fixed: system prompt, tool descriptions, skills, then the
1062/// most recent user prompt. The "rest of the session" follows the last
1063/// breakpoint and is therefore the only window eligible for in-band
1064/// compaction by non-dedicated providers.
1065#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
1066pub struct CacheBreakpoints {
1067    pub after_system: bool,
1068    pub after_tools: bool,
1069    pub after_skills: bool,
1070    pub after_user_prompt: bool,
1071}
1072
1073impl CacheBreakpoints {
1074    /// All four breakpoints active. The prompt assembler emits this layout
1075    /// for any session that has a non-empty system prompt and at least one
1076    /// user message; codecs may downgrade as needed.
1077    #[must_use]
1078    pub fn all() -> Self {
1079        Self {
1080            after_system: true,
1081            after_tools: true,
1082            after_skills: true,
1083            after_user_prompt: true,
1084        }
1085    }
1086
1087    /// Number of active breakpoints.
1088    #[must_use]
1089    pub fn count_active(&self) -> usize {
1090        usize::from(self.after_system)
1091            + usize::from(self.after_tools)
1092            + usize::from(self.after_skills)
1093            + usize::from(self.after_user_prompt)
1094    }
1095}
1096
1097/// Per-session cache of file ranges already shown to the model.
1098pub type FileViewCache = IndexMap<PathBuf, FileViewEntry>;
1099
1100#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1101/// Cached metadata for a file view.
1102pub struct FileViewEntry {
1103    pub path: PathBuf,
1104    pub full_hash: ContentHash,
1105    pub mtime: Timestamp,
1106    pub size: u64,
1107    pub viewed_ranges: Vec<ViewedRange>,
1108    pub last_shown_turn: TurnId,
1109}
1110
1111#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1112/// Inclusive range of file lines previously shown to the model.
1113pub struct ViewedRange {
1114    pub start_line: u32,
1115    pub end_line: u32,
1116    pub line_anchors: Vec<LineAnchor>,
1117}
1118
1119#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1120/// Small content anchor used to detect shifted viewed ranges.
1121pub struct LineAnchor {
1122    pub line: u32,
1123    pub anchor: [u8; 3],
1124}
1125
1126#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1127/// Tool call that has been emitted but not yet answered.
1128pub struct PendingToolCall {
1129    pub call: ToolCall,
1130    pub submitted_at: Timestamp,
1131}
1132
1133#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1134/// Summary retained after older context has been compacted.
1135pub struct SummarySlice {
1136    pub id: String,
1137    pub text: String,
1138}
1139
1140#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
1141/// Active transcript window after pruning and compaction planning.
1142pub struct TranscriptWindow {
1143    pub messages: Vec<Message>,
1144    pub elided_message_count: u64,
1145}
1146
1147#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
1148#[serde(transparent)]
1149/// Provider-native compacted context items carried across turns.
1150pub struct CompactedContext(pub Vec<Value>);
1151
1152impl CompactedContext {
1153    /// Wrap provider-native compacted context items.
1154    #[must_use]
1155    pub fn new(items: Vec<Value>) -> Self {
1156        Self(items)
1157    }
1158
1159    /// Borrow the compacted context items.
1160    #[must_use]
1161    pub fn items(&self) -> &[Value] {
1162        &self.0
1163    }
1164
1165    /// Consume the wrapper and return the raw items.
1166    #[must_use]
1167    pub fn into_items(self) -> Vec<Value> {
1168        self.0
1169    }
1170
1171    /// Whether there are no compacted context items.
1172    #[must_use]
1173    pub fn is_empty(&self) -> bool {
1174        self.0.is_empty()
1175    }
1176
1177    /// Number of compacted context items.
1178    #[must_use]
1179    pub fn len(&self) -> usize {
1180        self.0.len()
1181    }
1182}
1183
1184impl From<Vec<Value>> for CompactedContext {
1185    fn from(value: Vec<Value>) -> Self {
1186        Self(value)
1187    }
1188}
1189
1190impl AsRef<[Value]> for CompactedContext {
1191    fn as_ref(&self) -> &[Value] {
1192        self.items()
1193    }
1194}
1195
1196#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
1197/// Split of messages selected for provider compaction.
1198pub struct CompactionWindow {
1199    pub eligible_messages: Vec<Message>,
1200    pub preserved_messages: Vec<Message>,
1201    pub reserved_response_block: bool,
1202}
1203
1204impl CompactionWindow {
1205    /// Preserve the latest assistant response block and compact the older
1206    /// prefix. Providers with a first-class compaction endpoint use this
1207    /// broader window because the provider restores the compacted context as
1208    /// provider-native content.
1209    #[must_use]
1210    pub fn preserve_latest_assistant_response_block(messages: &[Message]) -> Self {
1211        let Some(last_assistant_index) = messages
1212            .iter()
1213            .rposition(|message| matches!(message, Message::Assistant(_)))
1214        else {
1215            return Self {
1216                eligible_messages: messages.to_vec(),
1217                preserved_messages: Vec::new(),
1218                reserved_response_block: false,
1219            };
1220        };
1221
1222        Self {
1223            eligible_messages: messages[..last_assistant_index].to_vec(),
1224            preserved_messages: messages[last_assistant_index..].to_vec(),
1225            reserved_response_block: true,
1226        }
1227    }
1228
1229    /// Preserve every message through the latest user message and compact
1230    /// only the post-user tail. Inline compaction providers use this narrower
1231    /// window so system, tool, skill, and latest-user cache anchors remain
1232    /// verbatim.
1233    #[must_use]
1234    pub fn preserve_through_latest_user(messages: &[Message]) -> Self {
1235        let Some(last_user_index) = messages
1236            .iter()
1237            .rposition(|message| matches!(message, Message::User(_)))
1238        else {
1239            return Self {
1240                eligible_messages: Vec::new(),
1241                preserved_messages: messages.to_vec(),
1242                reserved_response_block: false,
1243            };
1244        };
1245        let pivot = last_user_index + 1;
1246        Self {
1247            eligible_messages: messages[pivot..].to_vec(),
1248            preserved_messages: messages[..pivot].to_vec(),
1249            reserved_response_block: false,
1250        }
1251    }
1252}
1253
1254#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1255/// File-view data included in a context plan.
1256pub struct FileViewSlice {
1257    pub path: PathBuf,
1258    pub full_hash: ContentHash,
1259    pub viewed_ranges: Vec<ViewedRange>,
1260    pub last_shown_turn: TurnId,
1261}
1262
1263#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
1264/// Marker describing content omitted from the active context.
1265pub struct ElisionMarker {
1266    pub kind: String,
1267    pub count: u64,
1268}
1269
1270#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
1271/// Long-lived memory item available to context planning.
1272pub struct MemoryItem {
1273    pub key: String,
1274    pub text: String,
1275}
1276
1277#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1278/// Link from a parent session to a spawned subagent session.
1279pub struct SubagentRef {
1280    pub session_id: SessionId,
1281    pub task: String,
1282}
1283
1284#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1285/// Immutable session metadata created at session start.
1286pub struct SessionBlueprint {
1287    pub session_id: SessionId,
1288    pub parent_session_id: Option<SessionId>,
1289    pub default_model: ModelId,
1290    pub subagent_model: ModelId,
1291    #[serde(default)]
1292    pub subagent_event_forwarding: SubagentEventForwarding,
1293    pub snapshot_revision: Revision,
1294    pub working_dir: PathBuf,
1295    pub system_prompt_seed: Vec<PromptSegment>,
1296    pub max_turns: Option<u32>,
1297    pub subagent_depth: u32,
1298}
1299
1300#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
1301/// Mutable state persisted for a session.
1302pub struct SessionState {
1303    pub messages: Vec<Message>,
1304    #[serde(default)]
1305    pub compacted_prefix: Vec<Value>,
1306    pub file_view_cache: FileViewCache,
1307    pub appended_prompt_segments: Vec<PromptSegment>,
1308    pub pending_tool_calls: IndexMap<ToolCallId, PendingToolCall>,
1309    pub usage_so_far: Usage,
1310    pub summaries: Vec<SummarySlice>,
1311    pub lineage: Vec<SubagentRef>,
1312    pub fired_hook_ids: Vec<String>,
1313    pub pending_session_start_source: Option<HookSessionStartSource>,
1314    pub pending_warning_messages: Vec<HookWarning>,
1315    /// The OpenAI Responses API response ID from the last successful turn.
1316    /// Used for `previous_response_id` chaining to avoid re-sending full history.
1317    #[serde(default, skip_serializing_if = "Option::is_none")]
1318    pub last_response_id: Option<String>,
1319    /// Number of messages the model has already seen via `previous_response_id`.
1320    /// Messages at indices `[0..messages_seen_by_provider)` don't need re-sending.
1321    #[serde(default)]
1322    pub messages_seen_by_provider: usize,
1323}
1324
1325#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1326/// Environment facts captured while building a context plan.
1327pub struct ObservedState {
1328    pub cwd: PathBuf,
1329    pub git_branch: Option<String>,
1330    pub git_dirty: Option<bool>,
1331    pub now_utc: Timestamp,
1332    pub env_facts: IndexMap<String, String>,
1333}
1334
1335#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
1336/// Instruction file loaded into a resource snapshot.
1337pub struct InstructionFile {
1338    pub path: PathBuf,
1339    pub body: String,
1340}
1341
1342#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
1343/// Loaded skill definition made available to prompt assembly.
1344pub struct SkillDef {
1345    pub id: SkillId,
1346    pub name: String,
1347    pub description: String,
1348    pub body: String,
1349}
1350
1351#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
1352/// Loaded agent definition used by subagent tools.
1353pub struct AgentDef {
1354    pub id: AgentId,
1355    pub name: String,
1356    pub prompt: String,
1357}
1358
1359#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
1360/// Manifest data loaded from a plugin root.
1361pub struct PluginManifest {
1362    pub name: String,
1363    pub version: String,
1364    pub skills: Vec<String>,
1365    pub agents: Vec<String>,
1366    pub hooks: Option<String>,
1367    pub mcp_servers: Option<String>,
1368    pub lsp_servers: Option<String>,
1369    pub allowed_http_hosts: Vec<String>,
1370    pub allowed_env_vars: Vec<String>,
1371}
1372
1373#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
1374/// Named prompt segments loaded from resources.
1375pub struct PromptRegistry {
1376    pub prompts: IndexMap<String, Vec<PromptSegment>>,
1377}
1378
1379#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1380/// Complete resource set visible to a session runtime.
1381pub struct ResourceSnapshot {
1382    pub revision: Revision,
1383    pub tools: IndexMap<ToolName, ToolSpec>,
1384    pub skills: IndexMap<SkillName, SkillDef>,
1385    pub agents: IndexMap<AgentName, AgentDef>,
1386    pub prompts: PromptRegistry,
1387    pub plugins: IndexMap<PluginId, PluginManifest>,
1388    pub instruction_files: Vec<InstructionFile>,
1389}
1390
1391impl ResourceSnapshot {
1392    /// Build an empty snapshot for tests and custom SDK assembly.
1393    #[must_use]
1394    pub fn empty() -> Self {
1395        Self {
1396            revision: Revision("empty".to_owned()),
1397            tools: IndexMap::new(),
1398            skills: IndexMap::new(),
1399            agents: IndexMap::new(),
1400            prompts: PromptRegistry::default(),
1401            plugins: IndexMap::new(),
1402            instruction_files: Vec::new(),
1403        }
1404    }
1405}
1406
1407#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1408/// Capability flags advertised by a provider adapter.
1409pub struct ProviderCapabilities {
1410    pub supports_tools: bool,
1411    pub supports_streaming: bool,
1412    pub supports_reasoning: bool,
1413    pub supports_interleaved_reasoning: bool,
1414    pub supports_images: bool,
1415    pub supports_documents: bool,
1416    pub supports_prompt_cache: bool,
1417    pub supports_compaction: bool,
1418    /// How the provider implements compaction. This remains exposed for
1419    /// diagnostics and external callers, but runtime planning asks the
1420    /// provider for a `CompactionWindow` instead of branching on this value.
1421    #[serde(default)]
1422    pub compaction_strategy: Option<ProviderCompactionStrategy>,
1423    pub supports_tool_result_media: bool,
1424    pub requires_non_empty_assistant_content: bool,
1425    pub tool_call_id_policy: ToolCallIdPolicy,
1426    pub max_input_tokens: u64,
1427    pub max_output_tokens: u64,
1428}
1429
1430#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1431#[serde(rename_all = "snake_case")]
1432pub enum ProviderCompactionStrategy {
1433    /// A first-class compaction endpoint (e.g. OpenAI Responses
1434    /// `/v1/responses/compact`) that returns encrypted content for safe
1435    /// reinjection. The runtime can compact aggressively because the
1436    /// provider preserves anchor invariants.
1437    Dedicated,
1438    /// In-band compaction via the regular completions endpoint
1439    /// (e.g. OpenRouter's responses passthrough). Lossy: the runtime
1440    /// only compacts the trailing window after the last cache breakpoint
1441    /// and wraps the result in explicit compaction tags so the model can
1442    /// distinguish it from authoritative system content.
1443    Inline,
1444}
1445
1446impl Default for ProviderCapabilities {
1447    fn default() -> Self {
1448        Self {
1449            supports_tools: true,
1450            supports_streaming: true,
1451            supports_reasoning: false,
1452            supports_interleaved_reasoning: false,
1453            supports_images: false,
1454            supports_documents: false,
1455            supports_prompt_cache: false,
1456            supports_compaction: false,
1457            compaction_strategy: None,
1458            supports_tool_result_media: false,
1459            requires_non_empty_assistant_content: false,
1460            tool_call_id_policy: ToolCallIdPolicy::ProviderSupplied,
1461            max_input_tokens: 0,
1462            max_output_tokens: 0,
1463        }
1464    }
1465}
1466
1467#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1468#[serde(rename_all = "snake_case")]
1469/// How provider tool-call ids are assigned and normalized.
1470pub enum ToolCallIdPolicy {
1471    /// Provider supplies ids and they can be used directly.
1472    ProviderSupplied,
1473    /// Runtime must synthesize ids when the provider omits them.
1474    RuntimeSynthesized,
1475    /// Runtime normalizes ids for stable replay across providers.
1476    StableReplayNormalized,
1477}
1478
1479#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1480/// Fully resolved model and provider configuration.
1481pub struct ResolvedModel {
1482    pub role: ModelRole,
1483    pub id: ModelId,
1484    pub provider: ProviderName,
1485    pub provider_kind: ProviderKind,
1486    pub api_kind: ApiKind,
1487    pub model: String,
1488    pub max_input_tokens: Option<u32>,
1489    pub max_output_tokens: Option<u32>,
1490    pub reasoning: Option<ReasoningEffort>,
1491    #[serde(default)]
1492    pub tokens_per_minute: Option<u64>,
1493}
1494
1495#[derive(
1496    Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq, PartialOrd, Ord,
1497)]
1498#[serde(rename_all = "snake_case")]
1499/// Relative value of a message when pruning context before compaction.
1500pub enum MessageSignal {
1501    /// Compact first -- orientation commands, empty results, duplicate failures.
1502    VeryLow = 0,
1503    /// Low signal -- failed tool calls, stale reads.
1504    Low = 1,
1505    /// Default for most messages.
1506    Normal = 2,
1507    /// Active file reads and system guidance.
1508    High = 3,
1509    /// Assistant text or reasoning content.
1510    VeryHigh = 4,
1511    /// Never compact -- user messages.
1512    Anchor = 5,
1513}
1514
1515#[derive(
1516    Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq, PartialOrd, Ord,
1517)]
1518#[serde(rename_all = "snake_case")]
1519/// Highest message-signal tier eligible for pre-compaction pruning.
1520pub enum PruneSignalThreshold {
1521    VeryLow,
1522    Low,
1523    #[default]
1524    Normal,
1525    High,
1526}
1527
1528#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1529/// Result of applying a compaction output to session state.
1530pub struct CompactionResult {
1531    /// Number of messages compacted into the raw prefix.
1532    pub compacted_count: usize,
1533    /// Human-readable summary for events and hooks.
1534    pub summary: String,
1535}
1536
1537#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1538/// Context manager output consumed by prompt assembly and provider codecs.
1539pub struct ContextPlan {
1540    pub prompt_segments: Vec<PromptSegment>,
1541    pub transcript_window: TranscriptWindow,
1542    #[serde(default)]
1543    pub compacted_prefix: Vec<Value>,
1544    pub file_views: Vec<FileViewSlice>,
1545    pub carried_summaries: Vec<SummarySlice>,
1546    pub elided_tool_results: Vec<ElisionMarker>,
1547    pub memory_items: Vec<MemoryItem>,
1548    pub tool_specs: Vec<ToolSpec>,
1549    pub observed_state: ObservedState,
1550    pub projected_input_tokens: u64,
1551    pub cache_boundary_hash: ContentHash,
1552    pub messages: Vec<Message>,
1553    pub estimated_tokens: u64,
1554    /// If the planner compacted messages this turn, the result is here.
1555    /// The caller should apply it to `SessionState` after using the plan.
1556    pub compaction: Option<CompactionResult>,
1557    /// When set, the codec should chain via `previous_response_id`.
1558    #[serde(default, skip_serializing_if = "Option::is_none")]
1559    pub previous_response_id: Option<String>,
1560    /// Index into `messages` where new messages start (for chained requests).
1561    #[serde(default)]
1562    pub new_messages_start: usize,
1563}
1564
1565#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1566/// Fully assembled prompt and transcript material ready for provider encoding.
1567pub struct AssembledPrompt {
1568    pub segments: Vec<PromptSegment>,
1569    pub transcript: Vec<Message>,
1570    pub ordered_segments: Vec<PromptSegment>,
1571    pub prefix_cache_key: String,
1572    pub rendered_prefix: String,
1573    pub rendered_transcript: String,
1574    pub rendered: String,
1575    /// Section boundaries that the assembler asks the codec to expose as
1576    /// cache breakpoints. Codecs that do not support explicit breakpoints
1577    /// (e.g. OpenAI Responses, which uses prefix-prefix caching) ignore
1578    /// this; codecs that do (Anthropic) emit `cache_control` on the last
1579    /// content block of each marked section.
1580    #[serde(default)]
1581    pub cache_breakpoints: CacheBreakpoints,
1582    /// Index into `ordered_segments` after which the system-prompt
1583    /// breakpoint applies. `None` when there are no system segments.
1584    #[serde(default)]
1585    pub system_segment_count: usize,
1586    /// Number of segments at the head of `ordered_segments` that belong
1587    /// to the skills section. Always immediately follows the system block.
1588    #[serde(default)]
1589    pub skill_segment_count: usize,
1590}
1591
1592impl AssembledPrompt {
1593    /// Slice of segments that constitute the system-prompt section.
1594    #[must_use]
1595    pub fn system_segments(&self) -> &[PromptSegment] {
1596        let end = self.system_segment_count.min(self.ordered_segments.len());
1597        &self.ordered_segments[..end]
1598    }
1599
1600    /// Slice of segments that constitute the skills section.
1601    #[must_use]
1602    pub fn skill_segments(&self) -> &[PromptSegment] {
1603        let start = self.system_segment_count.min(self.ordered_segments.len());
1604        let end = (start + self.skill_segment_count).min(self.ordered_segments.len());
1605        &self.ordered_segments[start..end]
1606    }
1607
1608    /// Slice of segments that follow both the system and skills sections —
1609    /// hook-appended context, etc. These never receive a cache breakpoint
1610    /// because they may change turn-to-turn.
1611    #[must_use]
1612    pub fn append_segments(&self) -> &[PromptSegment] {
1613        let start =
1614            (self.system_segment_count + self.skill_segment_count).min(self.ordered_segments.len());
1615        &self.ordered_segments[start..]
1616    }
1617}
1618
1619#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1620/// Request sent from the runtime to a provider for normal generation.
1621pub struct ProviderRequest {
1622    pub session_id: SessionId,
1623    pub turn_id: TurnId,
1624    pub model: ResolvedModel,
1625    pub prompt: AssembledPrompt,
1626    #[serde(default)]
1627    pub compacted_prefix: Vec<Value>,
1628    pub messages: Vec<Message>,
1629    pub tools: Vec<ToolSpec>,
1630    /// When set, the provider can chain onto the previous response instead of
1631    /// re-sending the full conversation history. The codec should send only
1632    /// messages after `new_messages_start` when this is present.
1633    #[serde(default, skip_serializing_if = "Option::is_none")]
1634    pub previous_response_id: Option<String>,
1635    /// Index into `messages` where new (unseen-by-provider) messages begin.
1636    /// Only meaningful when `previous_response_id` is `Some`.
1637    #[serde(default)]
1638    pub new_messages_start: usize,
1639}
1640
1641#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1642/// Request sent to a provider for context compaction.
1643pub struct ProviderCompactionRequest {
1644    pub session_id: SessionId,
1645    pub model: ResolvedModel,
1646    #[serde(default)]
1647    pub compacted_prefix: Vec<Value>,
1648    pub messages: Vec<Message>,
1649    pub tools: Vec<ToolSpec>,
1650    pub instructions: String,
1651}
1652
1653#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1654/// Provider response containing compacted context items.
1655pub struct ProviderCompactionResponse {
1656    pub output: Vec<Value>,
1657    pub usage: Usage,
1658}
1659
1660#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1661/// Final output returned from a completed subagent.
1662pub struct SubagentResult {
1663    pub session_id: SessionId,
1664    pub output: String,
1665    pub usage: Usage,
1666}
1667
1668#[cfg(test)]
1669mod tests {
1670    use bytes::Bytes;
1671
1672    use super::*;
1673
1674    #[test]
1675    fn message_roundtrip() {
1676        let message = Message::User(UserMessage::text("hello"));
1677        let encoded = serde_json::to_string(&message).expect("serialize message");
1678        let decoded: Message = serde_json::from_str(&encoded).expect("deserialize message");
1679        assert_eq!(decoded, message);
1680    }
1681
1682    #[test]
1683    fn session_event_roundtrip() {
1684        let event = SessionEvent::new_committed(
1685            SessionId::new(),
1686            1,
1687            Delivery::Lossless,
1688            SessionEventPayload::TurnCompleted {
1689                turn_id: TurnId::new(),
1690                usage: Usage {
1691                    input_tokens: 10,
1692                    output_tokens: 5,
1693                    cache_creation_input_tokens: 0,
1694                    cache_read_input_tokens: 0,
1695                },
1696            },
1697        );
1698        let encoded = serde_json::to_string(&event).expect("serialize event");
1699        let decoded: SessionEvent = serde_json::from_str(&encoded).expect("deserialize event");
1700        assert_eq!(decoded, event);
1701    }
1702
1703    #[test]
1704    fn pending_event_into_committed_preserves_fields() {
1705        let session_id = SessionId::from("session-42");
1706        let payload = SessionEventPayload::ContextCompacted {
1707            summary: "summary".to_owned(),
1708        };
1709        let pending = PendingEvent::new(session_id.clone(), Delivery::Lossless, payload.clone());
1710
1711        let committed = pending.clone().into_committed(7);
1712
1713        assert_eq!(committed.session_id, session_id);
1714        assert_eq!(committed.sequence(), 7);
1715        assert_eq!(committed.delivery, Delivery::Lossless);
1716        assert_eq!(committed.payload, payload);
1717
1718        // PendingEvent is still unsequenced; we reject post-hoc mutation of
1719        // committed events by keeping the sequence field crate-private.
1720        let encoded = serde_json::to_string(&pending).expect("serialize pending");
1721        assert!(!encoded.contains("sequence"));
1722    }
1723
1724    #[test]
1725    fn turn_roundtrip_preserves_model_overrides() {
1726        let turn = Turn::user("hello")
1727            .with_default_model("default")
1728            .with_subagent_model("subagent");
1729
1730        let encoded = serde_json::to_string(&turn).expect("serialize turn");
1731        let decoded: Turn = serde_json::from_str(&encoded).expect("deserialize turn");
1732
1733        assert_eq!(decoded, turn);
1734    }
1735
1736    #[test]
1737    fn compacted_context_serializes_as_existing_prefix_array() {
1738        let context = CompactedContext::new(vec![
1739            serde_json::json!({"type": "reasoning", "encrypted_content": "summary"}),
1740        ]);
1741
1742        let encoded = serde_json::to_string(&context).expect("serialize compacted context");
1743        assert!(encoded.starts_with('['));
1744
1745        let decoded: CompactedContext =
1746            serde_json::from_str(&encoded).expect("deserialize compacted context");
1747        assert_eq!(decoded, context);
1748
1749        let state: SessionState = serde_json::from_value(serde_json::json!({
1750            "messages": [],
1751            "compacted_prefix": [
1752                {"type": "reasoning", "encrypted_content": "summary"}
1753            ],
1754            "file_view_cache": {},
1755            "appended_prompt_segments": [],
1756            "pending_tool_calls": {},
1757            "usage_so_far": {
1758                "input_tokens": 0,
1759                "output_tokens": 0,
1760                "cache_creation_input_tokens": 0,
1761                "cache_read_input_tokens": 0
1762            },
1763            "summaries": [],
1764            "lineage": [],
1765            "fired_hook_ids": [],
1766            "pending_session_start_source": null,
1767            "pending_warning_messages": [],
1768            "messages_seen_by_provider": 0
1769        }))
1770        .expect("deserialize existing session state");
1771        assert_eq!(state.compacted_prefix.len(), 1);
1772    }
1773
1774    #[test]
1775    fn compaction_window_preserves_latest_assistant_response_block() {
1776        let messages = vec![
1777            Message::User(UserMessage::text("first")),
1778            assistant_text("answer"),
1779            Message::User(UserMessage::text("follow up")),
1780        ];
1781
1782        let window = CompactionWindow::preserve_latest_assistant_response_block(&messages);
1783
1784        assert_eq!(window.eligible_messages.len(), 1);
1785        assert_eq!(window.preserved_messages.len(), 2);
1786        assert!(window.reserved_response_block);
1787    }
1788
1789    #[test]
1790    fn compaction_window_preserves_through_latest_user() {
1791        let messages = vec![
1792            Message::User(UserMessage::text("first")),
1793            assistant_text("answer"),
1794            Message::User(UserMessage::text("follow up")),
1795            assistant_text("tail"),
1796        ];
1797
1798        let window = CompactionWindow::preserve_through_latest_user(&messages);
1799
1800        assert_eq!(window.preserved_messages.len(), 3);
1801        assert!(matches!(
1802            window.preserved_messages.last(),
1803            Some(Message::User(_))
1804        ));
1805        assert_eq!(window.eligible_messages.len(), 1);
1806        assert!(!window.reserved_response_block);
1807    }
1808
1809    #[test]
1810    fn user_message_with_media_roundtrips() {
1811        let message = Message::User(UserMessage {
1812            id: MessageId::new(),
1813            created_at: Utc::now(),
1814            parts: vec![
1815                UserPart::Text {
1816                    text: "hello".to_owned(),
1817                },
1818                UserPart::Image {
1819                    media_type: "image/png".to_owned(),
1820                    data: Bytes::from_static(b"png"),
1821                },
1822                UserPart::Document {
1823                    media_type: "application/pdf".to_owned(),
1824                    data: Bytes::from_static(b"pdf"),
1825                },
1826            ],
1827        });
1828
1829        let encoded = serde_json::to_string(&message).expect("serialize message");
1830        let decoded: Message = serde_json::from_str(&encoded).expect("deserialize message");
1831        assert_eq!(decoded, message);
1832    }
1833
1834    #[test]
1835    fn stream_event_with_signature_roundtrips() {
1836        let event = StreamEvent::ThinkingEnd {
1837            id: BlockId::new(),
1838            signature: Some("sig-123".to_owned()),
1839        };
1840
1841        let encoded = serde_json::to_string(&event).expect("serialize event");
1842        let decoded: StreamEvent = serde_json::from_str(&encoded).expect("deserialize event");
1843        assert_eq!(decoded, event);
1844    }
1845
1846    fn assistant_text(text: &str) -> Message {
1847        Message::Assistant(AssistantMessage {
1848            id: MessageId::new(),
1849            created_at: Utc::now(),
1850            parts: vec![AssistantPart::Text {
1851                text: text.to_owned(),
1852            }],
1853            stop_reason: None,
1854            usage: None,
1855            replay_meta: ReplayMeta::default(),
1856        })
1857    }
1858}