Skip to main content

kernel/model/
seam.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! What one call across the seam carries in each direction, and the two
7//! conversions between assistant content and a ledger payload.
8//! The part `crates/kernel/spec/Model.lean` specifies this module with the
9//! rest of `kernel::model`.
10
11use serde::{Deserialize, Serialize};
12use serde_json::Map;
13
14use super::usage::ModelUsage;
15use super::wire::{BuildingPolicy, ChatRequest, ChatResponse, ContentBlock, StopReason};
16use crate::budget::UsdMicros;
17use crate::error::{AxCode, AxError};
18use crate::event::Payload;
19use crate::locator::B3Hash;
20use crate::tool::ToolCall;
21
22/// The account-side form of assistant content: the single authority for
23/// how content blocks enter `model_returned` payloads (and thus how the
24/// window is rebuilt offline, C16).
25pub fn message_payload(content: &[ContentBlock]) -> Result<Payload, AxError> {
26    let blocks = serde_json::to_value(content).map_err(|err| {
27        AxError::failure(
28            AxCode::InvalidArgs,
29            "encode content blocks",
30            format!("{} block(s) of assistant content", content.len()),
31        )
32        .with_recovery(format!(
33            "JSON refused these blocks ({err}); ask the provider dialect to return text, \
34             tool calls, and thinking blocks only"
35        ))
36    })?;
37    let mut map = Map::new();
38    map.insert("content".to_owned(), blocks);
39    Payload::new(map)
40}
41
42/// What the adapter needs to make one call. `segments` are the frozen
43/// prefix hashes (same source as `prompt_assembled`); `chat` is the full
44/// canonical conversation the dialect puts on the wire.
45#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
46pub struct ModelRequest<'a> {
47    pub policy: BuildingPolicy,
48    pub segments: [B3Hash; 4],
49    pub chat: ChatRequest<'a>,
50}
51
52impl ModelRequest<'_> {
53    /// The same request, owning what it borrowed: for a holder that
54    /// keeps it past the call it was assembled for. Cloning a borrowed
55    /// request first copies only the borrow, so this is the one copy.
56    pub fn into_owned(self) -> ModelRequest<'static> {
57        ModelRequest {
58            policy: self.policy,
59            segments: self.segments,
60            chat: self.chat.into_owned(),
61        }
62    }
63}
64
65/// One assistant turn. `message` is the in-window content; `calls` is the
66/// requested tool wave — empty means the turn loop may conclude the run.
67/// `usage`/`stop`/`billed_usd_micros` ride along from real dialects and
68/// stay `None` for scripted adapters that predate cost accounting.
69#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
70pub struct ModelReturn {
71    pub message: Payload,
72    pub calls: Vec<ToolCall>,
73    pub usage: Option<ModelUsage>,
74    pub stop: Option<StopReason>,
75    pub billed_usd_micros: Option<UsdMicros>,
76}
77
78impl ModelReturn {
79    /// Scripted/minimal construction: content and wave only.
80    pub fn bare(message: Payload, calls: Vec<ToolCall>) -> ModelReturn {
81        ModelReturn {
82            message,
83            calls,
84            usage: None,
85            stop: None,
86            billed_usd_micros: None,
87        }
88    }
89
90    /// The one mapping from canonical response to seam return: tool_use
91    /// blocks become the wave, everything is kept for the account.
92    pub fn from_response(
93        resp: ChatResponse,
94        billed_usd_micros: Option<UsdMicros>,
95    ) -> Result<ModelReturn, AxError> {
96        let message = message_payload(&resp.content)?;
97        let mut calls = Vec::new();
98        for block in &resp.content {
99            if let ContentBlock::ToolUse { id, name, input } = block {
100                calls.push(ToolCall {
101                    id: id.clone(),
102                    name: name.clone(),
103                    args: input.clone(),
104                });
105            }
106        }
107        Ok(ModelReturn {
108            message,
109            calls,
110            usage: Some(resp.usage),
111            stop: Some(resp.stop),
112            billed_usd_micros,
113        })
114    }
115}
116
117/// The inverse of [`message_payload`] for window folding and rebuild.
118///
119/// A message without a canonical `content` array folds to no blocks —
120/// defined behavior for scripted payloads, not an error path. A `content`
121/// array that is present but unreadable is an error: silently folding it
122/// to nothing would drop an assistant message the ledger still holds, and
123/// a window that disagrees with the ledger is a second history.
124pub fn content_from_message(message: &Payload) -> Result<Vec<ContentBlock>, AxError> {
125    let Some(value) = message.as_map().get("content") else {
126        return Ok(Vec::new());
127    };
128    serde_json::from_value(value.clone()).map_err(|err| {
129        AxError::failure(
130            AxCode::WireMismatch,
131            "read assistant content",
132            err.to_string(),
133        )
134        .with_recovery(
135            "this build cannot read a block kind the ledger holds; replay with the build that \
136             wrote it, or extend ContentBlock",
137        )
138    })
139}