Skip to main content

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