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    // ---- BP-8: session durability (catalog:150/151/152/154/156) ---------
111
112    /// Install the append-only session journal — see
113    /// [`crate::agent::Agent::set_journal`].
114    pub fn set_journal(&mut self, journal: crate::session_journal::SessionJournal) {
115        self.0.set_journal(journal);
116    }
117
118    /// Whether an append-only journal is installed.
119    pub fn has_journal(&self) -> bool {
120        self.0.has_journal()
121    }
122
123    /// Declare the durable view caught up at `messages` messages.
124    pub fn journal_checkpoint(&self, messages: usize) {
125        self.0.journal_checkpoint(messages);
126    }
127
128    /// This session's in-place conversation tree, when the module is on.
129    pub fn session_tree(&self) -> Option<&crate::session_tree::SessionTree> {
130        self.0.session_tree()
131    }
132
133    /// Install a tree loaded from the store.
134    pub fn set_session_tree(&mut self, tree: crate::session_tree::SessionTree) {
135        self.0.set_session_tree(tree);
136    }
137
138    /// Materialize the degenerate single-path tree from the live history.
139    pub fn rebuild_session_tree_from_history(&mut self) {
140        self.0.rebuild_session_tree_from_history();
141    }
142
143    /// Rewind THIS conversation to an earlier point, recorded and
144    /// invertible — see [`crate::agent::Agent::rewind_conversation`].
145    pub fn rewind_conversation(&mut self, keep: usize) -> crate::agent::RewindOutcome {
146        self.0.rewind_conversation(keep)
147    }
148
149    /// Invert the most recent rewind.
150    pub fn undo_rewind(&mut self) -> bool {
151        self.0.undo_rewind()
152    }
153
154    /// How many rewinds are currently undoable.
155    pub fn undoable_rewinds(&self) -> usize {
156        self.0.undoable_rewinds()
157    }
158
159    /// Restore an undo stack recovered from the journal.
160    pub fn restore_rewind_undo(&mut self, stack: Vec<Vec<crate::ChatMessage>>) {
161        self.0.restore_rewind_undo(stack);
162    }
163
164    /// Re-queue pending inputs recovered from the journal.
165    pub fn restore_queues(&mut self, steer: &[String], follow_up: &[String]) {
166        self.0.restore_queues(steer, follow_up);
167    }
168
169    /// Append messages recovered from the journal after a crash.
170    pub fn append_recovered_messages(&mut self, messages: &[crate::ChatMessage]) {
171        self.0.append_recovered_messages(messages);
172    }
173
174    /// The session's `update_plan` checklist.
175    pub fn plan(&self) -> Vec<crate::session_journal::PlanEntry> {
176        self.0.plan()
177    }
178
179    /// Restore a plan read back from the store.
180    pub fn set_plan(&mut self, steps: Vec<crate::session_journal::PlanEntry>) {
181        self.0.set_plan(steps);
182    }
183
184    /// BP-8: arm `[core.session]`'s durability for `name` and restore what a
185    /// previous process left — see [`crate::session_journal::arm`].
186    pub fn arm_session_journal(
187        &mut self,
188        store: &crate::store::SessionStore,
189        name: &str,
190    ) -> crate::session_journal::RestoreReport {
191        crate::session_journal::arm(&mut self.0, store, name)
192    }
193
194    /// BP-8: the mirror — see [`crate::session_journal::checkpoint`].
195    pub fn checkpoint_session_journal(
196        &self,
197        store: &crate::store::SessionStore,
198        name: &str,
199        messages: usize,
200    ) {
201        crate::session_journal::checkpoint(&self.0, store, name, messages);
202    }
203
204    /// BP-3 (§2 module 8 `plan_mode`): the session's shared plan-mode state
205    /// — what a composer's `/plan` toggles and the permission gate reads.
206    /// Reading or toggling it acquires no loop ownership.
207    pub fn plan_mode(&self) -> &std::sync::Arc<crate::tools::PlanModeState> {
208        self.0.plan_mode()
209    }
210
211    /// Install the reversible provider-view reduction policy.
212    pub fn set_reduction_policy(&mut self, policy: crate::reduce::ReductionPolicy) {
213        self.0.set_reduction_policy(policy);
214    }
215
216    /// Inspect the current provider-view reduction policy.
217    pub fn reduction_policy(&self) -> Option<&crate::reduce::ReductionPolicy> {
218        self.0.reduction_policy()
219    }
220
221    /// Replace the reversible reduction log after an SDK-owned projection.
222    pub fn set_reduction_log(&mut self, log: crate::reduce::ReductionLog) {
223        self.0.set_reduction_log(log);
224    }
225
226    /// Inspect the reversible reduction log.
227    pub fn reduction_log(&self) -> &crate::reduce::ReductionLog {
228        self.0.reduction_log()
229    }
230
231    /// Prepare optional cleared-turn summary metadata without sending a turn.
232    pub fn prepare_cleared_turns_summary(
233        &self,
234        messages: &[crate::ChatMessage],
235        policy: &crate::reduce::ReductionPolicy,
236        prior: &crate::reduce::ReductionLog,
237    ) -> Option<crate::reduce::PreparedClearSummary> {
238        self.0
239            .prepare_cleared_turns_summary(messages, policy, prior)
240    }
241
242    /// Install a reduction span summarizer.
243    pub fn set_span_summarizer(
244        &mut self,
245        summarizer: impl crate::reduce::summarize::SpanSummarizer + Send + Sync + 'static,
246    ) {
247        self.0.set_span_summarizer(summarizer);
248    }
249
250    /// Install the optional persisted-session title generator.
251    pub fn set_session_titler(
252        &mut self,
253        titler: impl crate::session_title::SessionTitler + Send + Sync + 'static,
254    ) {
255        self.0.set_session_titler(titler);
256    }
257
258    /// Generate a title from canonical history when configured.
259    pub fn auto_title(&self) -> Option<String> {
260        self.0.auto_title()
261    }
262
263    /// Attach a session store for SDK-owned subagent persistence.
264    pub fn set_subagent_store(
265        &mut self,
266        store: std::sync::Arc<crate::SessionStore>,
267        session_name: impl Into<String>,
268    ) {
269        self.0.set_subagent_store(store, session_name);
270    }
271
272    /// Install restored Claude runtime state without activating a timer.
273    pub fn set_claude_runtime_manifest(
274        &mut self,
275        manifest: crate::claude_runtime_state::ClaudeRuntimeManifest,
276    ) {
277        self.0.set_claude_runtime_manifest(manifest);
278    }
279
280    /// Inspect restored Claude runtime state.
281    pub fn claude_runtime_manifest(
282        &self,
283    ) -> Option<&crate::claude_runtime_state::ClaudeRuntimeManifest> {
284        self.0.claude_runtime_manifest()
285    }
286
287    /// Mutate Claude runtime state from the SDK scheduler/persistence driver.
288    pub fn claude_runtime_manifest_mut(
289        &mut self,
290    ) -> Option<&mut crate::claude_runtime_state::ClaudeRuntimeManifest> {
291        self.0.claude_runtime_manifest_mut()
292    }
293
294    /// Restore project-scoped Claude agent definitions after disk reload.
295    pub fn restore_claude_project_agents(&mut self) -> CoreResult<usize> {
296        self.0.restore_claude_project_agents()
297    }
298
299    /// Replace canonical history with a loaded normalized session.
300    pub fn load_session(&mut self, session: Session) {
301        self.0.load_session(session);
302    }
303
304    /// Load a Supercode transcript through the SDK component.
305    pub fn load_transcript(&mut self, path: impl AsRef<Path>) -> CoreResult<()> {
306        self.0.load_transcript(path)
307    }
308
309    /// Save the canonical transcript through the SDK component.
310    pub fn save_transcript(&self, path: impl AsRef<Path>) -> CoreResult<()> {
311        self.0.save_transcript(path)
312    }
313
314    /// Read canonical history at a quiescent boundary.
315    pub fn history(&self) -> &[crate::ChatMessage] {
316        self.0.history()
317    }
318
319    /// Rewind canonical history to a prior message boundary.
320    pub fn rewind_to(&mut self, checkpoint: usize) {
321        self.0.rewind_to(checkpoint);
322    }
323
324    /// BP-4 (catalog:98): compact now, with optional `/compact <focus>`
325    /// steering text — see [`Agent::compact_now`].
326    pub fn compact_now(&mut self, focus: Option<&str>) -> bool {
327        self.0.compact_now(focus)
328    }
329
330    /// BP-4 (catalog:109): live context-window accounting — see
331    /// [`Agent::context_usage`].
332    pub fn context_usage(&self) -> crate::ContextUsage {
333        self.0.context_usage()
334    }
335
336    /// BP-4 (catalog:106): reset the working view to a fresh objective plus
337    /// a curated keep-set — see [`Agent::new_context`].
338    pub fn new_context(&mut self, objective: &str, keep_recent: Option<usize>) -> usize {
339        self.0.new_context(objective, keep_recent)
340    }
341
342    /// BP-4 (catalog:91): splice an ambient context block into the live
343    /// session — see [`Agent::inject_context_block`].
344    pub fn inject_context_block(
345        &mut self,
346        name: impl Into<String>,
347        content: impl Into<String>,
348    ) -> bool {
349        self.0.inject_context_block(name, content)
350    }
351
352    /// BP-4 (catalog:90): re-derive and re-emit the environment context
353    /// block if it changed — see [`Agent::refresh_env_context`].
354    pub fn refresh_env_context(&mut self) -> bool {
355        self.0.refresh_env_context()
356    }
357
358    /// Append an SDK-assembled system note.
359    pub fn append_system_note(&mut self, text: &str) {
360        self.0.append_system_note(text);
361    }
362
363    /// Register one configured tool before transferring live-loop ownership.
364    pub fn register_tool(&mut self, tool: impl crate::Tool + 'static) {
365        self.0.register_tool(tool);
366    }
367
368    /// Register one MCP prompt source before transferring loop ownership.
369    pub fn register_mcp_prompt(
370        &mut self,
371        command_name: impl Into<String>,
372        source: impl SdkPromptSource + 'static,
373    ) {
374        self.0.register_mcp_prompt(command_name, source);
375    }
376
377    /// Inspect the exact next-request tool schemas for preflight measurement.
378    pub fn tool_schemas(&self) -> Vec<crate::ToolSchema> {
379        self.0.tool_schemas()
380    }
381
382    /// Arm the per-request context limit guard.
383    pub fn set_context_limit(&mut self, limit: u64) {
384        self.0.set_context_limit(limit);
385    }
386
387    /// Inspect the armed context limit.
388    pub fn context_limit(&self) -> Option<u64> {
389        self.0.context_limit()
390    }
391
392    /// Switch the next-request model at a quiescent boundary.
393    pub fn set_model(&mut self, model: impl Into<String>) {
394        self.0.set_model(model);
395    }
396
397    /// BP-13 (catalog D9 "Fast mode / service tiers"): set or clear the
398    /// session-level service-tier override — what `/fast` pulls. `None`
399    /// restores whatever the routing table resolved for the model.
400    pub fn set_service_tier(&mut self, tier: Option<String>) {
401        self.0.set_service_tier(tier);
402    }
403
404    /// BP-13: the model this runtime sends on its next request.
405    pub fn model(&self) -> &str {
406        self.0.model()
407    }
408
409    /// BP-13 (catalog D9 "Mid-session model switching"): the GOVERNED
410    /// switch — under `[core.model_switch] allow_switch` it strips model-A
411    /// reasoning artifacts from the live history (dep 8), records the
412    /// change in the session journal, and emits it, before moving
413    /// `Config::model`. With the gate off it is exactly [`Self::set_model`].
414    pub fn switch_model(&mut self, model: impl Into<String>) {
415        self.0.switch_model(model);
416    }
417
418    /// Whether a provider request has actually been issued.
419    pub fn request_issued(&self) -> bool {
420        self.0.request_issued()
421    }
422
423    /// Configured durable session name.
424    pub fn session_name(&self) -> Option<&str> {
425        self.0.session_name()
426    }
427
428    /// Whether persistence is enabled for this component.
429    pub fn session_persist(&self) -> bool {
430        self.0.session_persist()
431    }
432
433    /// Captured git provenance.
434    pub fn git_metadata(&self) -> Option<&crate::git_metadata::GitMetadataRecord> {
435        self.0.git_metadata()
436    }
437
438    /// Save captured git provenance.
439    pub fn save_git_metadata(&self, store: &crate::SessionStore, name: &str) -> CoreResult<()> {
440        self.0.save_git_metadata(store, name)
441    }
442
443    /// BP-7: save the accumulated per-turn usage log (tokens + cost).
444    pub fn save_usage_log(&self, store: &crate::SessionStore, name: &str) -> CoreResult<()> {
445        self.0.save_usage_log(store, name)
446    }
447
448    /// BP-7: the per-round-trip marker log.
449    pub fn turn_records(&self) -> &[crate::turn_record::TurnRecord] {
450        self.0.turn_records()
451    }
452
453    /// BP-7: save the per-round-trip marker log.
454    pub fn save_turn_records(&self, store: &crate::SessionStore, name: &str) -> CoreResult<()> {
455        self.0.save_turn_records(store, name)
456    }
457
458    /// BP-7: record that the in-flight turn was interrupted.
459    pub fn note_abort(&mut self, source: &str) {
460        self.0.note_abort(source);
461    }
462
463    /// BP-7: dollars spent so far (see [`Self::model_priced`]).
464    pub fn total_cost_usd(&self) -> f64 {
465        self.0.total_cost_usd()
466    }
467
468    /// BP-7: whether this build can price this component's model.
469    pub fn model_priced(&self) -> bool {
470        self.0.model_priced()
471    }
472
473    /// BP-7: tool calls executed so far.
474    pub fn total_steps(&self) -> usize {
475        self.0.total_steps()
476    }
477
478    /// BP-7: set or revise the session's standing objective.
479    pub fn set_goal(&mut self, objective: impl Into<String>) -> bool {
480        self.0.set_goal(objective)
481    }
482
483    /// BP-7: the session's standing objective.
484    pub fn goal(&self) -> Option<&crate::goals::GoalRecord> {
485        self.0.goal()
486    }
487
488    /// BP-7: drop the standing objective.
489    pub fn clear_goal(&mut self) -> bool {
490        self.0.clear_goal()
491    }
492
493    /// BP-7: adopt a goal loaded from the store.
494    pub fn restore_goal(&mut self, goal: Option<crate::goals::GoalRecord>) {
495        self.0.restore_goal(goal);
496    }
497
498    /// BP-7: persist (or clear) the standing objective.
499    pub fn save_goal(&self, store: &crate::SessionStore, name: &str) -> CoreResult<()> {
500        self.0.save_goal(store, name)
501    }
502
503    /// BP-7: the reasoning-effort level in force, `None` when off.
504    pub fn effort(&self) -> Option<&str> {
505        self.0.effort()
506    }
507
508    /// BP-7: change reasoning effort mid-session; `None` turns it off.
509    pub fn set_effort(&mut self, effort: Option<String>) -> Option<String> {
510        self.0.set_effort(effort)
511    }
512
513    /// BP-7: this harness's review-turn prompt with `{args}` filled in.
514    pub fn review_prompt(&self, args: &str) -> Option<String> {
515        self.0.review_prompt(args)
516    }
517
518    /// BP-7: a tool-less question over the full context that records
519    /// nothing.
520    pub async fn side_question(&self, question: &str) -> CoreResult<String> {
521        self.0.side_question(question).await
522    }
523
524    /// Number of non-system canonical messages.
525    pub fn turn_count(&self) -> usize {
526        self.0.turn_count()
527    }
528
529    /// Cumulative provider-reported output tokens.
530    pub fn total_output_tokens(&self) -> u64 {
531        self.0.total_output_tokens()
532    }
533}
534
535impl From<Agent> for SdkAgent {
536    fn from(agent: Agent) -> Self {
537        Self::from_agent(agent)
538    }
539}
540
541/// Construct the emulation component inside the SDK ownership boundary.
542pub fn create_agent(config: Config) -> CoreResult<SdkAgent> {
543    Agent::new(config).map(SdkAgent::from_agent)
544}
545
546/// Resume canonical history inside a fresh SDK-owned emulation component.
547pub fn resume_agent(config: Config, session: Session) -> CoreResult<SdkAgent> {
548    Agent::resume(config, session).map(SdkAgent::from_agent)
549}
550
551/// Submit one text turn through the SDK-owned emulation loop.
552pub async fn submit_agent(agent: &mut SdkAgent, prompt: &str) -> CoreResult<String> {
553    agent.0.send(prompt).await
554}
555
556/// BP-5 (catalog D2 "Prompt-input debugging"): render the exact model-visible
557/// input `prompt` would produce, as JSON, without sending anything.
558///
559/// The prompt is expanded exactly as [`submit_agent`] would expand it and the
560/// request is assembled by the loop's own `chat_request`, so this is the real
561/// input rather than a second rendering of it. Nothing is recorded, nothing is
562/// persisted, and no provider request is issued.
563pub async fn show_model_input(agent: &mut SdkAgent, prompt: &str) -> serde_json::Value {
564    let req = agent.0.model_input_for(prompt).await;
565    Agent::render_model_input(&req)
566}
567
568/// Submit one multimodal turn through the SDK-owned emulation loop.
569pub async fn submit_agent_with_images(
570    agent: &mut SdkAgent,
571    prompt: &str,
572    image_urls: &[String],
573) -> CoreResult<String> {
574    agent.0.send_with_images(prompt, image_urls).await
575}
576
577/// One operation owned by the SDK facade.
578#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
579#[serde(rename_all = "snake_case")]
580pub enum SdkOperation {
581    /// Discover persisted sessions.
582    Discover,
583    /// Load one persisted session without modifying it.
584    Load,
585    /// Start a harness-native runtime.
586    Start,
587    /// Resume a harness-native persisted runtime.
588    Resume,
589    /// Send input to an SDK-owned runtime connection.
590    Input,
591    /// Poll canonical runtime events.
592    Events,
593    /// Interrupt the active turn.
594    Interrupt,
595    /// Queue guidance at the next model-loop boundary.
596    Steer,
597    /// Answer a typed runtime request.
598    Respond,
599    /// Export a loaded session through a native serializer.
600    Export,
601    /// List the harness's scheduled jobs (observed tier, read-only).
602    JobsList,
603    /// Read one scheduled job's definition (observed tier, read-only).
604    JobsGet,
605    /// Create a scheduled job through the harness's own verb (ORCH-18).
606    JobsCreate,
607    /// Patch a scheduled job through the harness's own verb.
608    JobsUpdate,
609    /// Stop the harness's scheduler from firing a job.
610    JobsPause,
611    /// Let the harness's scheduler fire a job again.
612    JobsResume,
613    /// Fire a job now through the harness's own verb.
614    JobsRun,
615    /// Delete a scheduled job through the harness's own verb.
616    JobsDelete,
617    /// Open a fresh conversation through the harness's own door (ORCH-19).
618    SessionsNew,
619    /// Reset a conversation through the harness's own door.
620    SessionsReset,
621    /// Archive a conversation through the harness's own door.
622    SessionsArchive,
623    /// Delete a conversation through the harness's own door.
624    SessionsDelete,
625    /// List a job's past fires with their outcomes (ORCH-8, read-only).
626    RunsList,
627    /// Read one fire's outcome (ORCH-8, read-only).
628    RunsGet,
629    /// Close an SDK-owned runtime connection.
630    Close,
631    /// List the harnesses' named config homes (ORCH-10, read-only).
632    ProfilesList,
633    /// Read one named config home by harness and name.
634    ProfilesGet,
635    /// Create a named config home through the harness's own verb (ORCH-21).
636    ProfilesCreate,
637    /// Delete a named config home through the harness's own verb (ORCH-21).
638    ProfilesDelete,
639    /// List the skill packages each harness has installed (ORCH-11, read-only).
640    SkillsList,
641    /// Install a skill package through the harness's own door (ORCH-22).
642    SkillsInstall,
643    /// Remove a skill package through the harness's own door (ORCH-22).
644    SkillsRemove,
645    /// Read a harness's persistent memory documents (ORCH-12, read-only).
646    MemoryShow,
647    /// Search those same documents by substring or regex (ORCH-12, read-only).
648    MemorySearch,
649    /// List the approval requests waiting for an answer (ORCH-9, read-only).
650    ApprovalsList,
651    /// Answer one listed approval request with a uniform decision (ORCH-20).
652    ApprovalsResolve,
653    /// List the transports each gateway harness is reachable on (ORCH-14).
654    ChannelsList,
655    /// List the routes a gateway harness uses to pick a profile / agent (ORCH-15).
656    RoutesList,
657    /// List a gateway harness's inbound triggers — webhook routes and hook mappings (ORCH-16).
658    TriggersList,
659    /// Read one channel's row by harness and name (ORCH-14).
660    ChannelsStatus,
661    /// Read one home folder as one typed world value (ONT-4).
662    WorldLoad,
663    /// Write a world value back into our own folder (ONT-4).
664    WorldSave,
665    /// Compile another harness's home into a world value (ONT-4).
666    WorldCompile,
667    /// Decompile a world back into another harness's home (ONT-4).
668    WorldDecompile,
669    /// Another harness's home becomes our folder, credentials along (ONT-7).
670    WorldImport,
671    /// Our folder becomes another harness's home, credentials along (ONT-7).
672    WorldExport,
673}
674
675impl SdkOperation {
676    /// Complete v1 operation inventory in stable declaration order.
677    pub const ALL: [Self; 46] = [
678        Self::Discover,
679        Self::Load,
680        Self::Start,
681        Self::Resume,
682        Self::Input,
683        Self::Events,
684        Self::Interrupt,
685        Self::Steer,
686        Self::Respond,
687        Self::Export,
688        Self::JobsList,
689        Self::JobsGet,
690        Self::JobsCreate,
691        Self::JobsUpdate,
692        Self::JobsPause,
693        Self::JobsResume,
694        Self::JobsRun,
695        Self::JobsDelete,
696        Self::SessionsNew,
697        Self::SessionsReset,
698        Self::SessionsArchive,
699        Self::SessionsDelete,
700        Self::RunsList,
701        Self::RunsGet,
702        Self::Close,
703        Self::ProfilesList,
704        Self::ProfilesGet,
705        Self::ProfilesCreate,
706        Self::ProfilesDelete,
707        Self::SkillsList,
708        Self::SkillsInstall,
709        Self::SkillsRemove,
710        Self::MemoryShow,
711        Self::MemorySearch,
712        Self::ApprovalsList,
713        Self::ApprovalsResolve,
714        Self::ChannelsList,
715        Self::RoutesList,
716        Self::TriggersList,
717        Self::ChannelsStatus,
718        Self::WorldLoad,
719        Self::WorldSave,
720        Self::WorldCompile,
721        Self::WorldDecompile,
722        Self::WorldImport,
723        Self::WorldExport,
724    ];
725
726    /// Canonical `harness.v1` method used by JSON transports, when the
727    /// operation is request/response rather than a subscription poll.
728    pub const fn method(self) -> Option<&'static str> {
729        match self {
730            Self::Discover => Some("harness.v1.sessions.discover"),
731            Self::Load => Some("harness.v1.sessions.load"),
732            Self::Start => Some("harness.v1.runtimes.start"),
733            Self::Resume => Some("harness.v1.runtimes.resume"),
734            Self::Input => Some("harness.v1.runtimes.send_input"),
735            Self::Events => None,
736            Self::Interrupt => Some("harness.v1.runtimes.interrupt"),
737            Self::Steer => Some("harness.v1.runtimes.steer"),
738            Self::Respond => Some("harness.v1.runtimes.respond"),
739            Self::Export => Some("harness.v1.sessions.export"),
740            Self::JobsList => Some("harness.v1.jobs.list"),
741            Self::JobsGet => Some("harness.v1.jobs.get"),
742            Self::JobsCreate => Some("harness.v1.jobs.create"),
743            Self::JobsUpdate => Some("harness.v1.jobs.update"),
744            Self::JobsPause => Some("harness.v1.jobs.pause"),
745            Self::JobsResume => Some("harness.v1.jobs.resume"),
746            Self::JobsRun => Some("harness.v1.jobs.run"),
747            Self::JobsDelete => Some("harness.v1.jobs.delete"),
748            Self::SessionsNew => Some("harness.v1.sessions.new"),
749            Self::SessionsReset => Some("harness.v1.sessions.reset"),
750            Self::SessionsArchive => Some("harness.v1.sessions.archive"),
751            Self::SessionsDelete => Some("harness.v1.sessions.delete"),
752            Self::RunsList => Some("harness.v1.runs.list"),
753            Self::RunsGet => Some("harness.v1.runs.get"),
754            Self::Close => Some("harness.v1.runtimes.close"),
755            Self::ProfilesList => Some("harness.v1.profiles.list"),
756            Self::ProfilesGet => Some("harness.v1.profiles.get"),
757            Self::ProfilesCreate => Some("harness.v1.profiles.create"),
758            Self::ProfilesDelete => Some("harness.v1.profiles.delete"),
759            Self::SkillsList => Some("harness.v1.skills.list"),
760            Self::SkillsInstall => Some("harness.v1.skills.install"),
761            Self::SkillsRemove => Some("harness.v1.skills.remove"),
762            Self::MemoryShow => Some("harness.v1.memory.show"),
763            Self::MemorySearch => Some("harness.v1.memory.search"),
764            Self::ApprovalsList => Some("harness.v1.approvals.list"),
765            Self::ApprovalsResolve => Some("harness.v1.approvals.resolve"),
766            Self::ChannelsList => Some("harness.v1.channels.list"),
767            Self::RoutesList => Some("harness.v1.routes.list"),
768            Self::TriggersList => Some("harness.v1.triggers.list"),
769            Self::ChannelsStatus => Some("harness.v1.channels.status"),
770            Self::WorldLoad => Some("harness.v1.world.load"),
771            Self::WorldSave => Some("harness.v1.world.save"),
772            Self::WorldCompile => Some("harness.v1.world.compile"),
773            Self::WorldDecompile => Some("harness.v1.world.decompile"),
774            Self::WorldImport => Some("harness.v1.world.import"),
775            Self::WorldExport => Some("harness.v1.world.export"),
776        }
777    }
778
779    /// Resolve one canonical method without accepting transport aliases.
780    pub fn from_method(method: &str) -> Option<Self> {
781        Self::ALL
782            .into_iter()
783            .find(|operation| operation.method() == Some(method))
784    }
785
786    /// Stable action spelling used by capability and error projections.
787    pub const fn action_name(self) -> &'static str {
788        match self {
789            Self::Discover => "discover",
790            Self::Load => "load",
791            Self::Start => "start",
792            Self::Resume => "resume",
793            Self::Input => "input",
794            Self::Events => "events",
795            Self::Interrupt => "interrupt",
796            Self::Steer => "steer",
797            Self::Respond => "respond",
798            Self::Export => "export",
799            Self::JobsList => "jobs_list",
800            Self::JobsGet => "jobs_get",
801            Self::JobsCreate => "jobs_create",
802            Self::JobsUpdate => "jobs_update",
803            Self::JobsPause => "jobs_pause",
804            Self::JobsResume => "jobs_resume",
805            Self::JobsRun => "jobs_run",
806            Self::JobsDelete => "jobs_delete",
807            Self::SessionsNew => "sessions_new",
808            Self::SessionsReset => "sessions_reset",
809            Self::SessionsArchive => "sessions_archive",
810            Self::SessionsDelete => "sessions_delete",
811            Self::RunsList => "runs_list",
812            Self::RunsGet => "runs_get",
813            Self::Close => "close",
814            Self::ProfilesList => "profiles_list",
815            Self::ProfilesGet => "profiles_get",
816            Self::ProfilesCreate => "profiles_create",
817            Self::ProfilesDelete => "profiles_delete",
818            Self::SkillsList => "skills_list",
819            Self::SkillsInstall => "skills_install",
820            Self::SkillsRemove => "skills_remove",
821            Self::MemoryShow => "memory_show",
822            Self::MemorySearch => "memory_search",
823            Self::ApprovalsList => "approvals_list",
824            Self::ApprovalsResolve => "approvals_resolve",
825            Self::ChannelsList => "channels_list",
826            Self::RoutesList => "routes_list",
827            Self::TriggersList => "triggers_list",
828            Self::ChannelsStatus => "channels_status",
829            Self::WorldLoad => "world_load",
830            Self::WorldSave => "world_save",
831            Self::WorldCompile => "world_compile",
832            Self::WorldDecompile => "world_decompile",
833            Self::WorldImport => "world_import",
834            Self::WorldExport => "world_export",
835        }
836    }
837
838    /// Resolve a stable action spelling.
839    pub fn from_action_name(action: &str) -> Option<Self> {
840        Self::ALL
841            .into_iter()
842            .find(|operation| operation.action_name() == action)
843    }
844}
845
846/// One typed SDK request before a transport adds its envelope.
847#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
848pub struct SdkRequest {
849    /// Requested SDK operation.
850    pub operation: SdkOperation,
851    /// Operation-specific language-neutral parameters.
852    #[serde(default)]
853    pub params: Value,
854}
855
856/// Stable machine-readable SDK failure categories.
857#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
858#[serde(rename_all = "snake_case")]
859pub enum SdkErrorCode {
860    /// No authenticated client context was supplied.
861    Unauthenticated,
862    /// The authenticated client lacks a required capability.
863    Unauthorized,
864    /// Another client owns control or no controller lease was claimed.
865    ControllerRequired,
866    /// The caller's controller lease expired before the mutation.
867    LeaseExpired,
868    /// Input did not satisfy the operation contract.
869    InvalidArgument,
870    /// The requested session, runtime, or request was not found.
871    NotFound,
872    /// A turn already owns the runtime.
873    Busy,
874    /// The selected adapter honestly does not implement the operation.
875    UnsupportedAction,
876    /// A runtime or provider operation failed.
877    Execution,
878    /// The transport closed or returned an invalid envelope.
879    Transport,
880}
881
882/// Typed turn failure shared by local, HTTP, ACP, CLI, and language adapters.
883#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
884pub enum RuntimeSubmitError {
885    /// Another turn already owns the runtime.
886    #[error("a turn is already in progress")]
887    Busy,
888    /// The active turn was cancelled through the SDK runtime handle.
889    #[error("turn interrupted")]
890    Interrupted,
891    /// The model/provider/tool loop failed.
892    #[error("{0}")]
893    Agent(String),
894}
895
896/// Typed failure returned by every SDK adapter and compatibility projection.
897#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
898pub enum SdkError {
899    /// A runtime operation was attempted without authenticated client state.
900    #[error("SDK runtime authentication required")]
901    Unauthenticated,
902    /// The authenticated client lacks a required runtime permission.
903    #[error("SDK runtime permission `{permission}` is required")]
904    Unauthorized {
905        /// Stable permission spelling.
906        permission: String,
907    },
908    /// Mutation requires the controller lease. When another client owns it,
909    /// its opaque identity and deadline are included for deterministic retry.
910    #[error("controller lease required")]
911    ControllerRequired {
912        /// Current controller, when known.
913        holder: Option<String>,
914        /// Current controller deadline, when known.
915        expires_at_ms: Option<u64>,
916    },
917    /// This client previously controlled the runtime but its lease expired.
918    #[error("controller lease expired")]
919    LeaseExpired,
920    /// Input did not satisfy an operation contract.
921    #[error("invalid SDK argument for {operation:?}: {message}")]
922    InvalidArgument {
923        /// Operation being decoded.
924        operation: SdkOperation,
925        /// Validation detail.
926        message: String,
927    },
928    /// A stable identity was not found.
929    #[error("SDK target for {operation:?} was not found: {message}")]
930    NotFound {
931        /// Operation being executed.
932        operation: SdkOperation,
933        /// Lookup detail.
934        message: String,
935    },
936    /// The active adapter does not implement the requested action.
937    #[error("SDK action `{0}` is not supported by this runtime")]
938    UnsupportedAction(&'static str),
939    /// The requested catalog operation is absent or has no typed route.
940    #[error("SDK operation `{0}` is not supported by this runtime")]
941    UnsupportedOperation(String),
942    /// A slow consumer fell behind the bounded live-event channel.
943    #[error("SDK event stream lost {0} event(s); reattach for a fresh snapshot")]
944    ReplayGap(u64),
945    /// The runtime closed its event stream.
946    #[error("SDK runtime event stream closed")]
947    Closed,
948    /// An authenticated remote transport failed or returned an invalid value.
949    #[error("SDK transport failed: {0}")]
950    Transport(String),
951    /// No live request exists for the supplied response id.
952    #[error("SDK request {0} is not pending")]
953    UnknownRequest(u64),
954    /// The response kind or value does not match the pending request.
955    #[error("invalid SDK response: {0}")]
956    InvalidResponse(String),
957    /// The canonical runtime rejected or failed a turn.
958    #[error(transparent)]
959    Submit(#[from] RuntimeSubmitError),
960    /// A session/runtime implementation failed after validation.
961    #[error("SDK execution failed for {operation:?}: {message}")]
962    Execution {
963        /// Operation being executed.
964        operation: SdkOperation,
965        /// Implementation detail.
966        message: String,
967    },
968}
969
970impl SdkError {
971    /// Construct a typed failure for an SDK operation.
972    pub fn new(code: SdkErrorCode, operation: SdkOperation, message: impl Into<String>) -> Self {
973        let message = message.into();
974        match code {
975            SdkErrorCode::Unauthenticated => Self::Unauthenticated,
976            SdkErrorCode::Unauthorized => Self::Unauthorized {
977                permission: message,
978            },
979            SdkErrorCode::ControllerRequired => Self::ControllerRequired {
980                holder: None,
981                expires_at_ms: None,
982            },
983            SdkErrorCode::LeaseExpired => Self::LeaseExpired,
984            SdkErrorCode::InvalidArgument => Self::InvalidArgument { operation, message },
985            SdkErrorCode::NotFound => Self::NotFound { operation, message },
986            SdkErrorCode::Busy => Self::Submit(RuntimeSubmitError::Busy),
987            SdkErrorCode::UnsupportedAction => Self::unsupported(operation),
988            SdkErrorCode::Execution => Self::Execution { operation, message },
989            SdkErrorCode::Transport => Self::Transport(message),
990        }
991    }
992
993    /// Construct a named unsupported-action failure.
994    pub fn unsupported(operation: SdkOperation) -> Self {
995        Self::UnsupportedAction(operation.action_name())
996    }
997
998    /// Stable machine-readable category.
999    pub fn code(&self) -> SdkErrorCode {
1000        match self {
1001            Self::Unauthenticated => SdkErrorCode::Unauthenticated,
1002            Self::Unauthorized { .. } => SdkErrorCode::Unauthorized,
1003            Self::ControllerRequired { .. } => SdkErrorCode::ControllerRequired,
1004            Self::LeaseExpired => SdkErrorCode::LeaseExpired,
1005            Self::InvalidArgument { .. } | Self::InvalidResponse(_) => {
1006                SdkErrorCode::InvalidArgument
1007            }
1008            Self::NotFound { .. } | Self::UnknownRequest(_) => SdkErrorCode::NotFound,
1009            Self::Submit(RuntimeSubmitError::Busy) => SdkErrorCode::Busy,
1010            Self::UnsupportedAction(_) | Self::UnsupportedOperation(_) => {
1011                SdkErrorCode::UnsupportedAction
1012            }
1013            Self::Transport(_) | Self::ReplayGap(_) | Self::Closed => SdkErrorCode::Transport,
1014            Self::Submit(_) | Self::Execution { .. } => SdkErrorCode::Execution,
1015        }
1016    }
1017
1018    /// Operation associated with this failure when it is unambiguous.
1019    pub fn operation(&self) -> Option<SdkOperation> {
1020        match self {
1021            Self::InvalidArgument { operation, .. }
1022            | Self::NotFound { operation, .. }
1023            | Self::Execution { operation, .. } => Some(*operation),
1024            Self::UnsupportedAction(action) => SdkOperation::from_action_name(action),
1025            Self::Unauthenticated
1026            | Self::Unauthorized { .. }
1027            | Self::ControllerRequired { .. }
1028            | Self::LeaseExpired => None,
1029            Self::UnknownRequest(_) | Self::InvalidResponse(_) => Some(SdkOperation::Respond),
1030            Self::Submit(_) => Some(SdkOperation::Input),
1031            Self::UnsupportedOperation(_)
1032            | Self::ReplayGap(_)
1033            | Self::Closed
1034            | Self::Transport(_) => None,
1035        }
1036    }
1037}
1038
1039/// Capability inventory for the complete v1 SDK, independent of transport.
1040#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1041pub struct SdkCapabilities {
1042    /// Schema identifier governing this descriptor.
1043    pub schema_version: String,
1044    /// Operations understood by the facade. A concrete runtime may still
1045    /// return `unsupported_action` for a mechanically unavailable action.
1046    pub operations: Vec<SdkOperation>,
1047    /// Stable error categories clients must preserve by name.
1048    pub error_codes: Vec<SdkErrorCode>,
1049    /// Whether events preserve unknown native payloads losslessly.
1050    pub opaque_events: bool,
1051}
1052
1053impl Default for SdkCapabilities {
1054    fn default() -> Self {
1055        Self {
1056            schema_version: SDK_SCHEMA_VERSION.into(),
1057            operations: SdkOperation::ALL.to_vec(),
1058            error_codes: vec![
1059                SdkErrorCode::Unauthenticated,
1060                SdkErrorCode::Unauthorized,
1061                SdkErrorCode::ControllerRequired,
1062                SdkErrorCode::LeaseExpired,
1063                SdkErrorCode::InvalidArgument,
1064                SdkErrorCode::NotFound,
1065                SdkErrorCode::Busy,
1066                SdkErrorCode::UnsupportedAction,
1067                SdkErrorCode::Execution,
1068                SdkErrorCode::Transport,
1069            ],
1070            opaque_events: true,
1071        }
1072    }
1073}
1074
1075/// Canonical event before a wire transport adds subscription metadata.
1076#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1077pub struct SdkEvent {
1078    /// Monotonic sequence scoped to the SDK runtime.
1079    pub sequence: u64,
1080    /// Normalized or native event kind.
1081    pub kind: String,
1082    /// Complete payload, including unknown fields.
1083    pub payload: Value,
1084}
1085
1086impl SdkEvent {
1087    pub(crate) fn new(sequence: u64, payload: Value) -> Self {
1088        let kind = payload
1089            .get("type")
1090            .or_else(|| payload.get("method"))
1091            .and_then(Value::as_str)
1092            .unwrap_or("unknown")
1093            .to_string();
1094        Self {
1095            sequence,
1096            kind,
1097            payload,
1098        }
1099    }
1100}
1101
1102/// One runtime event paired with its durable SDK identity.
1103///
1104/// A transport may add a connection or subscription id around this value,
1105/// but those routing fields never become part of [`SdkEvent`] or a session.
1106#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1107pub struct SdkRuntimeEvent {
1108    /// Stable SDK session identity, never a transport-local connection id.
1109    pub session_id: String,
1110    /// Canonical event shared by local and remote runtime adapters.
1111    pub event: SdkEvent,
1112}
1113
1114/// Canonical live-runtime contract owned by the SDK.
1115///
1116/// Frontend modules are projections of this trait. They may render events or
1117/// add transport envelopes, but they do not own a second model loop.
1118#[async_trait]
1119pub trait SdkRuntime: Send + Sync {
1120    /// Describe runtime identity, modules, commands, actions, and state.
1121    async fn describe(&self) -> Result<crate::frontend::FrontendRuntimeDescriptor, SdkError>;
1122    /// Atomically attach at the canonical history/live-event boundary.
1123    async fn attach(
1124        &self,
1125        history_limit: usize,
1126    ) -> Result<crate::frontend::FrontendAttachment, SdkError>;
1127    /// Atomically accept a new user turn and return once ownership is claimed.
1128    ///
1129    /// Exactly one simultaneous caller succeeds. The accepted turn continues
1130    /// on the SDK-owned runtime and publishes its result through the canonical
1131    /// event stream; a competing caller receives [`SdkErrorCode::Busy`]
1132    /// synchronously from this operation.
1133    async fn send_input(self: Arc<Self>, prompt: String) -> Result<(), SdkError>;
1134    /// Atomically accept a multimodal user turn and return once ownership is
1135    /// claimed. Implementations must preserve images natively or reject the
1136    /// action; silently folding them into text is never allowed.
1137    async fn send_input_with_images(
1138        self: Arc<Self>,
1139        prompt: String,
1140        image_urls: Vec<String>,
1141    ) -> Result<(), SdkError> {
1142        if image_urls.is_empty() {
1143            self.send_input(prompt).await
1144        } else {
1145            Err(SdkError::UnsupportedAction("send_input_attachments"))
1146        }
1147    }
1148    /// Submit a new user turn.
1149    async fn submit(&self, prompt: String) -> Result<String, SdkError>;
1150    /// Submit a new user turn with canonical multimodal image inputs.
1151    ///
1152    /// Frontends must pass only runtime-resolved URLs or data URIs here; the
1153    /// SDK runtime, not a remote display client, owns input interpretation.
1154    async fn submit_with_images(
1155        &self,
1156        prompt: String,
1157        image_urls: Vec<String>,
1158    ) -> Result<String, SdkError> {
1159        if image_urls.is_empty() {
1160            self.submit(prompt).await
1161        } else {
1162            Err(SdkError::UnsupportedAction("submit_attachments"))
1163        }
1164    }
1165    /// Interrupt an active turn.
1166    async fn interrupt(&self) -> Result<bool, SdkError>;
1167    /// Queue a steering instruction when supported.
1168    async fn steer(&self, prompt: String) -> Result<(), SdkError>;
1169    /// Answer a typed runtime request when supported.
1170    async fn respond(&self, response: crate::frontend::FrontendResponse) -> Result<(), SdkError>;
1171    /// Invoke one operation from the descriptor's explicit catalog.
1172    async fn invoke(
1173        &self,
1174        operation: crate::frontend::FrontendOperationInvocation,
1175    ) -> Result<crate::frontend::FrontendOperationResult, SdkError> {
1176        Err(SdkError::UnsupportedOperation(
1177            operation.operation_id().to_string(),
1178        ))
1179    }
1180    /// Read the one-controller/many-observer ownership state.
1181    async fn lease_snapshot(&self) -> Result<crate::RuntimeLeaseSnapshot, SdkError> {
1182        Err(SdkError::UnsupportedOperation("runtime.lease".into()))
1183    }
1184    /// Explicitly acquire the controller lease from another interactive
1185    /// client. Ordinary mutations never perform an implicit takeover.
1186    async fn take_control(&self) -> Result<crate::RuntimeLeaseSnapshot, SdkError> {
1187        Err(SdkError::UnsupportedOperation(
1188            "runtime.take_control".into(),
1189        ))
1190    }
1191    /// Refresh observer activity and a controller lease owned by this client.
1192    async fn heartbeat(&self) -> Result<crate::RuntimeLeaseSnapshot, SdkError> {
1193        Err(SdkError::UnsupportedOperation("runtime.heartbeat".into()))
1194    }
1195    /// Release this client's observer/controller state without stopping the
1196    /// runtime.
1197    async fn detach(&self) -> Result<crate::RuntimeLeaseSnapshot, SdkError> {
1198        Err(SdkError::UnsupportedOperation("runtime.detach".into()))
1199    }
1200    /// Explicitly close the SDK-owned runtime when the negotiated descriptor
1201    /// grants that owner-level action. Dropping an attachment is always a
1202    /// detach and never calls this operation implicitly.
1203    async fn close(&self) -> Result<(), SdkError> {
1204        Err(SdkError::unsupported(SdkOperation::Close))
1205    }
1206}
1207
1208/// Stateful SDK facade consumed by public transport adapters.
1209#[async_trait]
1210pub trait SdkService: Send {
1211    /// Describe the versioned contract without invoking a runtime.
1212    fn capabilities(&self) -> SdkCapabilities {
1213        SdkCapabilities::default()
1214    }
1215
1216    /// Execute one typed request. Transport correlation fields are not part
1217    /// of this API and therefore cannot contaminate canonical state.
1218    async fn execute(&mut self, request: SdkRequest) -> Result<Value, SdkError>;
1219
1220    /// Poll canonical runtime events without a transport envelope.
1221    async fn events(&mut self) -> Result<Vec<SdkRuntimeEvent>, SdkError>;
1222}