Skip to main content

supercode/
sdk.rs

1//! Versioned public SDK contract shared by every Supercode surface.
2//!
3//! This module names the operations, capabilities, events, and errors that
4//! transports project. CLI, JSON-RPC, HTTP, MCP, ACP, and language clients
5//! may add correlation ids or wire metadata, but they must not define a
6//! second execution contract or place those envelope fields in a session.
7
8use async_trait::async_trait;
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11use std::path::Path;
12use std::sync::Arc;
13
14use crate::{
15    Agent, Config, DiscoveryQuery, Fidelity, HarnessCatalog, Result as CoreResult, Session,
16    SessionDescriptor, SessionLocator,
17};
18
19/// Renderable prompt source configured on an SDK emulation component.
20///
21/// MCP implements this seam, but the SDK does not depend on MCP transport or
22/// client types, so removing the MCP adapter leaves runtime semantics intact.
23#[async_trait]
24pub trait SdkPromptSource: Send + Sync {
25    /// Render one prompt with its named arguments.
26    async fn render(&self, args: std::collections::BTreeMap<String, String>) -> CoreResult<String>;
27    /// Declared argument names in stable source order.
28    fn arg_names(&self) -> &[String];
29}
30
31/// Current language-neutral SDK schema.
32pub const SDK_SCHEMA_VERSION: &str = "supercode.sdk.v1";
33
34/// Discover persisted sessions through the canonical SDK catalog owner.
35pub fn discover_sessions(query: &DiscoveryQuery) -> CoreResult<Vec<SessionDescriptor>> {
36    HarnessCatalog::new().discover(query)
37}
38
39/// Load one durable locator through the canonical SDK catalog owner.
40pub fn load_session(locator: &SessionLocator) -> CoreResult<Session> {
41    HarnessCatalog::new().load(locator)
42}
43
44/// [`load_session`] at a declared fidelity.
45///
46/// Read-only surfaces pass [`Fidelity::Semantic`] so a transcript whose record
47/// graph cannot be reconstructed exactly still renders, with the degradation
48/// named in [`Session::load_residue`]. Continuation, transfer and export
49/// callers keep the strict default of [`load_session`].
50pub fn load_session_with_fidelity(
51    locator: &SessionLocator,
52    fidelity: Fidelity,
53) -> CoreResult<Session> {
54    HarnessCatalog::new().load_with_fidelity(locator, fidelity)
55}
56
57/// Load an explicit transcript/store path through the SDK import boundary.
58/// An OpenCode selector is accepted only for its SQLite store.
59pub fn load_session_path(path: &Path, opencode_session: Option<&str>) -> CoreResult<Session> {
60    if opencode_session.is_some() {
61        return Session::from_opencode_sqlite(path, opencode_session);
62    }
63    if let Some(session) = load_native_store_family(path)? {
64        return Ok(session);
65    }
66    Session::load(path)
67}
68
69fn load_native_store_family(path: &Path) -> CoreResult<Option<Session>> {
70    let Some(name) = path.file_stem().and_then(|value| value.to_str()) else {
71        return Ok(None);
72    };
73    let Some(parent) = path.parent() else {
74        return Ok(None);
75    };
76    if !parent.join(format!("{name}.meta.json")).is_file() {
77        return Ok(None);
78    }
79    let sidecar = parent.join(format!("{name}.sidecar.jsonl"));
80    let mut session = if sidecar.is_file() {
81        Session::from_sidecar_str(&std::fs::read_to_string(sidecar)?)?
82    } else {
83        let text = std::fs::read_to_string(path)?;
84        let messages = text
85            .lines()
86            .filter(|line| !line.trim().is_empty())
87            .map(serde_json::from_str)
88            .collect::<std::result::Result<Vec<_>, _>>()?;
89        Session::from_native_messages(messages)
90    };
91    session.meta.session_id = Some(name.to_string());
92    Ok(Some(session))
93}
94
95/// SDK-owned emulation runtime component.
96///
97/// The wrapper makes ownership transfer explicit: public adapters receive an
98/// SDK component, and [`crate::server::RpcEngine`] consumes that component as
99/// the sole live-loop owner. It intentionally does not implement `Deref`:
100/// model/tool-loop entry points stay unreachable outside the SDK/runtime
101/// implementation boundary.
102pub struct SdkAgent(Agent);
103
104impl SdkAgent {
105    pub(crate) fn from_agent(agent: Agent) -> Self {
106        Self(agent)
107    }
108
109    pub(crate) fn inner(&self) -> &Agent {
110        &self.0
111    }
112
113    pub(crate) fn inner_mut(&mut self) -> &mut Agent {
114        &mut self.0
115    }
116
117    /// Read the resolved runtime configuration without acquiring loop ownership.
118    pub fn config(&self) -> &Config {
119        self.0.config()
120    }
121
122    /// Install the full-fidelity sidecar writer used by SDK persistence.
123    pub fn set_recorder(&mut self, writer: crate::sidecar::SidecarWriter) {
124        self.0.set_recorder(writer);
125    }
126
127    /// Install the reversible provider-view reduction policy.
128    pub fn set_reduction_policy(&mut self, policy: crate::reduce::ReductionPolicy) {
129        self.0.set_reduction_policy(policy);
130    }
131
132    /// Inspect the current provider-view reduction policy.
133    pub fn reduction_policy(&self) -> Option<&crate::reduce::ReductionPolicy> {
134        self.0.reduction_policy()
135    }
136
137    /// Replace the reversible reduction log after an SDK-owned projection.
138    pub fn set_reduction_log(&mut self, log: crate::reduce::ReductionLog) {
139        self.0.set_reduction_log(log);
140    }
141
142    /// Inspect the reversible reduction log.
143    pub fn reduction_log(&self) -> &crate::reduce::ReductionLog {
144        self.0.reduction_log()
145    }
146
147    /// Prepare optional cleared-turn summary metadata without sending a turn.
148    pub fn prepare_cleared_turns_summary(
149        &self,
150        messages: &[crate::ChatMessage],
151        policy: &crate::reduce::ReductionPolicy,
152        prior: &crate::reduce::ReductionLog,
153    ) -> Option<crate::reduce::PreparedClearSummary> {
154        self.0
155            .prepare_cleared_turns_summary(messages, policy, prior)
156    }
157
158    /// Install a reduction span summarizer.
159    pub fn set_span_summarizer(
160        &mut self,
161        summarizer: impl crate::reduce::summarize::SpanSummarizer + Send + Sync + 'static,
162    ) {
163        self.0.set_span_summarizer(summarizer);
164    }
165
166    /// Install the optional persisted-session title generator.
167    pub fn set_session_titler(
168        &mut self,
169        titler: impl crate::session_title::SessionTitler + Send + Sync + 'static,
170    ) {
171        self.0.set_session_titler(titler);
172    }
173
174    /// Generate a title from canonical history when configured.
175    pub fn auto_title(&self) -> Option<String> {
176        self.0.auto_title()
177    }
178
179    /// Attach a session store for SDK-owned subagent persistence.
180    pub fn set_subagent_store(
181        &mut self,
182        store: std::sync::Arc<crate::SessionStore>,
183        session_name: impl Into<String>,
184    ) {
185        self.0.set_subagent_store(store, session_name);
186    }
187
188    /// Install restored Claude runtime state without activating a timer.
189    pub fn set_claude_runtime_manifest(
190        &mut self,
191        manifest: crate::claude_runtime_state::ClaudeRuntimeManifest,
192    ) {
193        self.0.set_claude_runtime_manifest(manifest);
194    }
195
196    /// Inspect restored Claude runtime state.
197    pub fn claude_runtime_manifest(
198        &self,
199    ) -> Option<&crate::claude_runtime_state::ClaudeRuntimeManifest> {
200        self.0.claude_runtime_manifest()
201    }
202
203    /// Mutate Claude runtime state from the SDK scheduler/persistence driver.
204    pub fn claude_runtime_manifest_mut(
205        &mut self,
206    ) -> Option<&mut crate::claude_runtime_state::ClaudeRuntimeManifest> {
207        self.0.claude_runtime_manifest_mut()
208    }
209
210    /// Restore project-scoped Claude agent definitions after disk reload.
211    pub fn restore_claude_project_agents(&mut self) -> CoreResult<usize> {
212        self.0.restore_claude_project_agents()
213    }
214
215    /// Replace canonical history with a loaded normalized session.
216    pub fn load_session(&mut self, session: Session) {
217        self.0.load_session(session);
218    }
219
220    /// Load a Supercode transcript through the SDK component.
221    pub fn load_transcript(&mut self, path: impl AsRef<Path>) -> CoreResult<()> {
222        self.0.load_transcript(path)
223    }
224
225    /// Save the canonical transcript through the SDK component.
226    pub fn save_transcript(&self, path: impl AsRef<Path>) -> CoreResult<()> {
227        self.0.save_transcript(path)
228    }
229
230    /// Read canonical history at a quiescent boundary.
231    pub fn history(&self) -> &[crate::ChatMessage] {
232        self.0.history()
233    }
234
235    /// Rewind canonical history to a prior message boundary.
236    pub fn rewind_to(&mut self, checkpoint: usize) {
237        self.0.rewind_to(checkpoint);
238    }
239
240    /// Append an SDK-assembled system note.
241    pub fn append_system_note(&mut self, text: &str) {
242        self.0.append_system_note(text);
243    }
244
245    /// Register one configured tool before transferring live-loop ownership.
246    pub fn register_tool(&mut self, tool: impl crate::Tool + 'static) {
247        self.0.register_tool(tool);
248    }
249
250    /// Register one MCP prompt source before transferring loop ownership.
251    pub fn register_mcp_prompt(
252        &mut self,
253        command_name: impl Into<String>,
254        source: impl SdkPromptSource + 'static,
255    ) {
256        self.0.register_mcp_prompt(command_name, source);
257    }
258
259    /// Inspect the exact next-request tool schemas for preflight measurement.
260    pub fn tool_schemas(&self) -> Vec<crate::ToolSchema> {
261        self.0.tool_schemas()
262    }
263
264    /// Arm the per-request context limit guard.
265    pub fn set_context_limit(&mut self, limit: u64) {
266        self.0.set_context_limit(limit);
267    }
268
269    /// Inspect the armed context limit.
270    pub fn context_limit(&self) -> Option<u64> {
271        self.0.context_limit()
272    }
273
274    /// Switch the next-request model at a quiescent boundary.
275    pub fn set_model(&mut self, model: impl Into<String>) {
276        self.0.set_model(model);
277    }
278
279    /// Whether a provider request has actually been issued.
280    pub fn request_issued(&self) -> bool {
281        self.0.request_issued()
282    }
283
284    /// Configured durable session name.
285    pub fn session_name(&self) -> Option<&str> {
286        self.0.session_name()
287    }
288
289    /// Whether persistence is enabled for this component.
290    pub fn session_persist(&self) -> bool {
291        self.0.session_persist()
292    }
293
294    /// Captured git provenance.
295    pub fn git_metadata(&self) -> Option<&crate::git_metadata::GitMetadataRecord> {
296        self.0.git_metadata()
297    }
298
299    /// Save captured git provenance.
300    pub fn save_git_metadata(&self, store: &crate::SessionStore, name: &str) -> CoreResult<()> {
301        self.0.save_git_metadata(store, name)
302    }
303
304    /// Number of non-system canonical messages.
305    pub fn turn_count(&self) -> usize {
306        self.0.turn_count()
307    }
308
309    /// Cumulative provider-reported output tokens.
310    pub fn total_output_tokens(&self) -> u64 {
311        self.0.total_output_tokens()
312    }
313}
314
315impl From<Agent> for SdkAgent {
316    fn from(agent: Agent) -> Self {
317        Self::from_agent(agent)
318    }
319}
320
321/// Construct the emulation component inside the SDK ownership boundary.
322pub fn create_agent(config: Config) -> CoreResult<SdkAgent> {
323    Agent::new(config).map(SdkAgent::from_agent)
324}
325
326/// Resume canonical history inside a fresh SDK-owned emulation component.
327pub fn resume_agent(config: Config, session: Session) -> CoreResult<SdkAgent> {
328    Agent::resume(config, session).map(SdkAgent::from_agent)
329}
330
331/// Submit one text turn through the SDK-owned emulation loop.
332pub async fn submit_agent(agent: &mut SdkAgent, prompt: &str) -> CoreResult<String> {
333    agent.0.send(prompt).await
334}
335
336/// Submit one multimodal turn through the SDK-owned emulation loop.
337pub async fn submit_agent_with_images(
338    agent: &mut SdkAgent,
339    prompt: &str,
340    image_urls: &[String],
341) -> CoreResult<String> {
342    agent.0.send_with_images(prompt, image_urls).await
343}
344
345/// One operation owned by the SDK facade.
346#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
347#[serde(rename_all = "snake_case")]
348pub enum SdkOperation {
349    /// Discover persisted sessions.
350    Discover,
351    /// Load one persisted session without modifying it.
352    Load,
353    /// Start a harness-native runtime.
354    Start,
355    /// Resume a harness-native persisted runtime.
356    Resume,
357    /// Send input to an SDK-owned runtime connection.
358    Input,
359    /// Poll canonical runtime events.
360    Events,
361    /// Interrupt the active turn.
362    Interrupt,
363    /// Queue guidance at the next model-loop boundary.
364    Steer,
365    /// Answer a typed runtime request.
366    Respond,
367    /// Export a loaded session through a native serializer.
368    Export,
369    /// Close an SDK-owned runtime connection.
370    Close,
371}
372
373impl SdkOperation {
374    /// Complete v1 operation inventory in stable declaration order.
375    pub const ALL: [Self; 11] = [
376        Self::Discover,
377        Self::Load,
378        Self::Start,
379        Self::Resume,
380        Self::Input,
381        Self::Events,
382        Self::Interrupt,
383        Self::Steer,
384        Self::Respond,
385        Self::Export,
386        Self::Close,
387    ];
388
389    /// Canonical `harness.v1` method used by JSON transports, when the
390    /// operation is request/response rather than a subscription poll.
391    pub const fn method(self) -> Option<&'static str> {
392        match self {
393            Self::Discover => Some("harness.v1.sessions.discover"),
394            Self::Load => Some("harness.v1.sessions.load"),
395            Self::Start => Some("harness.v1.runtimes.start"),
396            Self::Resume => Some("harness.v1.runtimes.resume"),
397            Self::Input => Some("harness.v1.runtimes.send_input"),
398            Self::Events => None,
399            Self::Interrupt => Some("harness.v1.runtimes.interrupt"),
400            Self::Steer => Some("harness.v1.runtimes.steer"),
401            Self::Respond => Some("harness.v1.runtimes.respond"),
402            Self::Export => Some("harness.v1.sessions.export"),
403            Self::Close => Some("harness.v1.runtimes.close"),
404        }
405    }
406
407    /// Resolve one canonical method without accepting transport aliases.
408    pub fn from_method(method: &str) -> Option<Self> {
409        Self::ALL
410            .into_iter()
411            .find(|operation| operation.method() == Some(method))
412    }
413
414    /// Stable action spelling used by capability and error projections.
415    pub const fn action_name(self) -> &'static str {
416        match self {
417            Self::Discover => "discover",
418            Self::Load => "load",
419            Self::Start => "start",
420            Self::Resume => "resume",
421            Self::Input => "input",
422            Self::Events => "events",
423            Self::Interrupt => "interrupt",
424            Self::Steer => "steer",
425            Self::Respond => "respond",
426            Self::Export => "export",
427            Self::Close => "close",
428        }
429    }
430
431    /// Resolve a stable action spelling.
432    pub fn from_action_name(action: &str) -> Option<Self> {
433        Self::ALL
434            .into_iter()
435            .find(|operation| operation.action_name() == action)
436    }
437}
438
439/// One typed SDK request before a transport adds its envelope.
440#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
441pub struct SdkRequest {
442    /// Requested SDK operation.
443    pub operation: SdkOperation,
444    /// Operation-specific language-neutral parameters.
445    #[serde(default)]
446    pub params: Value,
447}
448
449/// Stable machine-readable SDK failure categories.
450#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
451#[serde(rename_all = "snake_case")]
452pub enum SdkErrorCode {
453    /// No authenticated client context was supplied.
454    Unauthenticated,
455    /// The authenticated client lacks a required capability.
456    Unauthorized,
457    /// Another client owns control or no controller lease was claimed.
458    ControllerRequired,
459    /// The caller's controller lease expired before the mutation.
460    LeaseExpired,
461    /// Input did not satisfy the operation contract.
462    InvalidArgument,
463    /// The requested session, runtime, or request was not found.
464    NotFound,
465    /// A turn already owns the runtime.
466    Busy,
467    /// The selected adapter honestly does not implement the operation.
468    UnsupportedAction,
469    /// A runtime or provider operation failed.
470    Execution,
471    /// The transport closed or returned an invalid envelope.
472    Transport,
473}
474
475/// Typed turn failure shared by local, HTTP, ACP, CLI, and language adapters.
476#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
477pub enum RuntimeSubmitError {
478    /// Another turn already owns the runtime.
479    #[error("a turn is already in progress")]
480    Busy,
481    /// The active turn was cancelled through the SDK runtime handle.
482    #[error("turn interrupted")]
483    Interrupted,
484    /// The model/provider/tool loop failed.
485    #[error("{0}")]
486    Agent(String),
487}
488
489/// Typed failure returned by every SDK adapter and compatibility projection.
490#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
491pub enum SdkError {
492    /// A runtime operation was attempted without authenticated client state.
493    #[error("SDK runtime authentication required")]
494    Unauthenticated,
495    /// The authenticated client lacks a required runtime permission.
496    #[error("SDK runtime permission `{permission}` is required")]
497    Unauthorized {
498        /// Stable permission spelling.
499        permission: String,
500    },
501    /// Mutation requires the controller lease. When another client owns it,
502    /// its opaque identity and deadline are included for deterministic retry.
503    #[error("controller lease required")]
504    ControllerRequired {
505        /// Current controller, when known.
506        holder: Option<String>,
507        /// Current controller deadline, when known.
508        expires_at_ms: Option<u64>,
509    },
510    /// This client previously controlled the runtime but its lease expired.
511    #[error("controller lease expired")]
512    LeaseExpired,
513    /// Input did not satisfy an operation contract.
514    #[error("invalid SDK argument for {operation:?}: {message}")]
515    InvalidArgument {
516        /// Operation being decoded.
517        operation: SdkOperation,
518        /// Validation detail.
519        message: String,
520    },
521    /// A stable identity was not found.
522    #[error("SDK target for {operation:?} was not found: {message}")]
523    NotFound {
524        /// Operation being executed.
525        operation: SdkOperation,
526        /// Lookup detail.
527        message: String,
528    },
529    /// The active adapter does not implement the requested action.
530    #[error("SDK action `{0}` is not supported by this runtime")]
531    UnsupportedAction(&'static str),
532    /// The requested catalog operation is absent or has no typed route.
533    #[error("SDK operation `{0}` is not supported by this runtime")]
534    UnsupportedOperation(String),
535    /// A slow consumer fell behind the bounded live-event channel.
536    #[error("SDK event stream lost {0} event(s); reattach for a fresh snapshot")]
537    ReplayGap(u64),
538    /// The runtime closed its event stream.
539    #[error("SDK runtime event stream closed")]
540    Closed,
541    /// An authenticated remote transport failed or returned an invalid value.
542    #[error("SDK transport failed: {0}")]
543    Transport(String),
544    /// No live request exists for the supplied response id.
545    #[error("SDK request {0} is not pending")]
546    UnknownRequest(u64),
547    /// The response kind or value does not match the pending request.
548    #[error("invalid SDK response: {0}")]
549    InvalidResponse(String),
550    /// The canonical runtime rejected or failed a turn.
551    #[error(transparent)]
552    Submit(#[from] RuntimeSubmitError),
553    /// A session/runtime implementation failed after validation.
554    #[error("SDK execution failed for {operation:?}: {message}")]
555    Execution {
556        /// Operation being executed.
557        operation: SdkOperation,
558        /// Implementation detail.
559        message: String,
560    },
561}
562
563impl SdkError {
564    /// Construct a typed failure for an SDK operation.
565    pub fn new(code: SdkErrorCode, operation: SdkOperation, message: impl Into<String>) -> Self {
566        let message = message.into();
567        match code {
568            SdkErrorCode::Unauthenticated => Self::Unauthenticated,
569            SdkErrorCode::Unauthorized => Self::Unauthorized {
570                permission: message,
571            },
572            SdkErrorCode::ControllerRequired => Self::ControllerRequired {
573                holder: None,
574                expires_at_ms: None,
575            },
576            SdkErrorCode::LeaseExpired => Self::LeaseExpired,
577            SdkErrorCode::InvalidArgument => Self::InvalidArgument { operation, message },
578            SdkErrorCode::NotFound => Self::NotFound { operation, message },
579            SdkErrorCode::Busy => Self::Submit(RuntimeSubmitError::Busy),
580            SdkErrorCode::UnsupportedAction => Self::unsupported(operation),
581            SdkErrorCode::Execution => Self::Execution { operation, message },
582            SdkErrorCode::Transport => Self::Transport(message),
583        }
584    }
585
586    /// Construct a named unsupported-action failure.
587    pub fn unsupported(operation: SdkOperation) -> Self {
588        Self::UnsupportedAction(operation.action_name())
589    }
590
591    /// Stable machine-readable category.
592    pub fn code(&self) -> SdkErrorCode {
593        match self {
594            Self::Unauthenticated => SdkErrorCode::Unauthenticated,
595            Self::Unauthorized { .. } => SdkErrorCode::Unauthorized,
596            Self::ControllerRequired { .. } => SdkErrorCode::ControllerRequired,
597            Self::LeaseExpired => SdkErrorCode::LeaseExpired,
598            Self::InvalidArgument { .. } | Self::InvalidResponse(_) => {
599                SdkErrorCode::InvalidArgument
600            }
601            Self::NotFound { .. } | Self::UnknownRequest(_) => SdkErrorCode::NotFound,
602            Self::Submit(RuntimeSubmitError::Busy) => SdkErrorCode::Busy,
603            Self::UnsupportedAction(_) | Self::UnsupportedOperation(_) => {
604                SdkErrorCode::UnsupportedAction
605            }
606            Self::Transport(_) | Self::ReplayGap(_) | Self::Closed => SdkErrorCode::Transport,
607            Self::Submit(_) | Self::Execution { .. } => SdkErrorCode::Execution,
608        }
609    }
610
611    /// Operation associated with this failure when it is unambiguous.
612    pub fn operation(&self) -> Option<SdkOperation> {
613        match self {
614            Self::InvalidArgument { operation, .. }
615            | Self::NotFound { operation, .. }
616            | Self::Execution { operation, .. } => Some(*operation),
617            Self::UnsupportedAction(action) => SdkOperation::from_action_name(action),
618            Self::Unauthenticated
619            | Self::Unauthorized { .. }
620            | Self::ControllerRequired { .. }
621            | Self::LeaseExpired => None,
622            Self::UnknownRequest(_) | Self::InvalidResponse(_) => Some(SdkOperation::Respond),
623            Self::Submit(_) => Some(SdkOperation::Input),
624            Self::UnsupportedOperation(_)
625            | Self::ReplayGap(_)
626            | Self::Closed
627            | Self::Transport(_) => None,
628        }
629    }
630}
631
632/// Capability inventory for the complete v1 SDK, independent of transport.
633#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
634pub struct SdkCapabilities {
635    /// Schema identifier governing this descriptor.
636    pub schema_version: String,
637    /// Operations understood by the facade. A concrete runtime may still
638    /// return `unsupported_action` for a mechanically unavailable action.
639    pub operations: Vec<SdkOperation>,
640    /// Stable error categories clients must preserve by name.
641    pub error_codes: Vec<SdkErrorCode>,
642    /// Whether events preserve unknown native payloads losslessly.
643    pub opaque_events: bool,
644}
645
646impl Default for SdkCapabilities {
647    fn default() -> Self {
648        Self {
649            schema_version: SDK_SCHEMA_VERSION.into(),
650            operations: SdkOperation::ALL.to_vec(),
651            error_codes: vec![
652                SdkErrorCode::Unauthenticated,
653                SdkErrorCode::Unauthorized,
654                SdkErrorCode::ControllerRequired,
655                SdkErrorCode::LeaseExpired,
656                SdkErrorCode::InvalidArgument,
657                SdkErrorCode::NotFound,
658                SdkErrorCode::Busy,
659                SdkErrorCode::UnsupportedAction,
660                SdkErrorCode::Execution,
661                SdkErrorCode::Transport,
662            ],
663            opaque_events: true,
664        }
665    }
666}
667
668/// Canonical event before a wire transport adds subscription metadata.
669#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
670pub struct SdkEvent {
671    /// Monotonic sequence scoped to the SDK runtime.
672    pub sequence: u64,
673    /// Normalized or native event kind.
674    pub kind: String,
675    /// Complete payload, including unknown fields.
676    pub payload: Value,
677}
678
679impl SdkEvent {
680    pub(crate) fn new(sequence: u64, payload: Value) -> Self {
681        let kind = payload
682            .get("type")
683            .or_else(|| payload.get("method"))
684            .and_then(Value::as_str)
685            .unwrap_or("unknown")
686            .to_string();
687        Self {
688            sequence,
689            kind,
690            payload,
691        }
692    }
693}
694
695/// One runtime event paired with its durable SDK identity.
696///
697/// A transport may add a connection or subscription id around this value,
698/// but those routing fields never become part of [`SdkEvent`] or a session.
699#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
700pub struct SdkRuntimeEvent {
701    /// Stable SDK session identity, never a transport-local connection id.
702    pub session_id: String,
703    /// Canonical event shared by local and remote runtime adapters.
704    pub event: SdkEvent,
705}
706
707/// Canonical live-runtime contract owned by the SDK.
708///
709/// Frontend modules are projections of this trait. They may render events or
710/// add transport envelopes, but they do not own a second model loop.
711#[async_trait]
712pub trait SdkRuntime: Send + Sync {
713    /// Describe runtime identity, modules, commands, actions, and state.
714    async fn describe(&self) -> Result<crate::frontend::FrontendRuntimeDescriptor, SdkError>;
715    /// Atomically attach at the canonical history/live-event boundary.
716    async fn attach(
717        &self,
718        history_limit: usize,
719    ) -> Result<crate::frontend::FrontendAttachment, SdkError>;
720    /// Atomically accept a new user turn and return once ownership is claimed.
721    ///
722    /// Exactly one simultaneous caller succeeds. The accepted turn continues
723    /// on the SDK-owned runtime and publishes its result through the canonical
724    /// event stream; a competing caller receives [`SdkErrorCode::Busy`]
725    /// synchronously from this operation.
726    async fn send_input(self: Arc<Self>, prompt: String) -> Result<(), SdkError>;
727    /// Submit a new user turn.
728    async fn submit(&self, prompt: String) -> Result<String, SdkError>;
729    /// Submit a new user turn with canonical multimodal image inputs.
730    ///
731    /// Frontends must pass only runtime-resolved URLs or data URIs here; the
732    /// SDK runtime, not a remote display client, owns input interpretation.
733    async fn submit_with_images(
734        &self,
735        prompt: String,
736        image_urls: Vec<String>,
737    ) -> Result<String, SdkError> {
738        if image_urls.is_empty() {
739            self.submit(prompt).await
740        } else {
741            Err(SdkError::UnsupportedAction("submit_attachments"))
742        }
743    }
744    /// Interrupt an active turn.
745    async fn interrupt(&self) -> Result<bool, SdkError>;
746    /// Queue a steering instruction when supported.
747    async fn steer(&self, prompt: String) -> Result<(), SdkError>;
748    /// Answer a typed runtime request when supported.
749    async fn respond(&self, response: crate::frontend::FrontendResponse) -> Result<(), SdkError>;
750    /// Invoke one operation from the descriptor's explicit catalog.
751    async fn invoke(
752        &self,
753        operation: crate::frontend::FrontendOperationInvocation,
754    ) -> Result<crate::frontend::FrontendOperationResult, SdkError> {
755        Err(SdkError::UnsupportedOperation(
756            operation.operation_id().to_string(),
757        ))
758    }
759    /// Read the one-controller/many-observer ownership state.
760    async fn lease_snapshot(&self) -> Result<crate::RuntimeLeaseSnapshot, SdkError> {
761        Err(SdkError::UnsupportedOperation("runtime.lease".into()))
762    }
763    /// Explicitly acquire the controller lease from another interactive
764    /// client. Ordinary mutations never perform an implicit takeover.
765    async fn take_control(&self) -> Result<crate::RuntimeLeaseSnapshot, SdkError> {
766        Err(SdkError::UnsupportedOperation(
767            "runtime.take_control".into(),
768        ))
769    }
770    /// Refresh observer activity and a controller lease owned by this client.
771    async fn heartbeat(&self) -> Result<crate::RuntimeLeaseSnapshot, SdkError> {
772        Err(SdkError::UnsupportedOperation("runtime.heartbeat".into()))
773    }
774    /// Release this client's observer/controller state without stopping the
775    /// runtime.
776    async fn detach(&self) -> Result<crate::RuntimeLeaseSnapshot, SdkError> {
777        Err(SdkError::UnsupportedOperation("runtime.detach".into()))
778    }
779    /// Explicitly close the SDK-owned runtime when the negotiated descriptor
780    /// grants that owner-level action. Dropping an attachment is always a
781    /// detach and never calls this operation implicitly.
782    async fn close(&self) -> Result<(), SdkError> {
783        Err(SdkError::unsupported(SdkOperation::Close))
784    }
785}
786
787/// Stateful SDK facade consumed by public transport adapters.
788#[async_trait]
789pub trait SdkService: Send {
790    /// Describe the versioned contract without invoking a runtime.
791    fn capabilities(&self) -> SdkCapabilities {
792        SdkCapabilities::default()
793    }
794
795    /// Execute one typed request. Transport correlation fields are not part
796    /// of this API and therefore cannot contaminate canonical state.
797    async fn execute(&mut self, request: SdkRequest) -> Result<Value, SdkError>;
798
799    /// Poll canonical runtime events without a transport envelope.
800    async fn events(&mut self) -> Result<Vec<SdkRuntimeEvent>, SdkError>;
801}