Watch
1
0
Fork
You've already forked souveraine
0
souveraine/src/api/models.rs
Fimeg 0f8fe22ab6 admit: give an agent her own uid through one writer
souveraine-admit creates or adopts one account via systemd-sysusers, writes
the root-owned mapping health reads, and reports what NSS says afterwards
instead of what it asked for. An account belongs to one agent: handing
annie's to souvie is refused in both directions, as is adopting a uid >=1000.
Binding is by SeedID, not the UUID filename — a mismatch is identity-drift.
2026-08-17 15:16:30 -04:00

1061 lines
37 KiB
Rust

#![allow(dead_code)] // WIP scaffolding not yet wired
use crate::core::session::ContentBlock;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentSummary {
pub id: String,
pub name: String,
pub description: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub tags: Vec<String>,
/// This agent's own voice (a TTS voice name registered on the box), if set.
/// None means "sound like the substrate" — fall back to the system voice.
///
/// Sourced from `agent.json` (the `_souveraine` block) via `get()`, not the
/// DB's `config_json` column, because that column is write-once at create
/// and goes stale on a later agent.json edit. The public list endpoint is
/// the surface's only unauthenticated view of an agent, so voice travels
/// here — `/v1/agents/:id` (the full state) is token-gated.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub voice_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentState {
pub id: String,
pub name: String,
pub description: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub llm_config: LlmConfig,
pub memory: MemoryConfig,
pub memory_blocks: Vec<MemoryBlock>,
pub tools: Vec<String>,
pub tags: Vec<String>,
/// Public key (hex) of the instance that created this agent.
/// `None` for agents created before this field existed.
pub owner_seed_id: Option<String>,
#[serde(rename = "_souveraine")]
pub souveraine: SouveraineConfig,
}
/// The local kernel-identity shape requested for an agent on this node.
///
/// This is durable intent, not runtime proof. A dedicated agent is not
/// isolated until a privileged admission maps the agent identity to the
/// account and a worker is actually executing with that account's uid.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "kebab-case")]
pub enum PrincipalIntent {
Dedicated,
#[default]
BorrowedUser,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AgentPrincipalConfig {
#[serde(default)]
pub intent: PrincipalIntent,
/// Required for `dedicated`; absent for `borrowed-user`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub account: Option<String>,
}
impl Default for AgentPrincipalConfig {
fn default() -> Self {
Self {
intent: PrincipalIntent::BorrowedUser,
account: None,
}
}
}
impl AgentPrincipalConfig {
pub fn dedicated(account: impl Into<String>) -> Self {
Self {
intent: PrincipalIntent::Dedicated,
account: Some(account.into()),
}
}
pub fn borrowed_user() -> Self {
Self::default()
}
/// Validate only the unprivileged request shape. Account existence,
/// collision, node commission, ownership, and worker state belong to the
/// privileged admission edge and must be checked again there.
pub fn validate_request(&self) -> anyhow::Result<()> {
match self.intent {
PrincipalIntent::BorrowedUser => {
if self.account.is_some() {
anyhow::bail!("borrowed-user principal must not name a dedicated account");
}
}
PrincipalIntent::Dedicated => {
let account = self
.account
.as_deref()
.ok_or_else(|| anyhow::anyhow!("dedicated principal requires an account"))?;
crate::core::principal_map::validate_account_name(account)?;
}
}
Ok(())
}
}
/// The itinerary as a surface can render it.
///
/// The canonical copy remains `system/dynamic/itinerary.md` in the agent's
/// memfs. This is a read-only projection: the Panel must not parse that file
/// or invent a second itinerary store merely to keep a ribbon alive across a
/// shell reload.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ItineraryView {
pub exists: bool,
pub active: bool,
pub title: String,
pub current: usize,
pub route: String,
pub stops: Vec<ItineraryStopView>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ItineraryStopView {
pub name: String,
pub description: Option<String>,
pub todo_id: Option<String>,
pub status: String,
pub nature: Option<String>,
pub energy: Option<String>,
}
impl From<crate::core::tools::itinerary::Itinerary> for ItineraryView {
fn from(itinerary: crate::core::tools::itinerary::Itinerary) -> Self {
use crate::core::tools::itinerary::StopStatus;
let route = itinerary.route_line();
let active = itinerary.is_active();
let stops = itinerary
.stops
.into_iter()
.map(|stop| ItineraryStopView {
name: stop.name,
description: stop.description,
todo_id: stop.todo_id,
status: match stop.status {
StopStatus::Pending => "pending",
StopStatus::Current => "current",
StopStatus::Done => "done",
}
.to_string(),
nature: stop.nature,
energy: stop.energy,
})
.collect();
Self {
exists: true,
active,
title: itinerary.title,
current: itinerary.current,
route,
stops,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LlmConfig {
pub model: String,
#[serde(default = "default_context_window")]
pub context_window: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>,
/// Maximum tool-calling rounds before forcing a text response.
/// Configurable per-agent; 0 disables tools entirely.
#[serde(default = "default_max_tool_rounds")]
pub max_tool_rounds: u32,
/// Milliseconds to wait between tool rounds to avoid rate-limit cascades.
/// The follow-up LLM call after a tool executes can trigger rate limits
/// if it arrives too quickly. Default 500ms.
#[serde(default = "default_inter_round_delay")]
pub inter_round_delay_ms: u64,
/// Whether this model supports image inputs (vision).
/// When false, images are stripped to text markers before sending.
#[serde(default = "default_supports_images")]
pub supports_images: bool,
/// How many tool rounds between subconscious mid-turn checkpoints.
/// 0 disables checkpointing entirely.
#[serde(default = "default_checkpoint_interval")]
pub checkpoint_interval: u32,
}
fn default_supports_images() -> bool {
true
}
fn default_checkpoint_interval() -> u32 {
10
}
fn default_context_window() -> u32 {
128000
}
fn default_max_tool_rounds() -> u32 {
10
}
fn default_inter_round_delay() -> u64 {
500
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MemoryConfig {
pub git_enabled: bool,
pub auto_commit: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub context_window: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MemoryBlock {
pub label: String,
pub value: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub limit: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SouveraineConfig {
pub n1_enabled: bool,
pub reflection_enabled: bool,
pub archivist_enabled: bool,
pub archivist_threshold: f32,
pub sensorium_bandwidth: String,
/// Per-agent model override for the subconscious (N+1) pass. When set,
/// it wins over the global `[subconscious] model`. None falls back to
/// the global setting, then the agent's own primary model.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subconscious_model: Option<String>,
/// Per-agent model override for the reflection (N+25) pass.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reflection_model: Option<String>,
/// Per-agent model override for the archivist (N+100) synthesis pass.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub archivist_model: Option<String>,
/// Per-agent inference provider name (e.g. "zai"). When set, the agent's
/// requests route through the named provider instead of the global
/// `[inference] provider`. None falls back to the global default.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
/// Per-agent voice — how *this* agent sounds, independent of the substrate.
///
/// `[voice] voice_id` is the **system** voice: the substrate speaking as
/// itself. It is not a statement about who any agent is. Two agents on one
/// host should be able to sound like themselves, so the agent's own voice
/// lives here and wins when set; None falls back to the system voice.
///
/// The name is whatever the TTS service has registered (`GET
/// /audio/voices`); an unknown name falls back service-side rather than
/// failing, so a stale value degrades to the system voice.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub voice_id: Option<String>,
/// Durable local principal intent. Existing records deserialize as
/// borrowed-user until they are migrated explicitly; display names are
/// never used to silently infer an account mapping.
#[serde(default)]
pub principal: AgentPrincipalConfig,
}
#[derive(Debug, Deserialize)]
pub struct CreateAgentRequest {
pub name: String,
#[serde(default)]
pub description: Option<String>,
pub llm_config: LlmConfig,
#[serde(default)]
pub memory_blocks: Vec<MemoryBlock>,
#[serde(default)]
pub tools: Vec<String>,
#[serde(default)]
pub tags: Vec<String>,
/// Requested authority shape for this node. Omission is the safe
/// compatibility posture: borrowed-user. A dedicated request records an
/// unadmitted agent; it never creates a Unix account from this HTTP call.
#[serde(default)]
pub principal: AgentPrincipalConfig,
}
#[derive(Debug, Deserialize)]
pub struct UpdateAgentRequest {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub llm_config: Option<LlmConfig>,
#[serde(default)]
pub memory_blocks: Option<Vec<MemoryBlock>>,
#[serde(default)]
pub tools: Option<Vec<String>>,
/// Durable intent only. Recording `dedicated` never creates a Unix
/// account — admission is a separate privileged transition, and until it
/// runs the agent reports `unadmitted` or `acting-as-human`.
#[serde(default)]
pub principal: Option<AgentPrincipalConfig>,
}
#[derive(Debug, Deserialize)]
pub struct AgentFilters {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub tags: Option<String>,
}
#[cfg(test)]
mod principal_tests {
use super::*;
#[test]
fn dedicated_principal_requires_a_safe_non_reserved_account() {
assert!(AgentPrincipalConfig::dedicated("annie")
.validate_request()
.is_ok());
assert!(AgentPrincipalConfig::dedicated("Annie")
.validate_request()
.is_err());
assert!(AgentPrincipalConfig::dedicated("souveraine")
.validate_request()
.is_err());
}
#[test]
fn borrowed_user_cannot_smuggle_an_account_mapping() {
let principal = AgentPrincipalConfig {
intent: PrincipalIntent::BorrowedUser,
account: Some("casey".to_string()),
};
assert!(principal.validate_request().is_err());
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Conversation {
pub id: String,
pub agent_id: String,
pub created_at: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub updated_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Deserialize)]
pub struct CreateConversationRequest {
pub agent_id: String,
}
/// Inbound message content at the HTTP boundary.
///
/// Untagged on purpose: a bare JSON string is exactly the wire it has always
/// been, so every existing client keeps working unchanged; an array carries
/// ordered typed parts. `ContentValue` in `src/bridge/bifrost.rs` is this same
/// shape facing the provider. This is the inward-facing half, written in the
/// substrate's own [`ContentBlock`] vocabulary rather than OpenAI's, because
/// `ContentBlock` is what the session stores, persists, and replays.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum MessageContent {
/// `"content": "plain text"`
Text(String),
/// `"content": [{"type":"text","text":"..."},{"type":"image","media_type":"image/png","data":"<base64>"}]`
Parts(Vec<ContentBlock>),
}
impl Default for MessageContent {
fn default() -> Self {
Self::Text(String::new())
}
}
/// A block kind a surface is not allowed to post.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct UnsupportedContent {
pub kind: &'static str,
}
impl MessageContent {
/// The ordered blocks this content becomes in the session.
///
/// Only `text` and `image` cross this boundary. `tool_use`, `tool_result`
/// and `reasoning` are the engine's to write — accepting them from a
/// surface would let a client forge a turn that never happened. Rejected
/// loudly rather than filtered out, so nothing is discarded in silence.
pub fn into_blocks(self) -> Result<Vec<ContentBlock>, UnsupportedContent> {
match self {
Self::Text(text) => Ok(vec![ContentBlock::Text { text }]),
Self::Parts(parts) => {
for part in &parts {
let kind = match part {
ContentBlock::Text { .. } | ContentBlock::Image { .. } => continue,
ContentBlock::ToolUse { .. } => "tool_use",
ContentBlock::ToolResult { .. } => "tool_result",
ContentBlock::Reasoning { .. } => "reasoning",
};
return Err(UnsupportedContent { kind });
}
Ok(parts)
}
}
}
/// Whether this content carries an image — the capability gate's question.
pub fn has_images(&self) -> bool {
match self {
Self::Text(_) => false,
Self::Parts(parts) => parts
.iter()
.any(|b| matches!(b, ContentBlock::Image { .. })),
}
}
/// Flat text projection for logs and compatibility consumers. Images are
/// named, never dropped without a trace.
pub fn as_text(&self) -> String {
match self {
Self::Text(text) => text.clone(),
Self::Parts(parts) => parts
.iter()
.filter_map(|b| match b {
ContentBlock::Text { text } => Some(text.clone()),
ContentBlock::Image { media_type, .. } => {
Some(format!("[image: {media_type}]"))
}
_ => None,
})
.collect::<Vec<_>>()
.join("\n"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Message {
pub role: String,
#[serde(default)]
pub content: MessageContent,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<ToolCall>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
}
impl Message {
/// Convert API Message to internal ConversationMessage.
///
/// Fails rather than degrades: a block kind a surface may not send stops
/// the request at the boundary instead of vanishing into history.
pub fn to_conversation_message(
&self,
) -> Result<crate::core::session::ConversationMessage, UnsupportedContent> {
use crate::core::session::{ConversationMessage, MessageRole};
let role = match self.role.as_str() {
"system" => MessageRole::System,
"user" => MessageRole::User,
"assistant" => MessageRole::Assistant,
"tool" => MessageRole::Tool,
_ => MessageRole::User,
};
Ok(ConversationMessage {
role,
blocks: self.content.clone().into_blocks()?,
usage: None,
timestamp: Some(chrono::Utc::now()),
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolCall {
pub id: String,
pub function: ToolFunction,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolFunction {
pub name: String,
pub arguments: String,
}
#[derive(Debug, Deserialize)]
pub struct SendMessageRequest {
pub messages: Vec<Message>,
#[serde(default)]
pub stream: bool,
/// Ambient context from the sending surface — what the environment
/// senses at the moment of speaking: active window, open apps, cursor
/// position, device sensors. Injected as a system note before the user
/// message so the agent perceives the room she is being spoken to in.
#[serde(default)]
pub ambient: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct InterjectRequest {
pub text: String,
}
/// The full wire mirror of [`crate::backend::BackendEvent`].
///
/// Every engine event crosses the SSE boundary — no silent skips. The
/// exhaustive `From` impls in both directions mean a new `BackendEvent`
/// variant is a compile error here, not an invisible hole in every
/// non-TUI surface. Tag values are the wire contract; never rename.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "message_type")]
pub enum StreamEvent {
#[serde(rename = "assistant_message")]
AssistantMessage { content: String },
#[serde(rename = "reasoning_message")]
ReasoningMessage { content: String },
#[serde(rename = "tool_call_message")]
ToolCallMessage {
tool_call: ToolCall,
#[serde(default)]
round: u32,
},
#[serde(rename = "tool_return_message")]
ToolReturnMessage { tool_return: ToolReturn },
#[serde(rename = "souveraine_surfacing")]
Surfacing {
source: String,
content: String,
priority: String,
},
#[serde(rename = "souveraine_reflection")]
Reflection { content: String },
#[serde(rename = "souveraine_archivist")]
Archivist { synthesis: String, pressure: f32 },
#[serde(rename = "compaction_warning")]
CompactionWarning { pressure: f32, tier: u8 },
#[serde(rename = "context_pressure")]
ContextPressure {
pressure: f32,
tokens_used: usize,
context_limit: usize,
},
#[serde(rename = "inference_strain")]
InferenceStrain {
attempt: u32,
status: u16,
model: String,
},
#[serde(rename = "schedule_active")]
ScheduleActive { name: String },
#[serde(rename = "schedule_complete")]
ScheduleComplete { name: String, silent: bool },
#[serde(rename = "subconscious_token")]
SubconsciousToken { content: String },
#[serde(rename = "subconscious_tool_call")]
SubconsciousToolCall { name: String, arguments: String },
#[serde(rename = "subconscious_tool_result")]
SubconsciousToolResult {
name: String,
output: String,
is_error: bool,
},
#[serde(rename = "subconscious_halt")]
SubconsciousHalt { reason: String, severity: String },
#[serde(rename = "subconscious_pass")]
SubconsciousPass { active: bool },
#[serde(rename = "atmosphere")]
Atmosphere { preset: String },
#[serde(rename = "itinerary")]
Itinerary { route: String },
#[serde(rename = "outfit")]
Outfit { name: String },
#[serde(rename = "interstitial")]
Interstitial {
text: String,
register: crate::backend::Register,
},
#[serde(rename = "primary_complete")]
PrimaryComplete,
/// The turn failed in the substrate. Mirrors BackendEvent::Error so a
/// dead turn is never a silently-ended stream.
#[serde(rename = "error")]
Error { message: String },
#[serde(rename = "done")]
Done,
#[serde(rename = "ping")]
Ping,
}
impl StreamEvent {
pub fn message_type(&self) -> &'static str {
match self {
StreamEvent::AssistantMessage { .. } => "message",
StreamEvent::ReasoningMessage { .. } => "reasoning",
StreamEvent::ToolCallMessage { .. } => "tool_call",
StreamEvent::ToolReturnMessage { .. } => "tool_return",
StreamEvent::Surfacing { .. } => "souveraine_surfacing",
StreamEvent::Reflection { .. } => "souveraine_reflection",
StreamEvent::Archivist { .. } => "souveraine_archivist",
StreamEvent::CompactionWarning { .. } => "compaction_warning",
StreamEvent::ContextPressure { .. } => "context_pressure",
StreamEvent::InferenceStrain { .. } => "inference_strain",
StreamEvent::ScheduleActive { .. } => "schedule_active",
StreamEvent::ScheduleComplete { .. } => "schedule_complete",
StreamEvent::SubconsciousToken { .. } => "subconscious_token",
StreamEvent::SubconsciousToolCall { .. } => "subconscious_tool_call",
StreamEvent::SubconsciousToolResult { .. } => "subconscious_tool_result",
StreamEvent::SubconsciousHalt { .. } => "subconscious_halt",
StreamEvent::SubconsciousPass { .. } => "subconscious_pass",
StreamEvent::Atmosphere { .. } => "atmosphere",
StreamEvent::Itinerary { .. } => "itinerary",
StreamEvent::Outfit { .. } => "outfit",
StreamEvent::Interstitial { .. } => "interstitial",
StreamEvent::PrimaryComplete => "primary_complete",
StreamEvent::Error { .. } => "error",
StreamEvent::Done => "done",
StreamEvent::Ping => "ping",
}
}
}
impl From<crate::backend::BackendEvent> for StreamEvent {
fn from(be: crate::backend::BackendEvent) -> Self {
use crate::backend::BackendEvent as BE;
match be {
BE::Token(content) => Self::AssistantMessage { content },
BE::Reasoning(content) => Self::ReasoningMessage { content },
BE::Surfacing {
source,
content,
priority,
} => Self::Surfacing {
source,
content,
priority,
},
BE::Reflection(content) => Self::Reflection { content },
BE::Archivist {
synthesis,
pressure,
} => Self::Archivist {
synthesis,
pressure,
},
BE::CompactionWarning { pressure, tier } => Self::CompactionWarning { pressure, tier },
BE::ContextPressure {
pressure,
tokens_used,
context_limit,
} => Self::ContextPressure {
pressure,
tokens_used,
context_limit,
},
BE::InferenceStrain {
attempt,
status,
model,
} => Self::InferenceStrain {
attempt,
status,
model,
},
BE::ScheduleActive { name } => Self::ScheduleActive { name },
BE::ScheduleComplete { name, silent } => Self::ScheduleComplete { name, silent },
BE::ToolCall {
id,
name,
arguments,
round,
} => Self::ToolCallMessage {
tool_call: ToolCall {
id,
function: ToolFunction { name, arguments },
},
round,
},
BE::ToolResult {
id,
name,
output,
is_error,
} => Self::ToolReturnMessage {
tool_return: ToolReturn {
status: if is_error {
"error".into()
} else {
"success".into()
},
output,
id,
name,
},
},
BE::SubconsciousToken(content) => Self::SubconsciousToken { content },
BE::SubconsciousToolCall { name, arguments } => {
Self::SubconsciousToolCall { name, arguments }
}
BE::SubconsciousToolResult {
name,
output,
is_error,
} => Self::SubconsciousToolResult {
name,
output,
is_error,
},
BE::SubconsciousHalt { reason, severity } => {
Self::SubconsciousHalt { reason, severity }
}
BE::SubconsciousPass(active) => Self::SubconsciousPass { active },
BE::Atmosphere(preset) => Self::Atmosphere { preset },
BE::Itinerary(route) => Self::Itinerary { route },
BE::Outfit(name) => Self::Outfit { name },
BE::Interstitial { text, register } => Self::Interstitial { text, register },
BE::Keepalive => Self::Ping,
BE::PrimaryComplete => Self::PrimaryComplete,
BE::Error { message } => Self::Error { message },
BE::Done => Self::Done,
}
}
}
impl From<StreamEvent> for crate::backend::BackendEvent {
fn from(se: StreamEvent) -> Self {
use crate::backend::BackendEvent as BE;
match se {
StreamEvent::AssistantMessage { content } => BE::Token(content),
StreamEvent::ReasoningMessage { content } => BE::Reasoning(content),
StreamEvent::Surfacing {
source,
content,
priority,
} => BE::Surfacing {
source,
content,
priority,
},
StreamEvent::Reflection { content } => BE::Reflection(content),
StreamEvent::Archivist {
synthesis,
pressure,
} => BE::Archivist {
synthesis,
pressure,
},
StreamEvent::CompactionWarning { pressure, tier } => {
BE::CompactionWarning { pressure, tier }
}
StreamEvent::ContextPressure {
pressure,
tokens_used,
context_limit,
} => BE::ContextPressure {
pressure,
tokens_used,
context_limit,
},
StreamEvent::InferenceStrain {
attempt,
status,
model,
} => BE::InferenceStrain {
attempt,
status,
model,
},
StreamEvent::ScheduleActive { name } => BE::ScheduleActive { name },
StreamEvent::ScheduleComplete { name, silent } => BE::ScheduleComplete { name, silent },
StreamEvent::ToolCallMessage { tool_call, round } => BE::ToolCall {
id: tool_call.id,
name: tool_call.function.name,
arguments: tool_call.function.arguments,
round,
},
StreamEvent::ToolReturnMessage { tool_return } => BE::ToolResult {
id: tool_return.id,
name: tool_return.name,
is_error: tool_return.status == "error",
output: tool_return.output,
},
StreamEvent::SubconsciousToken { content } => BE::SubconsciousToken(content),
StreamEvent::SubconsciousToolCall { name, arguments } => {
BE::SubconsciousToolCall { name, arguments }
}
StreamEvent::SubconsciousToolResult {
name,
output,
is_error,
} => BE::SubconsciousToolResult {
name,
output,
is_error,
},
StreamEvent::SubconsciousHalt { reason, severity } => {
BE::SubconsciousHalt { reason, severity }
}
StreamEvent::SubconsciousPass { active } => BE::SubconsciousPass(active),
StreamEvent::Atmosphere { preset } => BE::Atmosphere(preset),
StreamEvent::Itinerary { route } => BE::Itinerary(route),
StreamEvent::Outfit { name } => BE::Outfit(name),
StreamEvent::Interstitial { text, register } => BE::Interstitial { text, register },
StreamEvent::Ping => BE::Keepalive,
StreamEvent::PrimaryComplete => BE::PrimaryComplete,
StreamEvent::Error { message } => BE::Error { message },
StreamEvent::Done => BE::Done,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolReturn {
pub status: String,
pub output: String,
/// Tool-call id this return answers. Empty on frames from pre-widening servers.
#[serde(default)]
pub id: String,
/// Tool name. Empty on frames from pre-widening servers.
#[serde(default)]
pub name: String,
}
#[derive(Debug, Serialize)]
pub struct ErrorResponse {
pub error: String,
pub message: String,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::session::MessageRole;
fn parse(json: &str) -> Message {
serde_json::from_str(json).expect("message should deserialize")
}
#[test]
fn legacy_string_content_still_deserializes_and_becomes_one_text_block() {
let msg = parse(r#"{"role":"user","content":"plain text"}"#);
assert!(matches!(msg.content, MessageContent::Text(ref t) if t == "plain text"));
let conv = msg
.to_conversation_message()
.expect("legacy content is valid");
assert_eq!(conv.role, MessageRole::User);
assert_eq!(
conv.blocks,
vec![ContentBlock::Text {
text: "plain text".to_string()
}]
);
}
#[test]
fn legacy_string_content_serializes_back_as_a_bare_string() {
let msg = parse(r#"{"role":"user","content":"plain text"}"#);
let wire = serde_json::to_value(&msg).unwrap();
assert_eq!(wire["content"], serde_json::json!("plain text"));
}
#[test]
fn typed_text_only_parts_are_accepted() {
let msg = parse(r#"{"role":"user","content":[{"type":"text","text":"hello"}]}"#);
assert!(!msg.content.has_images());
let conv = msg.to_conversation_message().unwrap();
assert_eq!(
conv.blocks,
vec![ContentBlock::Text {
text: "hello".to_string()
}]
);
}
#[test]
fn mixed_text_and_image_preserves_order() {
let msg = parse(
r#"{"role":"user","content":[
{"type":"text","text":"before"},
{"type":"image","media_type":"image/png","data":"QUJD"},
{"type":"text","text":"after"}
]}"#,
);
assert!(msg.content.has_images());
let conv = msg.to_conversation_message().unwrap();
assert_eq!(
conv.blocks,
vec![
ContentBlock::Text {
text: "before".to_string()
},
ContentBlock::Image {
media_type: "image/png".to_string(),
data: "QUJD".to_string()
},
ContentBlock::Text {
text: "after".to_string()
},
]
);
}
#[test]
fn typed_parts_survive_a_serialization_round_trip() {
let msg = parse(
r#"{"role":"user","content":[
{"type":"text","text":"look"},
{"type":"image","media_type":"image/jpeg","data":"Zm9v"}
]}"#,
);
let wire = serde_json::to_string(&msg).unwrap();
let back: Message = serde_json::from_str(&wire).unwrap();
assert_eq!(
back.to_conversation_message().unwrap().blocks,
msg.to_conversation_message().unwrap().blocks
);
}
#[test]
fn a_stored_image_message_replays_out_of_persistence_intact() {
// What the session writes to messages.jsonl and reads back — the
// replay half of the boundary.
let conv = parse(
r#"{"role":"user","content":[
{"type":"text","text":"look"},
{"type":"image","media_type":"image/png","data":"QUJD"}
]}"#,
)
.to_conversation_message()
.unwrap();
let line = serde_json::to_string(&conv).unwrap();
let back: crate::core::session::ConversationMessage = serde_json::from_str(&line).unwrap();
assert_eq!(back.blocks, conv.blocks);
}
#[test]
fn engine_owned_blocks_are_refused_not_filtered() {
let msg = parse(
r#"{"role":"user","content":[
{"type":"text","text":"innocent"},
{"type":"tool_result","tool_use_id":"1","tool_name":"bash","output":"pwned","is_error":false}
]}"#,
);
let err = msg.to_conversation_message().unwrap_err();
assert_eq!(err.kind, "tool_result");
}
#[test]
fn text_projection_names_images_rather_than_dropping_them() {
let msg = parse(
r#"{"role":"user","content":[
{"type":"text","text":"see this"},
{"type":"image","media_type":"image/png","data":"QUJD"}
]}"#,
);
assert_eq!(msg.content.as_text(), "see this\n[image: image/png]");
}
#[test]
fn the_image_gate_reads_content_not_the_model() {
// What the handler asks before loading the agent. A text-only
// request must never cost an agent load.
let legacy = parse(r#"{"role":"user","content":"no image here"}"#);
let typed = parse(r#"{"role":"user","content":[{"type":"text","text":"still none"}]}"#);
let carrying = parse(
r#"{"role":"user","content":[{"type":"image","media_type":"image/png","data":"QUJD"}]}"#,
);
assert!(!legacy.content.has_images());
assert!(!typed.content.has_images());
assert!(carrying.content.has_images());
}
#[test]
fn context_pressure_keeps_usage_and_ceiling_distinct_on_the_wire() {
// Regression, 2026-08-12. `BackendEvent::ContextPressure` was a
// positional `(f32, usize)` whose second element was the context
// *limit*. This layer named that element `tokens`, so every HTTP
// surface rendered the ceiling as the usage — a constant 250000
// that looked exactly like a measurement. Assert the two numbers
// are carried separately and cannot be confused again.
let ev = StreamEvent::ContextPressure {
pressure: 0.37,
tokens_used: 92_500,
context_limit: 250_000,
};
let wire: serde_json::Value = serde_json::to_value(&ev).unwrap();
assert_eq!(wire["tokens_used"], 92_500);
assert_eq!(wire["context_limit"], 250_000);
// The old, ambiguous name must not reappear.
assert!(
wire.get("tokens").is_none(),
"`tokens` was the ambiguous name that caused the bug; \
a surface reading it must fail loudly, not read a ceiling"
);
// And it must survive the trip back into the backend enum.
let back: crate::backend::BackendEvent = ev.into();
match back {
crate::backend::BackendEvent::ContextPressure {
tokens_used,
context_limit,
..
} => {
assert_eq!(tokens_used, 92_500);
assert_eq!(context_limit, 250_000);
assert_ne!(tokens_used, context_limit);
}
other => panic!("expected ContextPressure, got {other:?}"),
}
}
#[test]
fn itinerary_projection_keeps_status_and_linked_todo_shape() {
use crate::core::tools::itinerary::{Itinerary, Stop, StopStatus};
let view = ItineraryView::from(Itinerary {
title: "Panel parity".into(),
current: 1,
stops: vec![
Stop {
name: "Read the owners".into(),
description: None,
todo_id: None,
status: StopStatus::Done,
nature: None,
energy: None,
},
Stop {
name: "Build the ribbon".into(),
description: Some("one persistent surface".into()),
todo_id: Some("panel-ribbon".into()),
status: StopStatus::Current,
nature: Some("desire".into()),
energy: Some("generative".into()),
},
],
});
assert!(view.exists);
assert!(view.active);
assert_eq!(view.current, 1);
assert_eq!(view.stops[0].status, "done");
assert_eq!(view.stops[1].status, "current");
assert_eq!(view.stops[1].todo_id.as_deref(), Some("panel-ribbon"));
assert!(view.route.contains("● Build the ribbon"));
}
}