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.
1061 lines
37 KiB
Rust
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"));
|
|
}
|
|
}
|