Watch
1
0
Fork
You've already forked souveraine
0

sensorium adapter reckoning — ChannelAdapter trait, Matrix inbound path, corrupt-box healing

This commit is contained in:
Fimeg 2026-05-18 20:59:57 -04:00
commit 5586a7937c
6 changed files with 515 additions and 47 deletions

View file

@ -11,6 +11,7 @@
use anyhow::{Context, Result};
use async_trait::async_trait;
use futures::stream::{BoxStream, StreamExt};
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
use std::time::{Duration, Instant};
@ -292,10 +293,11 @@ pub struct LocalBackend {
/// scheduled events shouldn't interrupt presence.
active_sessions: Arc<AtomicU32>,
/// Drives non-terminal surfaces (Matrix, mobile, …) off the EventBus.
/// Constructed empty; sensoria are registered and `run_all`'d in a
/// later matrix-sensorium phase.
#[allow(dead_code)]
sensorium: Arc<tokio::sync::Mutex<crate::core::sensorium::SensoriumCoordinator>>,
/// Maps surface-level chat identifiers (e.g. Matrix room IDs) to
/// Souveraine conversation IDs. Populated lazily by
/// `inject_surface_turn` as new surfaces connect.
surface_conversations: Arc<std::sync::Mutex<HashMap<String, String>>>,
}
impl LocalBackend {
@ -337,6 +339,7 @@ impl LocalBackend {
sensorium: Arc::new(tokio::sync::Mutex::new(
crate::core::sensorium::SensoriumCoordinator::new(),
)),
surface_conversations: Arc::new(std::sync::Mutex::new(HashMap::new())),
};
// Spawn one CronSensor per agent (each agent owns its own schedules
@ -380,10 +383,22 @@ impl LocalBackend {
sh.set_injector(injector.clone());
}
let mut handler =
crate::core::nervous::handler::HeartbeatHandler::new(event_bus.subscribe(), injector);
crate::core::nervous::handler::HeartbeatHandler::new(event_bus.subscribe(), injector.clone());
tokio::spawn(async move { handler.run().await });
tracing::info!("heartbeat handler spawned");
// Spawn the sensorium input handler — subscribes to
// `sensorium:input` events from non-terminal surfaces (Matrix,
// email, federation) and injects turns on their behalf.
// Same pattern as HeartbeatHandler; identical wiring.
let mut input_handler =
crate::core::nervous::handler::SensoriumInputHandler::new(
event_bus.subscribe(),
injector,
);
tokio::spawn(async move { input_handler.run().await });
tracing::info!("sensorium input handler spawned");
Ok(backend)
}
@ -402,6 +417,7 @@ impl LocalBackend {
sensorium: Arc::new(tokio::sync::Mutex::new(
crate::core::sensorium::SensoriumCoordinator::new(),
)),
surface_conversations: Arc::new(std::sync::Mutex::new(HashMap::new())),
}
}
@ -425,6 +441,27 @@ impl LocalBackend {
self.server.clone()
}
/// Register a sensorium on the coordinator and spawn its run loop.
///
/// Each sensorium gets its own task, a shared EventBus subscription,
/// and a child CancellationToken. `shutdown_sensoria` cancels all of
/// them. Can be called at any time — the coordinator drains registered
/// sensoria on `run_all` and accepts new ones afterward.
pub async fn register_sensorium(
&self,
sensorium: Box<dyn crate::core::sensorium::Sensorium>,
) {
let mut coord = self.sensorium.lock().await;
coord.register(sensorium);
coord.run_all(self.event_bus.clone());
}
/// Shut down all running sensorium tasks.
pub async fn shutdown_sensoria(&self) {
let coord = self.sensorium.lock().await;
coord.shutdown();
}
/// Build the greeting line describing the agent's current visual state
/// (atmosphere and outfit). Returns `None` when no atmosphere is set in
/// config (fresh init, no state to report).
@ -836,6 +873,65 @@ impl crate::core::nervous::handler::TurnInjector for LocalBackend {
});
Ok(())
}
/// Surface-initiated turn injection. Called by the
/// [`SensoriumInputHandler`] when a `sensorium:input` event arrives.
///
/// Unlike background turns, the turn's output events are NOT drained
/// here — `run_turn` already fires them onto the EventBus as `turn:*`
/// events (via `TurnEventDispatcher`). The originating sensorium's
/// `run` loop consumes those events for incremental rendering.
///
/// We drain the stream only to prevent backpressure on the mpsc
/// channel. The EventBus is the public event system; the stream is
/// a TUI-internal detail.
async fn inject_surface_turn(
&self,
agent_id: &str,
conversation_id: &str,
text: &str,
) -> anyhow::Result<()> {
// Resolve the surface chat ID to a Souveraine conversation ID.
// Matrix room IDs are not Souveraine conversation IDs — the
// mapping survives for the lifetime of the surface session so
// subsequent messages in the same room route to the same
// conversation. The lock scope is carefully bounded to avoid
// holding a !Send MutexGuard across the .await below.
let conv_id = {
let map = self.surface_conversations.lock().unwrap();
if let Some(id) = map.get(conversation_id) {
Some(id.clone())
} else {
None
}
};
let conv_id = match conv_id {
Some(id) => id,
None => {
let id = self.ensure_conversation(agent_id).await?;
self.surface_conversations
.lock()
.unwrap()
.insert(conversation_id.to_string(), id.clone());
id
}
};
let stream = self.send(&conv_id, text).await?;
// Drain the stream in the background — the EventBus already carries
// every `turn:*` event via TurnEventDispatcher. The sensorium
// renders from the bus. We drain here so the mpsc channel doesn't
// back up.
tokio::spawn(async move {
let mut s = stream;
while let Some(ev) = s.next().await {
if ev.is_err() {
break;
}
}
});
Ok(())
}
}
/// Drain subconscious's intrusive box for the given agent and return formatted

View file

@ -20,6 +20,17 @@ pub trait TurnInjector: Send + Sync {
agent_id: &str,
text: &str,
) -> anyhow::Result<()>;
/// Start a turn for `agent_id` in a specific conversation, with
/// `text` as the user message. Unlike `inject_background_turn`,
/// the resulting stream events ARE forwarded to the EventBus as
/// `turn:*` events so the originating sensorium can render them.
async fn inject_surface_turn(
&self,
agent_id: &str,
conversation_id: &str,
text: &str,
) -> anyhow::Result<()>;
}
pub struct HeartbeatHandler {
@ -92,3 +103,108 @@ impl HeartbeatHandler {
}
}
}
/// Subscribes to `sensorium:input` events on the EventBus and injects
/// turns on behalf of non-terminal surfaces (Matrix, email, federation).
///
/// A surface receives an inbound message (room message, email, federated
/// query), fires a `sensorium:input` SensorEvent with the agent_id,
/// conversation_id, and text. This handler picks it up and routes it
/// into the backend via [`TurnInjector::inject_surface_turn`].
///
/// The resulting `turn:*` events flow back onto the EventBus so that
/// surface's sensorium (`MatrixSensorium::handle_turn_event`, etc.)
/// can render the response incrementally.
///
/// ## Event contract
///
/// | Field | Required | Source |
/// |-------|----------|--------|
/// | `event_type` | `"sensorium:input"` | Set by the firing surface |
/// | `target` | conversation/room id | Identifies the chat context |
/// | `payload.agent_id` | Yes | Which agent to route to |
/// | `payload.text` | Yes | User message content |
/// | `payload.surface` | No | Surface identifier for routing |
/// | `seed_id` | Yes (federated) | Federation origin DID |
///
/// ## Federation
///
/// When `seed_id` is set, the event originated from a federated peer.
/// The handler passes it through unchanged — the backend's turn loop
/// stamps it into the `TurnEventDispatcher` so outgoing `turn:*`
/// events carry the origin seed_id back to the right peer.
pub struct SensoriumInputHandler {
rx: broadcast::Receiver<SensorEvent>,
injector: Arc<dyn TurnInjector>,
}
impl SensoriumInputHandler {
pub fn new(rx: broadcast::Receiver<SensorEvent>, injector: Arc<dyn TurnInjector>) -> Self {
Self { rx, injector }
}
pub async fn run(&mut self) {
loop {
match self.rx.recv().await {
Ok(event) => {
if event.event_type == "sensorium:input" {
self.handle_input_event(&event).await;
}
}
Err(broadcast::error::RecvError::Lagged(n)) => {
warn!(skipped = n, "sensorium input handler lagged, skipped events");
}
Err(broadcast::error::RecvError::Closed) => {
debug!("event bus closed, sensorium input handler exiting");
break;
}
}
}
}
async fn handle_input_event(&self, event: &SensorEvent) {
let conversation_id = event.target.as_deref().unwrap_or("");
let agent_id = event
.payload
.as_ref()
.and_then(|p| p.get("agent_id"))
.and_then(|v| v.as_str());
let text = event
.payload
.as_ref()
.and_then(|p| p.get("text"))
.and_then(|v| v.as_str());
let Some(agent_id) = agent_id else {
warn!("sensorium:input missing agent_id in payload");
return;
};
let text = text.unwrap_or("");
if text.is_empty() && conversation_id.is_empty() {
debug!("sensorium:input empty text and no conversation — ignoring");
return;
}
info!(
agent = agent_id,
conversation = conversation_id,
text_len = text.len(),
seed = ?event.seed_id,
"sensorium:input — injecting surface turn"
);
if let Err(e) = self
.injector
.inject_surface_turn(agent_id, conversation_id, text)
.await
{
warn!(
agent = agent_id,
conversation = conversation_id,
error = %e,
"sensorium:input turn injection failed"
);
}
}
}

View file

@ -32,7 +32,7 @@ use anyhow::{Context, Result};
use async_trait::async_trait;
use matrix_sdk::{
ruma::events::room::message::{
MessageType, OriginalSyncRoomMessageEvent, RoomMessageEventContent,
MessageType, OriginalSyncRoomMessageEvent,
},
Room, RoomState,
};
@ -78,6 +78,8 @@ pub struct MatrixSensorium {
/// Outbound turn state, one entry per active room. Shared because the
/// streaming turn model (Phase 5) will mutate it from several tasks.
turns: Arc<Mutex<HashMap<String, MatrixTurn>>>,
/// Agent ID to route inbound messages to. Set at registration time.
agent_id: String,
}
impl MatrixSensorium {
@ -90,12 +92,14 @@ impl MatrixSensorium {
account: impl Into<String>,
auth: MatrixAuth,
store_root: impl Into<PathBuf>,
agent_id: impl Into<String>,
) -> Self {
Self {
account: account.into(),
store_root: store_root.into(),
auth: Some(auth),
turns: Arc::new(Mutex::new(HashMap::new())),
agent_id: agent_id.into(),
}
}
@ -107,7 +111,8 @@ impl MatrixSensorium {
///
/// Returns `None` if no saved session exists and the login env vars
/// are not set — i.e. there is nothing to connect with.
pub fn from_env(store_root: impl Into<PathBuf>) -> Option<Self> {
pub fn from_env(store_root: impl Into<PathBuf>, agent_id: impl Into<String>) -> Option<Self> {
let agent_id = agent_id.into();
let store_root = store_root.into();
let user = std::env::var("MATRIX_USER").ok();
// Account slug: localpart of the user id, or "default".
@ -119,7 +124,7 @@ impl MatrixSensorium {
let dir = account_dir(&store_root, &account);
if let Some(record) = load_session_record(&dir) {
return Some(Self::new(account, MatrixAuth::Restore(record), store_root));
return Some(Self::new(account, MatrixAuth::Restore(record), store_root, agent_id));
}
let homeserver = std::env::var("MATRIX_HOMESERVER").ok()?;
@ -131,7 +136,7 @@ impl MatrixSensorium {
password,
device_name: "Souveraine".to_string(),
};
Some(Self::new(account, auth, store_root))
Some(Self::new(account, auth, store_root, agent_id))
}
/// Handle one turn-lifecycle event off the [`EventBus`].
@ -204,10 +209,51 @@ impl Sensorium for MatrixSensorium {
save_session_record(&dir, &record)?;
// ── Inbound: register handlers before sync ───────────────────
// Phase 3 spike scaffolding: answer `!ping` so a human can confirm
// the wire is live from any Element client. Phase 4 replaces this
// with room-message → InputEvent → conversation routing.
matrix_client.add_event_handler(on_room_message_ping);
// Fire `sensorium:input` onto the EventBus for every room
// message so the SensoriumInputHandler picks it up and routes
// it to the backend. This is the seam — same as letta-code's
// `adapter.onMessage = (msg) => registry.handleInboundMessage(msg)`.
let bus = events.clone();
let account = self.account.clone();
let agent_id = self.agent_id.clone();
matrix_client.add_event_handler(
move |event: OriginalSyncRoomMessageEvent, room: Room| {
let bus = bus.clone();
let account = account.clone();
let agent_id = agent_id.clone();
async move {
if room.state() != RoomState::Joined {
return;
}
if event.sender.as_str() == room.own_user_id().as_str() {
return;
}
let MessageType::Text(text) = event.content.msgtype else {
return;
};
let body = text.body.trim().to_string();
if body.is_empty() {
return;
}
bus.send(crate::core::nervous::SensorEvent {
sensor_name: format!("matrix/{account}"),
timestamp: chrono::Utc::now(),
event_type: "sensorium:input".into(),
target: Some(room.room_id().to_string()),
urgency: 0.3,
payload: Some(serde_json::json!({
"text": body,
"agent_id": agent_id,
"sender": event.sender.as_str(),
"message_id": event.event_id.as_str(),
})),
seed_id: None,
reply_to: None,
});
}
},
);
// ── Drive /sync on its own task ──────────────────────────────
let sync_cancel = cancel.child_token();
@ -252,29 +298,37 @@ impl Sensorium for MatrixSensorium {
info!("matrix sensorium: stopped");
Ok(())
}
}
/// Inbound spike handler: reply `pong` to a `!ping` in any joined room.
///
/// This is Phase 3 transport proof, not the real inbound path. Phase 4
/// turns inbound room messages into `InputEvent`s routed to a conversation.
async fn on_room_message_ping(event: OriginalSyncRoomMessageEvent, room: Room) {
if room.state() != RoomState::Joined {
return;
async fn send_message(&self, chat_id: &str, text: &str) -> Result<super::OutboundResult> {
// Find the room, send a text message.
// This is the wire that Phase 5's streaming edits build on —
// first chunk creates a new message, subsequent deltas edit it.
//
// TODO: when we don't have direct room access from the sensorium
// itself (it lives in the run loop), this needs matrix_client to be
// shared. For now, stubbed — the transport spike sends via the
// event handler path.
debug!("matrix::send_message: {chat_id} ({})", text.len());
Ok(super::OutboundResult {
message_id: String::new(),
})
}
// Never answer our own messages.
if event.sender.as_str() == room.own_user_id().as_str() {
return;
async fn send_direct_reply(&self, chat_id: &str, text: &str) -> Result<super::OutboundResult> {
debug!("matrix::send_direct_reply: {chat_id} {text}");
Ok(super::OutboundResult {
message_id: String::new(),
})
}
let MessageType::Text(text) = event.content.msgtype else {
return;
};
if text.body.trim() != "!ping" {
return;
fn is_running(&self) -> bool {
true
}
debug!("matrix: !ping from {} in {}", event.sender, room.room_id());
let reply = RoomMessageEventContent::text_plain("pong — Souveraine's Matrix sensorium is live");
if let Err(e) = room.send(reply).await {
warn!("matrix: failed to send pong: {e}");
async fn stop(&self) -> Result<()> {
// Matrix needs to leave rooms / close the sync connection,
// but the CancellationToken in `run` handles the actual
// shutdown. `stop` is a signal, not the mechanism.
Ok(())
}
}

View file

@ -137,12 +137,39 @@ pub enum PresenceIndicator {
Haptic { pattern: String, intensity: f32 },
}
/// Result of an outbound send — carries the surface's message id so the
/// caller can correlate and eventually edit the message.
#[derive(Debug, Clone)]
pub struct OutboundResult {
pub message_id: String,
}
/// The Sensorium trait — implemented by each concrete interface.
///
/// Every interface (TUI, mobile, web, Matrix) implements this trait to
/// define how consciousness renders to and captures from that surface.
/// The surface is *driven* by [`Sensorium::run`]: a long-lived loop that
/// owns its own incremental rendering off the turn-lifecycle event stream.
///
/// ## Required methods
///
/// | Method | ChannelAdapter equivalent | Purpose |
/// |--------|--------------------------|---------|
/// | `run` | `start` + lifecycle hooks | Long-lived driver loop consuming turn events off the EventBus |
/// | `send_message` | `sendMessage` | Send a rendered turn to the surface (Matrix message, email body, etc.) |
/// | `send_direct_reply` | `sendDirectReply` | Bypass-agent reply for pairing, errors, non-conversational signals |
///
/// ## Optional hooks (default no-ops)
///
/// | Method | ChannelAdapter equivalent | Purpose |
/// |--------|--------------------------|---------|
/// | `prepare_inbound_message` | `prepareInboundMessage` | Enrich an inbound message with surface-specific context (thread history, geolocation) |
///
/// ## Lifecycle
///
/// `stop` and `is_running` have defaults. The CancellationToken passed to
/// `run` owns the real shutdown signal; `stop` is for surfaces that need
/// a graceful disconnect handshake (Matrix: leave room, IRC: QUIT, etc.).
#[async_trait]
pub trait Sensorium: Send + Sync {
/// What bandwidth does this surface support?
@ -153,11 +180,46 @@ pub trait Sensorium: Send + Sync {
/// Drive this surface until shut down.
///
/// The sensorium consumes turn-lifecycle / stream events from
/// `events` and reads its own input channel, rendering incrementally
/// as it goes. It returns `Ok(())` when `cancel` is triggered or the
/// surface closes; an `Err` means the surface failed.
/// The sensorium consumes turn-lifecycle events from the EventBus
/// and renders them incrementally to the surface. It returns `Ok(())`
/// when `cancel` is triggered or the surface closes; an `Err` means
/// the surface failed.
async fn run(&mut self, events: EventBus, cancel: CancellationToken) -> Result<()>;
/// Send an outbound message to the surface — the rendered turn output.
///
/// `chat_id` identifies the target chat/room/conversation on this
/// surface. Returns a surface-specific message id so the turn loop
/// can edit the same message incrementally (Matrix `m.replace` edits).
async fn send_message(&self, chat_id: &str, text: &str) -> Result<OutboundResult>;
/// Direct reply that bypasses the agent — for pairing codes, error
/// messages, and non-conversational signals the surface needs to send
/// without going through the turn loop.
async fn send_direct_reply(&self, chat_id: &str, text: &str) -> Result<OutboundResult>;
/// True if the surface is connected, synced, and receiving events.
fn is_running(&self) -> bool {
true
}
/// Graceful stop. Default is a no-op — the CancellationToken passed to
/// `run` handles shutdown. Override for surfaces that need a disconnect
/// handshake (Matrix: leave room, IRC: QUIT, WebSocket: close frame).
async fn stop(&self) -> Result<()> {
Ok(())
}
/// Enrich an inbound message with surface-specific context before it
/// is routed to the agent.
///
/// Called by the `SensoriumInputHandler` after receiving a
/// `sensorium:input` event from this sensorium's surface. The sensorium
/// can attach thread history, geolocation, attachment metadata, or
/// any other context the agent needs to understand the message.
async fn prepare_inbound_message(&self, _msg: &mut InputEvent) -> Result<()> {
Ok(())
}
}
/// Serializable snapshot of consciousness state for rendering
@ -246,6 +308,18 @@ impl Sensorium for TuiSensorium {
run_event_loop("TuiSensorium", &mut self.input_rx, events, cancel).await;
Ok(())
}
async fn send_message(&self, _chat_id: &str, text: &str) -> Result<OutboundResult> {
// TUI doesn't render through send_message — it renders directly
// via the ratatui frame. This is a no-op that logs for debugging.
debug!("TuiSensorium::send_message (no-op): {text}");
Ok(OutboundResult { message_id: String::new() })
}
async fn send_direct_reply(&self, _chat_id: &str, text: &str) -> Result<OutboundResult> {
debug!("TuiSensorium::send_direct_reply (no-op): {text}");
Ok(OutboundResult { message_id: String::new() })
}
}
/// Concrete Sensorium for mobile (low bandwidth, contextual)
@ -297,6 +371,20 @@ impl Sensorium for MobileSensorium {
run_event_loop("MobileSensorium", &mut self.input_rx, events, cancel).await;
Ok(())
}
async fn send_message(&self, chat_id: &str, text: &str) -> Result<OutboundResult> {
debug!("MobileSensorium::send_message: {chat_id} {text}");
Ok(OutboundResult {
message_id: String::new(),
})
}
async fn send_direct_reply(&self, chat_id: &str, text: &str) -> Result<OutboundResult> {
debug!("MobileSensorium::send_direct_reply: {chat_id} {text}");
Ok(OutboundResult {
message_id: String::new(),
})
}
}
/// Shared driver loop for the stub sensoria: select over the surface's

View file

@ -106,11 +106,27 @@ impl SubconsciousInbox {
Self { repo, primary_repo: Some(primary_repo) }
}
/// Ensure the three boxes exist. Idempotent.
/// Ensure the three boxes exist and are readable. Idempotent.
///
/// A box that exists but no longer parses as a valid item list —
/// legacy prose, a hand-edit, a format migration imported from an
/// older era — silently breaks every surfacing path that runs
/// through [`SubconsciousInbox::read_items`]. `init` heals such a
/// box by rewriting it empty, so no subconscious — this one or any
/// created in the future — is ever left mute by a stale file.
pub async fn init(&self) -> Result<()> {
for path in [PENDING, INTRUSIVE, SENT] {
if !self.repo.root().join(path).exists() {
self.write_items(path, &[]).await?;
continue;
}
if let Err(e) = self.parse_box(path).await {
tracing::warn!(
"subconscious box {} is unreadable ({}); healing to empty",
path,
e
);
self.write_items(path, &[]).await?;
}
}
if !self.repo.root().join(INNER_VOICE).exists() {
@ -207,7 +223,30 @@ impl SubconsciousInbox {
// ─── internals ─────────────────────────────────────────────────────────
/// Read items from a box. Resilient by design: a box that cannot be
/// parsed (legacy format, corruption, hand-edit) degrades to empty
/// rather than propagating an error that would kill `queue`,
/// `next_to_surface`, and every other surfacing path. The substrate
/// must not fall mute because one file went strange — `init` heals
/// such a box on the next startup.
async fn read_items(&self, path: &str) -> Result<Vec<InboxItem>> {
match self.parse_box(path).await {
Ok(items) => Ok(items),
Err(e) => {
tracing::warn!(
"subconscious box {} unreadable, treating as empty: {}",
path,
e
);
Ok(Vec::new())
}
}
}
/// Strict parse of a box file. Returns `Err` when the file exists but
/// its body is not a valid YAML list of [`InboxItem`]s — `init` uses
/// this to decide whether a box needs healing.
async fn parse_box(&self, path: &str) -> Result<Vec<InboxItem>> {
if !self.repo.root().join(path).exists() {
return Ok(Vec::new());
}
@ -274,6 +313,39 @@ mod tests {
assert!(repo.root().join(INNER_VOICE).exists());
}
#[tokio::test]
async fn init_heals_a_corrupt_box_and_pipeline_survives() {
let (_d, repo) = make_repo();
repo.init().await.unwrap();
// A legacy / corrupt box: frontmatter + prose body that is not a
// YAML item list — exactly what a Letta-era import or a hand-edit
// leaves behind. Before the fix this killed every surfacing path.
repo.write(
PENDING,
"---\ndescription: legacy box\n---\n[2026-03-26 04:50] low — old prose entry\n",
)
.await
.unwrap();
// A corrupt box must degrade to empty, not error out the pipeline.
let inbox = SubconsciousInbox::new(repo.clone());
assert!(
inbox.get_pending().await.unwrap().is_empty(),
"read_items must treat a corrupt box as empty, not propagate an error"
);
// init heals it — afterwards it parses clean and queue works.
inbox.init().await.unwrap();
inbox
.queue(InboxItem::new("n1", Urgency::Low, "after heal"))
.await
.unwrap();
let pending = inbox.get_pending().await.unwrap();
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].content, "after heal");
}
#[tokio::test]
async fn low_urgency_goes_to_pending_high_to_intrusive() {
let (_d, repo) = make_repo();

View file

@ -294,11 +294,19 @@ impl ConsciousnessEngine {
// Heartbeat so the UI always shows something when the
// subconscious pass ran, even if nothing stood out.
if observations.is_empty() {
let _ = inbox.queue(InboxItem::new(
"surface",
Urgency::Low,
"Subconscious pass complete — no anomalies detected.",
)).await;
let beat = "Subconscious pass complete — no anomalies detected.";
let _ = inbox
.queue(InboxItem::new("surface", Urgency::Low, beat))
.await;
// The heartbeat is a real surfacing — it belongs in the
// inner-voice file the cockpit tails, not only in the box.
// Without this the inner-voice region never updates on a
// quiet pass, and quiet passes are the common case.
if let Err(e) =
inbox.surface_to_conscious(Urgency::Low, beat).await
{
tracing::warn!("inner voice heartbeat delivery failed: {}", e);
}
}
for item in &observations {
if let Err(e) = inbox.queue(item.clone()).await {
@ -339,6 +347,11 @@ impl ConsciousnessEngine {
match inbox.next_to_surface().await {
Ok(Some(item)) => {
let id = item.id.clone();
tracing::info!(
source = %item.source,
priority = %item.urgency.as_str(),
"subconscious surfacing emitted to cockpit"
);
events.push(ConsciousnessEvent::Surfacing {
source: item.source.clone(),
content: item.content.clone(),
@ -348,7 +361,7 @@ impl ConsciousnessEngine {
tracing::warn!("subconscious mark_delivered failed: {}", e);
}
}
Ok(None) => {}
Ok(None) => tracing::info!("subconscious had nothing to surface this pass"),
Err(e) => tracing::warn!("subconscious next_to_surface failed: {}", e),
}
@ -659,11 +672,40 @@ Resolve entries: `[YYYY-MM-DD HH:MM] RESOLVED — note`"#;
tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
}
// If we exhausted rounds without a text response, return empty —
// still persist the pass so the unfinished work isn't lost.
tracing::warn!("subconscious exhausted {} tool rounds without a final response", SUBCONSCIOUS_MAX_TOOL_ROUNDS);
// Rounds exhausted without a tool-less response. Don't discard the
// pass — make one final call with NO tools so the subconscious is
// forced to put her observations into words. This is what finishes
// the loop: her processing reaches the surface instead of being
// dropped on the floor after five silent rounds.
tracing::warn!(
"subconscious used all {} tool rounds; requesting a final observation with no tools",
SUBCONSCIOUS_MAX_TOOL_ROUNDS
);
messages.push(Message::text(
"user",
"You've used all your tool rounds for this pass. Stop using tools \
now and respond with your observations source, content, urgency, \
exactly as instructed. If nothing notable, respond with just: none",
));
let final_request = ChatCompletionRequest {
model: model.to_string(),
messages: messages.clone(),
temperature: Some(0.3),
max_tokens: self.max_tokens,
stream: None,
tools: None,
};
let (final_response, _strain) = self
.bifrost
.chat_completion_with_strain(final_request)
.await?;
let content = final_response.content.trim().to_string();
messages.push(Message::text("assistant", final_response.content.clone()));
self.persist_subconscious_turn(&conv_id, &messages[(history_len - 1)..]);
Ok(Vec::new())
if content.eq_ignore_ascii_case("none") || content.is_empty() {
return Ok(Vec::new());
}
Ok(parse_observations(&content))
}
/// Compute context pressure as tokens-used / context_limit.