From 38c8cd49b72cc3d501204cab564d70ac710bd92a Mon Sep 17 00:00:00 2001 From: James Pine Date: Sun, 8 Mar 2026 21:41:48 -0700 Subject: [PATCH 1/3] feat: add system config self-awareness to channel status block Add SystemInfo struct that gives channels awareness of their own configuration (models, context window, capabilities, MCP servers, warmup state, cron jobs, version, deployment). Move worker time context from task message into system prompt template. Add prompt inspect API endpoint (GET /api/channels/inspect). Add verify-version job to release workflow. Remove dead worker_time_context template. --- .github/workflows/release.yml | 21 + .../system/worker_time_context.md.j2 | 4 - prompts/en/worker.md.j2 | 4 + src/agent/channel.rs | 29 +- src/agent/channel_dispatch.rs | 37 +- src/agent/channel_history.rs | 36 +- src/agent/channel_prompt.rs | 19 - src/agent/cortex.rs | 10 + src/agent/status.rs | 397 +++++++++++++++++- src/api/channels.rs | 121 ++++++ src/api/server.rs | 1 + src/cron/scheduler.rs | 5 + src/prompts/engine.rs | 21 +- src/prompts/text.rs | 4 - src/tools/spawn_worker.rs | 10 + tests/context_dump.rs | 2 + 16 files changed, 630 insertions(+), 91 deletions(-) delete mode 100644 prompts/en/fragments/system/worker_time_context.md.j2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 26be569e3..292e76c4f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,7 +15,26 @@ env: IMAGE: ghcr.io/${{ github.repository_owner }}/spacebot jobs: + verify-version: + if: startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + + - name: Verify Cargo.toml version matches tag + run: | + TAG="${GITHUB_REF#refs/tags/v}" + CARGO_VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/') + echo "Git tag version: $TAG" + echo "Cargo.toml version: $CARGO_VERSION" + if [ "$TAG" != "$CARGO_VERSION" ]; then + echo "::error::Version mismatch! Git tag is v${TAG} but Cargo.toml has version ${CARGO_VERSION}. Bump the version in Cargo.toml before tagging." + exit 1 + fi + build-binaries: + needs: [verify-version] + if: always() && (needs.verify-version.result == 'success' || needs.verify-version.result == 'skipped') strategy: matrix: include: @@ -103,6 +122,8 @@ jobs: retention-days: 1 build-docker: + needs: [verify-version] + if: always() && (needs.verify-version.result == 'success' || needs.verify-version.result == 'skipped') strategy: matrix: include: diff --git a/prompts/en/fragments/system/worker_time_context.md.j2 b/prompts/en/fragments/system/worker_time_context.md.j2 deleted file mode 100644 index fc06944c5..000000000 --- a/prompts/en/fragments/system/worker_time_context.md.j2 +++ /dev/null @@ -1,4 +0,0 @@ -## Time Context -- Current local date/time: {{ current_local_datetime }} -- Current UTC date/time: {{ current_utc_datetime }} -- Use this context for relative dates (today/tomorrow/yesterday/now) and include absolute dates when timing matters. diff --git a/prompts/en/worker.md.j2 b/prompts/en/worker.md.j2 index 383593834..2d5e521f7 100644 --- a/prompts/en/worker.md.j2 +++ b/prompts/en/worker.md.j2 @@ -140,6 +140,10 @@ Example uses: Do not log or echo the secret value after storing it. +{%- if status_text %} +{{ status_text }} +{%- endif %} + ## Rules 1. Do the work. Don't describe what you would do — use the tools and do it. diff --git a/src/agent/channel.rs b/src/agent/channel.rs index 0ae72f4e8..14e488a33 100644 --- a/src/agent/channel.rs +++ b/src/agent/channel.rs @@ -13,7 +13,7 @@ use crate::agent::channel_prompt::{ }; use crate::agent::compactor::Compactor; use crate::agent::process_control::ControlActionResult; -use crate::agent::status::StatusBlock; +use crate::agent::status::{StatusBlock, SystemInfo}; use crate::agent::worker::Worker; use crate::conversation::{ChannelStore, ConversationLogger, ProcessRunLogger}; use crate::error::{AgentError, Result}; @@ -1453,9 +1453,10 @@ impl Channel { let temporal_context = TemporalContext::from_runtime(rc.as_ref()); let current_time_line = temporal_context.current_time_line(); + let system_info = self.build_system_info().await; let status_text = { let status = self.state.status_block.read().await; - status.render_with_time_context(Some(¤t_time_line)) + status.render_full(¤t_time_line, &system_info) }; // Render coalesce hint @@ -2066,6 +2067,24 @@ impl Channel { } } + /// Build a snapshot of the system configuration for status block injection. + async fn build_system_info(&self) -> SystemInfo { + let rc = &self.deps.runtime_config; + let mut info = SystemInfo::from_runtime_config(rc, &self.deps.sandbox); + + // Add async-only fields that the base constructor can't populate + let cron_job_count = { + let scheduler_guard = rc.cron_scheduler.load(); + match scheduler_guard.as_ref() { + Some(scheduler) => Some(scheduler.job_count().await), + None => None, + } + }; + info.cron_job_count = cron_job_count; + + info + } + /// Assemble the full system prompt using the PromptEngine. async fn build_system_prompt(&self) -> crate::error::Result { let rc = &self.deps.runtime_config; @@ -2090,9 +2109,10 @@ impl Channel { let temporal_context = TemporalContext::from_runtime(rc.as_ref()); let current_time_line = temporal_context.current_time_line(); + let system_info = self.build_system_info().await; let status_text = { let status = self.state.status_block.read().await; - status.render_with_time_context(Some(¤t_time_line)) + status.render_full(¤t_time_line, &system_info) }; let available_channels = self.build_available_channels().await; @@ -2961,8 +2981,9 @@ impl Channel { pub async fn get_status(&self) -> String { let temporal_context = TemporalContext::from_runtime(self.deps.runtime_config.as_ref()); let current_time_line = temporal_context.current_time_line(); + let system_info = self.build_system_info().await; let status = self.state.status_block.read().await; - status.render_with_time_context(Some(¤t_time_line)) + status.render_full(¤t_time_line, &system_info) } /// Check if a memory persistence branch should be spawned based on message count. diff --git a/src/agent/channel_dispatch.rs b/src/agent/channel_dispatch.rs index c243a5d73..a34ef2981 100644 --- a/src/agent/channel_dispatch.rs +++ b/src/agent/channel_dispatch.rs @@ -6,7 +6,7 @@ use crate::agent::branch::Branch; use crate::agent::channel::ChannelState; -use crate::agent::channel_prompt::{TemporalContext, build_worker_task_with_temporal_context}; +use crate::agent::channel_prompt::TemporalContext; use crate::agent::worker::Worker; use crate::error::{AgentError, Error as SpacebotError}; use crate::{AgentDeps, BranchId, ChannelId, ProcessEvent, WorkerId}; @@ -414,10 +414,14 @@ async fn spawn_worker_inner( ) -> std::result::Result { let rc = &state.deps.runtime_config; let prompt_engine = rc.prompts.load(); + + // Build worker status text (time + system config) for the system prompt. + let system_info = + crate::agent::status::SystemInfo::from_runtime_config(rc.as_ref(), &state.deps.sandbox); let temporal_context = TemporalContext::from_runtime(rc.as_ref()); - let worker_task = - build_worker_task_with_temporal_context(task, &temporal_context, &prompt_engine) - .map_err(|error| AgentError::Other(anyhow::anyhow!("{error}")))?; + let current_time_line = temporal_context.current_time_line(); + let worker_status_text = Some(system_info.render_for_worker(¤t_time_line)); + let sandbox_enabled = state.deps.sandbox.mode_enabled(); let sandbox_containment_active = state.deps.sandbox.containment_active(); let sandbox_read_allowlist = state.deps.sandbox.prompt_read_allowlist(); @@ -440,6 +444,7 @@ async fn spawn_worker_inner( sandbox_write_allowlist, &tool_secret_names, browser_config.persist_session, + worker_status_text, ) .map_err(|e| AgentError::Other(anyhow::anyhow!("{e}")))?; let skills = rc.skills.load(); @@ -462,7 +467,7 @@ async fn spawn_worker_inner( let worker = if interactive { let (worker, input_tx, inject_tx) = Worker::new_interactive( Some(state.channel_id.clone()), - &worker_task, + task, &system_prompt, state.deps.clone(), browser_config.clone(), @@ -485,7 +490,7 @@ async fn spawn_worker_inner( } else { let (worker, inject_tx) = Worker::new( Some(state.channel_id.clone()), - &worker_task, + task, &system_prompt, state.deps.clone(), browser_config, @@ -586,11 +591,6 @@ async fn spawn_opencode_worker_inner( let directory = expand_tilde(directory); let rc = &state.deps.runtime_config; - let prompt_engine = rc.prompts.load(); - let temporal_context = TemporalContext::from_runtime(rc.as_ref()); - let worker_task = - build_worker_task_with_temporal_context(task, &temporal_context, &prompt_engine) - .map_err(|error| AgentError::Other(anyhow::anyhow!("{error}")))?; let opencode_config = rc.opencode.load(); if !opencode_config.enabled { @@ -618,7 +618,7 @@ async fn spawn_opencode_worker_inner( let (worker, input_tx) = crate::opencode::OpenCodeWorker::new_interactive( Some(state.channel_id.clone()), state.deps.agent_id.clone(), - &worker_task, + task, directory, server_pool, state.deps.event_tx.clone(), @@ -638,7 +638,7 @@ async fn spawn_opencode_worker_inner( let worker = crate::opencode::OpenCodeWorker::new( Some(state.channel_id.clone()), state.deps.agent_id.clone(), - &worker_task, + task, directory, server_pool, state.deps.event_tx.clone(), @@ -986,6 +986,16 @@ pub async fn resume_idle_worker_into_state( let rc = &state.deps.runtime_config; let prompt_engine = rc.prompts.load(); + + // Build worker status text for system prompt + let system_info = crate::agent::status::SystemInfo::from_runtime_config( + rc.as_ref(), + &state.deps.sandbox, + ); + let temporal_context = TemporalContext::from_runtime(rc.as_ref()); + let current_time_line = temporal_context.current_time_line(); + let worker_status_text = Some(system_info.render_for_worker(¤t_time_line)); + let sandbox_enabled = state.deps.sandbox.mode_enabled(); let sandbox_containment_active = state.deps.sandbox.containment_active(); let sandbox_read_allowlist = state.deps.sandbox.prompt_read_allowlist(); @@ -1006,6 +1016,7 @@ pub async fn resume_idle_worker_into_state( sandbox_write_allowlist, &tool_secret_names, browser_config.persist_session, + worker_status_text, ) .map_err(|error| format!("failed to render worker prompt: {error}"))?; let brave_search_key = (**rc.brave_search_key.load()).clone(); diff --git a/src/agent/channel_history.rs b/src/agent/channel_history.rs index 1c33bc95b..56764a256 100644 --- a/src/agent/channel_history.rs +++ b/src/agent/channel_history.rs @@ -996,38 +996,22 @@ mod tests { } #[test] - fn worker_task_temporal_context_preamble_includes_absolute_dates() { - let prompt_engine = - crate::prompts::PromptEngine::new("en").expect("prompt engine should initialize"); - let temporal_context = crate::agent::channel_prompt::TemporalContext { - now_utc: chrono::DateTime::parse_from_rfc3339("2026-02-26T20:30:00Z") - .expect("valid RFC3339 timestamp") - .with_timezone(&chrono::Utc), - timezone: crate::agent::channel_prompt::TemporalTimezone::Named { - timezone_name: "America/New_York".to_string(), - timezone: "America/New_York" - .parse() - .expect("valid timezone identifier"), - }, + fn worker_system_info_render_includes_time_and_model() { + let info = crate::agent::status::SystemInfo { + worker_model: "anthropic/claude-sonnet-4".into(), + ..Default::default() }; - let worker_task = crate::agent::channel_prompt::build_worker_task_with_temporal_context( - "Run the migration checks", - &temporal_context, - &prompt_engine, - ) - .expect("worker task preamble should render"); - assert!( - worker_task.contains("Current local date/time:"), - "worker task should include local time context" + let rendered = info.render_for_worker( + "2026-02-26 15:30:00 EST (America/New_York, UTC-05:00); UTC 2026-02-26 20:30:00 UTC", ); assert!( - worker_task.contains("Current UTC date/time:"), - "worker task should include UTC time context" + rendered.contains("Time: 2026-02-26 15:30:00 EST"), + "worker status should include time context" ); assert!( - worker_task.contains("Run the migration checks"), - "worker task should preserve the original task body" + rendered.contains("Model: anthropic/claude-sonnet-4"), + "worker status should include model name" ); } diff --git a/src/agent/channel_prompt.rs b/src/agent/channel_prompt.rs index 196a249c0..f6964f1da 100644 --- a/src/agent/channel_prompt.rs +++ b/src/agent/channel_prompt.rs @@ -4,7 +4,6 @@ //! all the prompt-building methods that assemble the channel's //! system prompt from identity, memory bulletin, skills, status, etc. -use crate::error::Result; use chrono::{DateTime, Local, Utc}; use chrono_tz::Tz; @@ -122,22 +121,4 @@ impl TemporalContext { self.now_utc.format("%Y-%m-%d %H:%M:%S UTC") ) } - - pub(crate) fn worker_task_preamble( - &self, - prompt_engine: &crate::prompts::PromptEngine, - ) -> Result { - let local_time = self.format_timestamp(self.now_utc); - let utc_time = self.now_utc.format("%Y-%m-%d %H:%M:%S UTC").to_string(); - prompt_engine.render_system_worker_time_context(&local_time, &utc_time) - } -} - -pub(crate) fn build_worker_task_with_temporal_context( - task: &str, - temporal_context: &TemporalContext, - prompt_engine: &crate::prompts::PromptEngine, -) -> Result { - let preamble = temporal_context.worker_task_preamble(prompt_engine)?; - Ok(format!("{preamble}\n\n{task}")) } diff --git a/src/agent/cortex.rs b/src/agent/cortex.rs index babe70610..8e350e6e1 100644 --- a/src/agent/cortex.rs +++ b/src/agent/cortex.rs @@ -2401,6 +2401,15 @@ async fn pickup_one_ready_task(deps: &AgentDeps, logger: &CortexLogger) -> anyho }; let browser_config = (**deps.runtime_config.browser_config.load()).clone(); + + // Build worker status text (time + model) for the system prompt. + let system_info = + crate::agent::status::SystemInfo::from_runtime_config(&deps.runtime_config, &deps.sandbox); + let temporal_context = + crate::agent::channel_prompt::TemporalContext::from_runtime(&deps.runtime_config); + let current_time_line = temporal_context.current_time_line(); + let worker_status_text = Some(system_info.render_for_worker(¤t_time_line)); + let worker_system_prompt = prompt_engine .render_worker_prompt( &deps.runtime_config.instance_dir.display().to_string(), @@ -2411,6 +2420,7 @@ async fn pickup_one_ready_task(deps: &AgentDeps, logger: &CortexLogger) -> anyho sandbox_write_allowlist, &tool_secret_names, browser_config.persist_session, + worker_status_text, ) .map_err(|error| anyhow::anyhow!("failed to render worker prompt: {error}"))?; diff --git a/src/agent/status.rs b/src/agent/status.rs index d909cb6f7..0b7592a21 100644 --- a/src/agent/status.rs +++ b/src/agent/status.rs @@ -3,6 +3,129 @@ use crate::{BranchId, ProcessEvent, ProcessId, WorkerId}; use chrono::{DateTime, Utc}; +/// Static system configuration snapshot injected into the status block. +/// +/// Assembled from `RuntimeConfig` each turn and rendered as a compact +/// key-value section at the top of the status block. Gives the channel +/// LLM self-awareness about its own models, limits, and capabilities. +#[derive(Debug, Clone, Default)] +pub struct SystemInfo { + /// Binary version string (e.g. "0.9.2"). + pub version: String, + /// Deployment kind: "native", "docker", or "hosted". + pub deployment: String, + /// Model assigned to the channel process. + pub channel_model: String, + /// Model assigned to branch processes. + pub branch_model: String, + /// Model assigned to worker processes. + pub worker_model: String, + /// Thinking effort for the channel (e.g. "auto", "low", "high"). + pub channel_thinking: String, + /// Thinking effort for workers. + pub worker_thinking: String, + /// Context window size in tokens. + pub context_window: usize, + /// Maximum concurrent workers allowed. + pub max_workers: usize, + /// Maximum concurrent branches allowed. + pub max_branches: usize, + /// Enabled capability flags (e.g. "browser", "web_search", "opencode"). + pub capabilities: Vec, + /// Names of connected MCP servers. + pub mcp_servers: Vec, + /// Whether sandbox containment is active. + pub sandbox_active: bool, + /// Warmup state label (e.g. "warm", "cold", "degraded"). + pub warmup_state: String, + /// Whether embeddings are loaded and ready. + pub embedding_ready: bool, + /// Age of the memory bulletin in minutes, if known. + pub bulletin_age_minutes: Option, + /// Number of registered cron jobs, if known. + pub cron_job_count: Option, +} + +impl SystemInfo { + /// Build a system info snapshot from runtime config. + /// + /// This is the synchronous base — it populates everything that can be + /// read without async (no cron count, no MCP tool names). Channels + /// augment this with async-only fields via `build_system_info`. + pub fn from_runtime_config( + rc: &crate::config::RuntimeConfig, + sandbox: &crate::sandbox::Sandbox, + ) -> Self { + let routing = rc.routing.load(); + + let mut capabilities = Vec::new(); + if rc.browser_config.load().enabled { + capabilities.push("browser".to_string()); + } + if rc.brave_search_key.load().is_some() { + capabilities.push("web_search".to_string()); + } + if rc.opencode.load().enabled { + capabilities.push("opencode".to_string()); + } + + let mcp_servers: Vec = rc + .mcp + .load() + .iter() + .filter(|server| server.enabled) + .map(|server| server.name.clone()) + .collect(); + + let warmup_status = rc.warmup_status.load(); + let warmup_state = match warmup_status.state { + crate::config::WarmupState::Cold => "cold", + crate::config::WarmupState::Warming => "warming", + crate::config::WarmupState::Warm => "warm", + crate::config::WarmupState::Degraded => "degraded", + } + .to_string(); + + let bulletin_age_minutes = warmup_status.bulletin_age_secs.map(|secs| secs / 60); + + Self { + version: crate::update::CURRENT_VERSION.to_string(), + deployment: match crate::update::Deployment::detect() { + crate::update::Deployment::Docker => "docker", + crate::update::Deployment::Hosted => "hosted", + crate::update::Deployment::Native => "native", + } + .to_string(), + channel_model: routing.channel.clone(), + branch_model: routing.branch.clone(), + worker_model: routing.worker.clone(), + channel_thinking: routing.channel_thinking_effort.clone(), + worker_thinking: routing.worker_thinking_effort.clone(), + context_window: **rc.context_window.load(), + max_workers: **rc.max_concurrent_workers.load(), + max_branches: **rc.max_concurrent_branches.load(), + capabilities, + mcp_servers, + sandbox_active: sandbox.containment_active(), + warmup_state, + embedding_ready: warmup_status.embedding_ready, + bulletin_age_minutes, + cron_job_count: None, + } + } + + /// Render a compact status string suitable for worker system prompts. + /// + /// Workers get a lighter version: just time + model + context window. + /// No warmup, no cron, no bulletin — they don't need it. + pub fn render_for_worker(&self, current_time_line: &str) -> String { + let mut output = String::from("## System\n"); + output.push_str(&format!("Time: {current_time_line}\n")); + output.push_str(&format!("Model: {}\n", self.worker_model)); + output + } +} + /// Live status block injected into channel context. #[derive(Debug, Clone, Default, serde::Serialize)] pub struct StatusBlock { @@ -228,14 +351,27 @@ impl StatusBlock { /// Render the status block as a string for context injection. pub fn render(&self) -> String { - self.render_with_time_context(None) + self.render_with_context(None, None) } /// Render the status block with optional current time context. pub fn render_with_time_context(&self, current_time_line: Option<&str>) -> String { + self.render_with_context(current_time_line, None) + } + + /// Render the status block with optional time context and system info. + pub fn render_with_context( + &self, + current_time_line: Option<&str>, + system_info: Option<&SystemInfo>, + ) -> String { let mut output = String::new(); - if let Some(current_time_line) = current_time_line { + // System configuration summary (includes current time when available) + if let Some(info) = system_info { + output.push_str(&render_system_info(info, current_time_line)); + } else if let Some(current_time_line) = current_time_line { + // Fallback: render time standalone when no system info is provided output.push_str(&format!("Current date/time: {current_time_line}\n\n")); } @@ -323,6 +459,11 @@ impl StatusBlock { output } + /// Render the status block with time context and system info (convenience). + pub fn render_full(&self, current_time_line: &str, system_info: &SystemInfo) -> String { + self.render_with_context(Some(current_time_line), Some(system_info)) + } + /// Check if a worker is active. pub fn is_worker_active(&self, worker_id: WorkerId) -> bool { self.active_workers.iter().any(|w| w.id == worker_id) @@ -374,6 +515,108 @@ impl StatusBlock { } } +/// Render the system info section as compact key-value lines. +fn render_system_info(info: &SystemInfo, current_time_line: Option<&str>) -> String { + let mut output = String::from("## System\n"); + + // Current date/time + timezone (first line — source of truth for temporal reasoning) + if let Some(time_line) = current_time_line { + output.push_str(&format!("Time: {time_line}\n")); + } + + // Version + deployment + output.push_str(&format!( + "Version: {} ({})\n", + info.version, info.deployment + )); + + // Model assignments — collapse if all the same + if info.channel_model == info.branch_model && info.branch_model == info.worker_model { + output.push_str(&format!("Models: {}\n", info.channel_model)); + } else if info.channel_model == info.branch_model { + output.push_str(&format!( + "Models: channel/branch={}, worker={}\n", + info.channel_model, info.worker_model + )); + } else { + output.push_str(&format!( + "Models: channel={}, branch={}, worker={}\n", + info.channel_model, info.branch_model, info.worker_model + )); + } + + // Thinking effort — only show if not all "auto" + if info.channel_thinking != "auto" || info.worker_thinking != "auto" { + if info.channel_thinking == info.worker_thinking { + output.push_str(&format!("Thinking: {}\n", info.channel_thinking)); + } else { + output.push_str(&format!( + "Thinking: channel={}, worker={}\n", + info.channel_thinking, info.worker_thinking + )); + } + } + + // Context + concurrency limits + let context_label = if info.context_window >= 1000 { + format!("{}k tokens", info.context_window / 1000) + } else { + format!("{} tokens", info.context_window) + }; + output.push_str(&format!( + "Context: {} | Workers: max {} | Branches: max {}\n", + context_label, info.max_workers, info.max_branches + )); + + // Capabilities — combine flags and MCP into one line + let mut caps: Vec<&str> = info.capabilities.iter().map(|s| s.as_str()).collect(); + if info.sandbox_active { + caps.push("sandbox"); + } + if !caps.is_empty() { + output.push_str(&format!("Capabilities: {}\n", caps.join(", "))); + } + + // MCP servers + if !info.mcp_servers.is_empty() { + output.push_str(&format!( + "MCP: {} ({} server{})\n", + info.mcp_servers.join(", "), + info.mcp_servers.len(), + if info.mcp_servers.len() == 1 { "" } else { "s" } + )); + } + + // Warmup / readiness + let mut warmup_parts = vec![info.warmup_state.as_str()]; + let embedding_label = if info.embedding_ready { + "embeddings ready".to_string() + } else { + "embeddings loading".to_string() + }; + warmup_parts.push(&embedding_label); + let bulletin_label; + if let Some(age) = info.bulletin_age_minutes { + bulletin_label = format!("bulletin {}m ago", age); + warmup_parts.push(&bulletin_label); + } + output.push_str(&format!("Warmup: {}\n", warmup_parts.join(", "))); + + // Cron jobs + if let Some(count) = info.cron_job_count + && count > 0 + { + output.push_str(&format!( + "Cron: {} active job{}\n", + count, + if count == 1 { "" } else { "s" } + )); + } + + output.push('\n'); + output +} + #[cfg(test)] mod tests { use super::StatusBlock; @@ -450,4 +693,154 @@ mod tests { let found = status.find_duplicate_worker_task("any task"); assert_eq!(found, None); } + + #[test] + fn render_full_includes_system_info_and_time() { + use super::SystemInfo; + + let status = StatusBlock::new(); + let info = SystemInfo { + version: "0.9.2".into(), + deployment: "hosted".into(), + channel_model: "anthropic/claude-sonnet-4".into(), + branch_model: "anthropic/claude-sonnet-4".into(), + worker_model: "anthropic/claude-sonnet-4".into(), + channel_thinking: "auto".into(), + worker_thinking: "auto".into(), + context_window: 128_000, + max_workers: 5, + max_branches: 3, + capabilities: vec!["browser".into(), "web_search".into()], + mcp_servers: vec!["github".into(), "linear".into()], + sandbox_active: true, + warmup_state: "warm".into(), + embedding_ready: true, + bulletin_age_minutes: Some(12), + cron_job_count: Some(4), + }; + + let rendered = status.render_full("2026-03-08 10:30:00 EST", &info); + + // Time is inside System section + assert!(rendered.contains("Time: 2026-03-08 10:30:00 EST")); + // Version + assert!(rendered.contains("Version: 0.9.2 (hosted)")); + // Models collapsed (all same) + assert!(rendered.contains("Models: anthropic/claude-sonnet-4")); + assert!(!rendered.contains("channel=")); + // Thinking effort hidden when all auto + assert!(!rendered.contains("Thinking:")); + // Context + limits + assert!(rendered.contains("128k tokens")); + assert!(rendered.contains("Workers: max 5")); + assert!(rendered.contains("Branches: max 3")); + // Capabilities + assert!(rendered.contains("browser")); + assert!(rendered.contains("web_search")); + assert!(rendered.contains("sandbox")); + // MCP + assert!(rendered.contains("github, linear (2 servers)")); + // Warmup + assert!(rendered.contains("warm")); + assert!(rendered.contains("embeddings ready")); + assert!(rendered.contains("bulletin 12m ago")); + // Cron + assert!(rendered.contains("4 active jobs")); + } + + #[test] + fn render_system_info_collapses_identical_models() { + use super::SystemInfo; + + let status = StatusBlock::new(); + let info = SystemInfo { + channel_model: "anthropic/claude-sonnet-4".into(), + branch_model: "anthropic/claude-sonnet-4".into(), + worker_model: "anthropic/claude-sonnet-4".into(), + ..Default::default() + }; + + let rendered = status.render_with_context(None, Some(&info)); + assert!(rendered.contains("Models: anthropic/claude-sonnet-4\n")); + assert!(!rendered.contains("channel=")); + } + + #[test] + fn render_system_info_splits_different_models() { + use super::SystemInfo; + + let status = StatusBlock::new(); + let info = SystemInfo { + channel_model: "anthropic/claude-sonnet-4".into(), + branch_model: "anthropic/claude-sonnet-4".into(), + worker_model: "anthropic/claude-haiku-35".into(), + ..Default::default() + }; + + let rendered = status.render_with_context(None, Some(&info)); + assert!(rendered.contains("channel/branch=anthropic/claude-sonnet-4")); + assert!(rendered.contains("worker=anthropic/claude-haiku-35")); + } + + #[test] + fn render_system_info_shows_all_three_when_all_different() { + use super::SystemInfo; + + let status = StatusBlock::new(); + let info = SystemInfo { + channel_model: "anthropic/claude-opus-4".into(), + branch_model: "anthropic/claude-sonnet-4".into(), + worker_model: "anthropic/claude-haiku-35".into(), + ..Default::default() + }; + + let rendered = status.render_with_context(None, Some(&info)); + assert!(rendered.contains("channel=anthropic/claude-opus-4")); + assert!(rendered.contains("branch=anthropic/claude-sonnet-4")); + assert!(rendered.contains("worker=anthropic/claude-haiku-35")); + } + + #[test] + fn render_system_info_shows_thinking_when_not_auto() { + use super::SystemInfo; + + let status = StatusBlock::new(); + let info = SystemInfo { + channel_thinking: "high".into(), + worker_thinking: "low".into(), + ..Default::default() + }; + + let rendered = status.render_with_context(None, Some(&info)); + assert!(rendered.contains("Thinking: channel=high, worker=low")); + } + + #[test] + fn render_system_info_hides_thinking_when_all_auto() { + use super::SystemInfo; + + let status = StatusBlock::new(); + let info = SystemInfo { + channel_thinking: "auto".into(), + worker_thinking: "auto".into(), + ..Default::default() + }; + + let rendered = status.render_with_context(None, Some(&info)); + assert!(!rendered.contains("Thinking:")); + } + + #[test] + fn render_system_info_no_cron_when_zero() { + use super::SystemInfo; + + let status = StatusBlock::new(); + let info = SystemInfo { + cron_job_count: Some(0), + ..Default::default() + }; + + let rendered = status.render_with_context(None, Some(&info)); + assert!(!rendered.contains("Cron:")); + } } diff --git a/src/api/channels.rs b/src/api/channels.rs index 1fa5c094c..fb1f4f423 100644 --- a/src/api/channels.rs +++ b/src/api/channels.rs @@ -377,6 +377,127 @@ pub(super) async fn cancel_process( } } +// ── Prompt Inspect ────────────────────────────────────────────────── + +#[derive(Deserialize)] +pub(super) struct PromptInspectQuery { + channel_id: String, +} + +/// Render the full prompt that the LLM would see on the next turn for a +/// given channel. Returns the system prompt and conversation history +/// exactly as they would be assembled — useful for debugging prompt +/// construction, status block content, and context window usage. +pub(super) async fn inspect_prompt( + State(state): State>, + Query(query): Query, +) -> Result, StatusCode> { + let channel_state = { + let states = state.channel_states.read().await; + states.get(&query.channel_id).cloned() + }; + + let channel_state = channel_state.ok_or(StatusCode::NOT_FOUND)?; + let rc = &channel_state.deps.runtime_config; + let prompt_engine = rc.prompts.load(); + + // ── Identity + memory bulletin ── + let identity_context = rc.identity.load().render(); + let memory_bulletin = rc.memory_bulletin.load(); + let skills = rc.skills.load(); + let skills_prompt = skills + .render_channel_prompt(&prompt_engine) + .unwrap_or_default(); + + // ── Worker capabilities ── + let browser_enabled = rc.browser_config.load().enabled; + let web_search_enabled = rc.brave_search_key.load().is_some(); + let opencode_enabled = rc.opencode.load().enabled; + let mcp_tool_names = channel_state.deps.mcp_manager.get_tool_names().await; + let worker_capabilities = prompt_engine + .render_worker_capabilities( + browser_enabled, + web_search_enabled, + opencode_enabled, + &mcp_tool_names, + ) + .unwrap_or_default(); + + // ── Status block with system info ── + let system_info = crate::agent::status::SystemInfo::from_runtime_config( + rc.as_ref(), + &channel_state.deps.sandbox, + ); + let temporal_context = crate::agent::channel_prompt::TemporalContext::from_runtime(rc.as_ref()); + let current_time_line = temporal_context.current_time_line(); + let status_text = { + let status = channel_state.status_block.read().await; + status.render_full(¤t_time_line, &system_info) + }; + + // ── Conversation context (from DB channel metadata) ── + let conversation_context = match channel_state.channel_store.get(&query.channel_id).await { + Ok(Some(info)) => { + let server_name = info + .platform_meta + .as_ref() + .and_then(|meta| { + meta.get("discord_guild_name") + .or_else(|| meta.get("slack_workspace_id")) + }) + .and_then(|v| v.as_str()); + prompt_engine + .render_conversation_context( + &info.platform, + server_name, + info.display_name.as_deref(), + ) + .ok() + } + _ => None, + }; + + let sandbox_enabled = channel_state.deps.sandbox.containment_active(); + + let empty_to_none = |s: String| if s.is_empty() { None } else { Some(s) }; + + // ── Render system prompt ── + let system_prompt = prompt_engine + .render_channel_prompt_with_links( + empty_to_none(identity_context), + empty_to_none(memory_bulletin.to_string()), + empty_to_none(skills_prompt), + worker_capabilities, + conversation_context, + empty_to_none(status_text), + None, // coalesce_hint — only relevant during batched dispatch + None, // available_channels — requires messaging manager context + sandbox_enabled, + None, // org_context — requires link resolution + None, // adapter_prompt — requires adapter context + None, // project_context — requires project store queries + ) + .map_err(|error| { + tracing::warn!(%error, "failed to render channel prompt for inspect"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + // ── History ── + let history = channel_state.history.read().await; + let history_json = serde_json::to_value(&*history).unwrap_or(serde_json::Value::Null); + + // ── Build response ── + let response = serde_json::json!({ + "channel_id": query.channel_id, + "system_prompt": system_prompt, + "system_prompt_chars": system_prompt.len(), + "history_length": history.len(), + "history": history_json, + }); + + Ok(Json(response)) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/api/server.rs b/src/api/server.rs index e84e5535e..a108977b7 100644 --- a/src/api/server.rs +++ b/src/api/server.rs @@ -90,6 +90,7 @@ pub async fn start_http_server( .route("/channels/archive", put(channels::set_channel_archive)) .route("/channels/messages", get(channels::channel_messages)) .route("/channels/status", get(channels::channel_status)) + .route("/channels/inspect", get(channels::inspect_prompt)) .route("/agents/workers", get(workers::list_workers)) .route("/agents/workers/detail", get(workers::worker_detail)) .route( diff --git a/src/cron/scheduler.rs b/src/cron/scheduler.rs index e3be04cda..15ff88652 100644 --- a/src/cron/scheduler.rs +++ b/src/cron/scheduler.rs @@ -473,6 +473,11 @@ impl Scheduler { jobs.contains_key(job_id) } + /// Return the number of registered cron jobs. + pub async fn job_count(&self) -> usize { + self.jobs.read().await.len() + } + /// Trigger a cron job immediately, outside the timer loop. pub async fn trigger_now(&self, job_id: &str) -> Result<()> { let job = { diff --git a/src/prompts/engine.rs b/src/prompts/engine.rs index c1ab5b706..7da6eb7a4 100644 --- a/src/prompts/engine.rs +++ b/src/prompts/engine.rs @@ -148,10 +148,6 @@ impl PromptEngine { "fragments/system/tool_syntax_correction", crate::prompts::text::get("fragments/system/tool_syntax_correction"), )?; - env.add_template( - "fragments/system/worker_time_context", - crate::prompts::text::get("fragments/system/worker_time_context"), - )?; env.add_template( "fragments/coalesce_hint", crate::prompts::text::get("fragments/coalesce_hint"), @@ -262,6 +258,7 @@ impl PromptEngine { sandbox_write_allowlist: Vec, tool_secret_names: &[String], browser_persist_session: bool, + status_text: Option, ) -> Result { self.render( "worker", @@ -274,6 +271,7 @@ impl PromptEngine { sandbox_write_allowlist => sandbox_write_allowlist, tool_secret_names => tool_secret_names, browser_persist_session => browser_persist_session, + status_text => status_text, }, ) } @@ -330,21 +328,6 @@ impl PromptEngine { self.render_static("fragments/system/tool_syntax_correction") } - /// Render worker task time-context preamble. - pub fn render_system_worker_time_context( - &self, - current_local_datetime: &str, - current_utc_datetime: &str, - ) -> Result { - self.render( - "fragments/system/worker_time_context", - context! { - current_local_datetime => current_local_datetime, - current_utc_datetime => current_utc_datetime, - }, - ) - } - /// Convenience method for rendering truncation marker. pub fn render_system_truncation(&self, remove_count: usize) -> Result { self.render( diff --git a/src/prompts/text.rs b/src/prompts/text.rs index 60fc04b60..88fe86df8 100644 --- a/src/prompts/text.rs +++ b/src/prompts/text.rs @@ -118,10 +118,6 @@ fn lookup(lang: &str, key: &str) -> &'static str { ("en", "fragments/system/tool_syntax_correction") => { include_str!("../../prompts/en/fragments/system/tool_syntax_correction.md.j2") } - ("en", "fragments/system/worker_time_context") => { - include_str!("../../prompts/en/fragments/system/worker_time_context.md.j2") - } - // Agent Communication Fragments ("en", "fragments/org_context") => { include_str!("../../prompts/en/fragments/org_context.md.j2") diff --git a/src/tools/spawn_worker.rs b/src/tools/spawn_worker.rs index 28c4c60fe..8d7692b23 100644 --- a/src/tools/spawn_worker.rs +++ b/src/tools/spawn_worker.rs @@ -392,6 +392,15 @@ impl Tool for DetachedSpawnWorkerTool { async fn call(&self, args: Self::Args) -> Result { let rc = &self.deps.runtime_config; let prompt_engine = rc.prompts.load(); + + // Build worker status text (time + model) for the system prompt. + let system_info = + crate::agent::status::SystemInfo::from_runtime_config(rc.as_ref(), &self.deps.sandbox); + let temporal_context = + crate::agent::channel_prompt::TemporalContext::from_runtime(rc.as_ref()); + let current_time_line = temporal_context.current_time_line(); + let worker_status_text = Some(system_info.render_for_worker(¤t_time_line)); + let sandbox_enabled = self.deps.sandbox.mode_enabled(); let sandbox_containment_active = self.deps.sandbox.containment_active(); let sandbox_read_allowlist = self.deps.sandbox.prompt_read_allowlist(); @@ -414,6 +423,7 @@ impl Tool for DetachedSpawnWorkerTool { sandbox_write_allowlist, &tool_secret_names, browser_config.persist_session, + worker_status_text, ) .map_err(|error| { SpawnWorkerError(format!("failed to render worker prompt: {error}")) diff --git a/tests/context_dump.rs b/tests/context_dump.rs index 7862f6d6b..e6fb1a8bc 100644 --- a/tests/context_dump.rs +++ b/tests/context_dump.rs @@ -371,6 +371,7 @@ async fn dump_worker_context() { Vec::new(), &[], browser_config.persist_session, + None, ) .expect("failed to render worker prompt"); print_section("WORKER SYSTEM PROMPT", &worker_prompt); @@ -546,6 +547,7 @@ async fn dump_all_contexts() { Vec::new(), &[], browser_config.persist_session, + None, ) .expect("failed to render worker prompt"); let brave_search_key = (**rc.brave_search_key.load()).clone(); From faf6b4f04a2189dce8dbd2abf8ebf5af6c3be86e Mon Sep 17 00:00:00 2001 From: James Pine Date: Mon, 9 Mar 2026 03:03:29 -0700 Subject: [PATCH 2/3] feat: add prompt inspector with snapshot capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Debug tool that shows exactly what the LLM sees on each turn. The inspector modal displays the full rendered system prompt and conversation history as raw monospace text — useful for debugging prompt construction, message coalescing, history backfill, and status block content. Snapshot capture is a per-channel toggle that records the exact prompt and history on every LLM turn. Snapshots are stored in a dedicated redb database (prompt_snapshots.redb) so they can be deleted independently. Backend: - PromptSnapshotStore with save/list/get/clear_channel (separate redb) - Channel::maybe_capture_snapshot() fires-and-forgets on each turn - Settings store: prompt_capture_enabled/set_prompt_capture per channel - API: GET /channels/inspect returns rendered system_prompt + history - API: POST /channels/inspect/capture toggles capture - API: GET /channels/inspect/snapshots and /snapshot for history Frontend: - PromptInspectModal with Current/History sidebar and capture toggle - Raw text rendering of system prompt and message history - Snapshot list with click-to-view historical captures --- interface/src/api/client.ts | 58 ++++ .../src/components/PromptInspectModal.tsx | 328 ++++++++++++++++++ interface/src/routes/ChannelDetail.tsx | 14 +- src/agent.rs | 1 + src/agent/channel.rs | 63 ++++ src/agent/prompt_snapshot.rs | 291 ++++++++++++++++ src/api/channels.rs | 191 ++++++++-- src/api/server.rs | 12 + src/config/runtime.rs | 3 + src/cron/scheduler.rs | 1 + src/main.rs | 25 ++ src/settings/store.rs | 13 + tests/context_dump.rs | 2 + 13 files changed, 981 insertions(+), 21 deletions(-) create mode 100644 interface/src/components/PromptInspectModal.tsx create mode 100644 src/agent/prompt_snapshot.rs diff --git a/interface/src/api/client.ts b/interface/src/api/client.ts index 253cae3b7..8cab46c2d 100644 --- a/interface/src/api/client.ts +++ b/interface/src/api/client.ts @@ -255,6 +255,45 @@ export interface StatusBlockSnapshot { /** channel_id -> StatusBlockSnapshot */ export type ChannelStatusResponse = Record; +export interface PromptInspectResponse { + channel_id: string; + system_prompt: string; + total_chars: number; + history_length: number; + history: unknown[]; + capture_enabled: boolean; + /** Present when the channel is not active */ + error?: string; + message?: string; +} + +export interface PromptSnapshotSummary { + timestamp_ms: number; + user_message: string; + system_prompt_chars: number; + history_length: number; +} + +export interface PromptSnapshotListResponse { + channel_id: string; + snapshots: PromptSnapshotSummary[]; +} + +export interface PromptSnapshot { + channel_id: string; + timestamp_ms: number; + user_message: string; + system_prompt: string; + system_prompt_chars: number; + history: unknown; + history_length: number; +} + +export interface PromptCaptureResponse { + channel_id: string; + capture_enabled: boolean; +} + // --- Workers API types --- export type ActionContent = @@ -1634,6 +1673,25 @@ export const api = { return fetchJson(`/channels/messages?${params}`); }, channelStatus: () => fetchJson("/channels/status"), + inspectPrompt: (channelId: string) => + fetchJson(`/channels/inspect?channel_id=${encodeURIComponent(channelId)}`), + setPromptCapture: async (channelId: string, enabled: boolean) => { + const response = await fetch(`${API_BASE}/channels/inspect/capture`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ channel_id: channelId, enabled }), + }); + if (!response.ok) throw new Error(`API error: ${response.status}`); + return response.json() as Promise; + }, + listPromptSnapshots: (channelId: string, limit = 50) => + fetchJson( + `/channels/inspect/snapshots?channel_id=${encodeURIComponent(channelId)}&limit=${limit}`, + ), + getPromptSnapshot: (channelId: string, timestampMs: number) => + fetchJson( + `/channels/inspect/snapshot?channel_id=${encodeURIComponent(channelId)}×tamp_ms=${timestampMs}`, + ), workersList: (agentId: string, params: { limit?: number; offset?: number; status?: string } = {}) => { const search = new URLSearchParams({ agent_id: agentId }); if (params.limit) search.set("limit", String(params.limit)); diff --git a/interface/src/components/PromptInspectModal.tsx b/interface/src/components/PromptInspectModal.tsx new file mode 100644 index 000000000..b396e8dff --- /dev/null +++ b/interface/src/components/PromptInspectModal.tsx @@ -0,0 +1,328 @@ +import { useState, useCallback } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { + api, + type PromptInspectResponse, + type PromptSnapshotSummary, + type PromptSnapshot, +} from "@/api/client"; +import { Button, Dialog, DialogContent, DialogHeader, DialogTitle, Toggle } from "@/ui"; + +interface PromptInspectModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + channelId: string; +} + +type View = "current" | "history"; + +export function PromptInspectModal({ open, onOpenChange, channelId }: PromptInspectModalProps) { + const [view, setView] = useState("current"); + const [selectedSnapshot, setSelectedSnapshot] = useState(null); + const queryClient = useQueryClient(); + + const { data, isLoading, error } = useQuery({ + queryKey: ["inspectPrompt", channelId], + queryFn: () => api.inspectPrompt(channelId), + enabled: open, + staleTime: 0, + }); + + const { data: snapshotList } = useQuery({ + queryKey: ["promptSnapshots", channelId], + queryFn: () => api.listPromptSnapshots(channelId), + enabled: open && view === "history", + staleTime: 0, + }); + + const { data: snapshotDetail, isLoading: snapshotLoading } = useQuery({ + queryKey: ["promptSnapshot", channelId, selectedSnapshot], + queryFn: () => api.getPromptSnapshot(channelId, selectedSnapshot!), + enabled: open && selectedSnapshot !== null, + staleTime: 0, + }); + + const captureMutation = useMutation({ + mutationFn: (enabled: boolean) => api.setPromptCapture(channelId, enabled), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["inspectPrompt", channelId] }); + queryClient.invalidateQueries({ queryKey: ["promptSnapshots", channelId] }); + }, + }); + + const captureEnabled = data?.capture_enabled ?? false; + + const handleToggleCapture = useCallback(() => { + captureMutation.mutate(!captureEnabled); + }, [captureMutation, captureEnabled]); + + // Determine what to display + const systemPrompt = view === "current" ? data?.system_prompt : snapshotDetail?.system_prompt; + const history = view === "current" ? data?.history : snapshotDetail?.history; + const totalChars = view === "current" ? (data?.total_chars ?? 0) : (snapshotDetail?.system_prompt_chars ?? 0); + const historyLength = view === "current" ? (data?.history_length ?? 0) : (snapshotDetail?.history_length ?? 0); + const showContent = view === "current" || selectedSnapshot !== null; + const contentLoading = view === "current" ? isLoading : snapshotLoading; + + return ( + + + + Prompt Inspector + {showContent && !contentLoading && systemPrompt != null && ( +
+ {totalChars.toLocaleString()} chars + {historyLength} history messages + {view === "history" && snapshotDetail && ( + {new Date(snapshotDetail.timestamp_ms).toLocaleString()} + )} +
+ )} +
+ +
+ {/* Sidebar */} +
+
+ { setView("current"); setSelectedSnapshot(null); }} + > + Current + + { setView("history"); setSelectedSnapshot(null); }} + > + History + +
+ +
+ +
+ Capture + +
+ + {view === "history" && ( +
+ {!captureEnabled && ( +

+ Enable capture to record prompt snapshots on each LLM turn. +

+ )} + {captureEnabled && snapshotList?.snapshots.length === 0 && ( +

+ No snapshots yet. Send a message to capture. +

+ )} + {snapshotList?.snapshots.map((snapshot) => ( + setSelectedSnapshot(snapshot.timestamp_ms)} + /> + ))} +
+ )} +
+ + {/* Main content */} +
+ {contentLoading && ( +
+ Loading... +
+ )} + {error && ( +
+ Failed to load prompt: {error instanceof Error ? error.message : "Unknown error"} +
+ )} + {data?.error && ( +
+ {data.message} +
+ )} + {view === "history" && selectedSnapshot === null && !snapshotLoading && ( +
+ + {captureEnabled + ? "Select a snapshot from the sidebar" + : "Enable capture to start recording prompt snapshots"} + +
+ )} + {showContent && !contentLoading && systemPrompt != null && ( +
+								{"--- SYSTEM PROMPT ---\n\n"}
+								{systemPrompt}
+								{history != null && (
+									<>
+										{"\n\n--- MESSAGES ---\n"}
+										{renderRawHistory(history)}
+									
+								)}
+							
+ )} +
+
+ +
+ +
+ +
+ ); +} + +/** Render the message history as raw text, exactly as the model sees it. */ +function renderRawHistory(history: unknown): string { + const messages = Array.isArray(history) ? history : []; + if (messages.length === 0) return "\n(empty)"; + + const lines: string[] = []; + for (const message of messages) { + const role = message.role ?? "unknown"; + const parts = extractTextParts(message); + lines.push(`\n[${role}]`); + if (parts.length === 0) { + lines.push("(empty)"); + } else { + lines.push(parts.join("\n")); + } + } + return lines.join("\n"); +} + +/** + * Extract all text parts from a rig Message, including tool call/result + * representations. Returns the text exactly as the model would interpret it. + * + * Rig serializes: + * - UserContent with `#[serde(tag = "type")]` -> `{type: "text", text: "..."}` + * - AssistantContent with `#[serde(untagged)]` -> `{text: "..."}` (no type field), + * tool calls are `{id, function: {name, arguments}}` + */ +function extractTextParts(message: any): string[] { + const parts: string[] = []; + const content = message.content; + + if (typeof content === "string") { + parts.push(content); + } else if (Array.isArray(content)) { + for (const block of content) { + if (block.type === "text" && typeof block.text === "string") { + parts.push(block.text); + } else if (!block.type && typeof block.text === "string") { + parts.push(block.text); + } else if (block.type === "toolresult") { + const resultText = formatToolResultText(block.content); + parts.push(`[tool_result id=${block.id}] ${resultText}`); + } else if (block.function && typeof block.function === "object") { + const args = typeof block.function.arguments === "string" + ? block.function.arguments + : JSON.stringify(block.function.arguments); + parts.push(`[tool_use ${block.function.name}] ${args}`); + } else if (Array.isArray(block.reasoning)) { + parts.push(`[thinking] ${block.reasoning.join("\n")}`); + } + } + } else if (content && typeof content === "object") { + if (typeof content.text === "string") { + parts.push(content.text); + } else if (content.function) { + const args = typeof content.function.arguments === "string" + ? content.function.arguments + : JSON.stringify(content.function.arguments); + parts.push(`[tool_use ${content.function.name}] ${args}`); + } else if (content.type === "toolresult") { + const resultText = formatToolResultText(content.content); + parts.push(`[tool_result id=${content.id}] ${resultText}`); + } + } + + return parts; +} + +function formatToolResultText(content: any): string { + if (typeof content === "string") return content; + if (Array.isArray(content)) { + return content + .map((c: any) => (typeof c.text === "string" ? c.text : JSON.stringify(c))) + .join(" "); + } + return JSON.stringify(content); +} + +function SidebarButton({ + active, + onClick, + children, +}: { + active: boolean; + onClick: () => void; + children: React.ReactNode; +}) { + return ( + + ); +} + +function SnapshotListItem({ + snapshot, + selected, + onClick, +}: { + snapshot: PromptSnapshotSummary; + selected: boolean; + onClick: () => void; +}) { + const time = new Date(snapshot.timestamp_ms); + const timeStr = time.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); + const dateStr = time.toLocaleDateString([], { month: "short", day: "numeric" }); + const preview = snapshot.user_message.length > 60 + ? snapshot.user_message.slice(0, 60) + "..." + : snapshot.user_message; + + return ( + + ); +} diff --git a/interface/src/routes/ChannelDetail.tsx b/interface/src/routes/ChannelDetail.tsx index 7e0dea1e2..35e135d69 100644 --- a/interface/src/routes/ChannelDetail.tsx +++ b/interface/src/routes/ChannelDetail.tsx @@ -6,9 +6,10 @@ import { isOpenCodeWorker, type ChannelLiveState, type ActiveWorker, type Active import { CortexChatPanel } from "@/components/CortexChatPanel"; import { LiveDuration } from "@/components/LiveDuration"; import { Markdown } from "@/components/Markdown"; +import { PromptInspectModal } from "@/components/PromptInspectModal"; import { formatTimestamp, platformIcon, platformColor } from "@/lib/format"; import { Button } from "@/ui"; -import { Cancel01Icon, IdeaIcon } from "@hugeicons/core-free-icons"; +import { Cancel01Icon, IdeaIcon, CodeIcon } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; interface ChannelDetailProps { @@ -292,6 +293,7 @@ export function ChannelDetail({ agentId, channelId, channel, liveState, onLoadMo const activeBranchCount = Object.keys(branches).length; const hasActivity = activeWorkerCount > 0 || activeBranchCount > 0; const [cortexOpen, setCortexOpen] = useState(true); + const [inspectOpen, setInspectOpen] = useState(false); const scrollRef = useRef(null); const sentinelRef = useRef(null); @@ -381,6 +383,14 @@ export function ChannelDetail({ agentId, channelId, channel, liveState, onLoadMo )}
+
+ + {/* Cortex chat panel */} {cortexOpen && ( diff --git a/src/agent.rs b/src/agent.rs index a375d7457..e6aa74db7 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -13,6 +13,7 @@ pub mod ingestion; #[cfg(test)] mod invariant_harness; pub mod process_control; +pub mod prompt_snapshot; pub mod status; pub mod worker; diff --git a/src/agent/channel.rs b/src/agent/channel.rs index 14e488a33..843242ca9 100644 --- a/src/agent/channel.rs +++ b/src/agent/channel.rs @@ -109,6 +109,8 @@ pub struct ChannelState { pub channel_store: ChannelStore, pub screenshot_dir: std::path::PathBuf, pub logs_dir: std::path::PathBuf, + /// Prompt snapshot store for debugging prompt construction. + pub prompt_snapshot_store: Option>, } impl ChannelState { @@ -403,6 +405,7 @@ impl Channel { event_rx: broadcast::Receiver, screenshot_dir: std::path::PathBuf, logs_dir: std::path::PathBuf, + prompt_snapshot_store: Option>, ) -> (Self, mpsc::Sender) { let process_id = ProcessId::Channel(id.clone()); let hook = SpacebotHook::new( @@ -441,6 +444,7 @@ impl Channel { channel_store: channel_store.clone(), screenshot_dir, logs_dir, + prompt_snapshot_store, }; // Each channel gets its own isolated tool server to avoid races between @@ -2254,6 +2258,9 @@ impl Channel { }; let history_len_before = history.len(); + // ── Prompt snapshot capture (fire-and-forget) ── + self.maybe_capture_snapshot(system_prompt, user_text, &history); + let mut result = self.hook.prompt_once(&agent, &mut history, user_text).await; // If the LLM responded with text that looks like tool call syntax, it failed @@ -3019,6 +3026,62 @@ impl Channel { } } } + + /// If prompt capture is enabled for this channel, snapshot the current + /// system prompt sections and conversation history. The save is + /// fire-and-forget so it never blocks the agentic loop. + fn maybe_capture_snapshot( + &self, + system_prompt: &str, + user_message: &str, + history: &[rig::message::Message], + ) { + // 1. Check if we have a snapshot store. + let snapshot_store = match self.state.prompt_snapshot_store.as_ref() { + Some(store) => store.clone(), + None => return, + }; + + // 2. Check if capture is enabled via settings. + let rc = &self.deps.runtime_config; + let capture_enabled = rc + .settings + .load() + .as_ref() + .as_ref() + .map(|settings| settings.prompt_capture_enabled(&self.id)) + .unwrap_or(false); + if !capture_enabled { + return; + } + + // 3. Serialize history and build the snapshot. + let history_json = serde_json::to_value(history).unwrap_or(serde_json::Value::Null); + let history_length = history.len(); + let system_prompt_chars = system_prompt.len(); + + let snapshot = crate::agent::prompt_snapshot::PromptSnapshot { + channel_id: self.id.to_string(), + timestamp_ms: chrono::Utc::now().timestamp_millis(), + user_message: user_message.to_string(), + system_prompt: system_prompt.to_string(), + system_prompt_chars, + history: history_json, + history_length, + }; + + // 5. Fire-and-forget save. + let channel_id = self.id.clone(); + tokio::spawn(async move { + if let Err(error) = snapshot_store.save(&snapshot) { + tracing::warn!( + channel_id = %channel_id, + %error, + "failed to save prompt snapshot" + ); + } + }); + } } #[cfg(test)] diff --git a/src/agent/prompt_snapshot.rs b/src/agent/prompt_snapshot.rs new file mode 100644 index 000000000..d8232b064 --- /dev/null +++ b/src/agent/prompt_snapshot.rs @@ -0,0 +1,291 @@ +//! Prompt snapshot store for debugging channel prompt construction. +//! +//! Stores per-turn snapshots of the full system prompt (broken into named +//! sections) and the conversation history at the time of each LLM call. +//! Uses a dedicated redb database (`prompt_snapshots.redb`) so it can be +//! deleted independently without affecting settings or secrets. + +use redb::{Database, ReadableTable, TableDefinition}; +use serde::{Deserialize, Serialize}; +use std::path::Path; +use std::sync::Arc; + +/// Table: channel_id:timestamp_ms -> JSON-encoded PromptSnapshot +const SNAPSHOTS_TABLE: TableDefinition<&str, &[u8]> = TableDefinition::new("prompt_snapshots"); + +/// A complete snapshot of what the LLM sees on a given turn. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PromptSnapshot { + /// Channel that produced this snapshot. + pub channel_id: String, + /// Unix timestamp in milliseconds when the snapshot was captured. + pub timestamp_ms: i64, + /// The user message that triggered this turn. + pub user_message: String, + /// The full rendered system prompt, exactly as sent to the model. + pub system_prompt: String, + /// Total character count of the rendered system prompt. + pub system_prompt_chars: usize, + /// The conversation history as serialized rig Messages. + pub history: serde_json::Value, + /// Number of messages in the history. + pub history_length: usize, +} + +/// Summary of a snapshot for listing (without the full content). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PromptSnapshotSummary { + pub timestamp_ms: i64, + pub user_message: String, + pub system_prompt_chars: usize, + pub history_length: usize, +} + +/// Persistent store for prompt snapshots, backed by a dedicated redb. +pub struct PromptSnapshotStore { + db: Arc, +} + +impl PromptSnapshotStore { + /// Open or create the snapshot store at the given path. + pub fn new(path: &Path) -> crate::error::Result { + let db = Database::create(path).map_err(|error| { + crate::error::SettingsError::Other(format!( + "failed to open prompt snapshot db: {error}" + )) + })?; + + // Initialize the table if it doesn't exist. + let write_txn = db.begin_write().map_err(|error| { + crate::error::SettingsError::Other(format!("failed to begin write txn: {error}")) + })?; + { + let _ = write_txn.open_table(SNAPSHOTS_TABLE).map_err(|error| { + crate::error::SettingsError::Other(format!( + "failed to open snapshots table: {error}" + )) + })?; + } + write_txn.commit().map_err(|error| { + crate::error::SettingsError::Other(format!("failed to commit write txn: {error}")) + })?; + + Ok(Self { db: Arc::new(db) }) + } + + /// Composite key: `{channel_id}:{timestamp_ms}`. + fn key(channel_id: &str, timestamp_ms: i64) -> String { + format!("{channel_id}:{timestamp_ms}") + } + + /// Store a snapshot. + pub fn save(&self, snapshot: &PromptSnapshot) -> crate::error::Result<()> { + let key = Self::key(&snapshot.channel_id, snapshot.timestamp_ms); + let data = serde_json::to_vec(snapshot).map_err(|error| { + crate::error::SettingsError::Other(format!("failed to serialize snapshot: {error}")) + })?; + + let write_txn = + self.db + .begin_write() + .map_err(|error| crate::error::SettingsError::WriteFailed { + key: key.clone(), + details: error.to_string(), + })?; + { + let mut table = write_txn.open_table(SNAPSHOTS_TABLE).map_err(|error| { + crate::error::SettingsError::WriteFailed { + key: key.clone(), + details: error.to_string(), + } + })?; + table + .insert(key.as_str(), data.as_slice()) + .map_err(|error| crate::error::SettingsError::WriteFailed { + key: key.clone(), + details: error.to_string(), + })?; + } + write_txn + .commit() + .map_err(|error| crate::error::SettingsError::WriteFailed { + key, + details: error.to_string(), + })?; + + Ok(()) + } + + /// List snapshot summaries for a channel, newest first. + pub fn list( + &self, + channel_id: &str, + limit: usize, + ) -> crate::error::Result> { + let prefix = format!("{channel_id}:"); + let read_txn = + self.db + .begin_read() + .map_err(|error| crate::error::SettingsError::ReadFailed { + key: prefix.clone(), + details: error.to_string(), + })?; + + let table = read_txn.open_table(SNAPSHOTS_TABLE).map_err(|error| { + crate::error::SettingsError::ReadFailed { + key: prefix.clone(), + details: error.to_string(), + } + })?; + + let mut summaries = Vec::new(); + // Scan all keys with the channel prefix. redb iterates in key order + // (lexicographic), and our keys are `channel_id:timestamp_ms`, so + // entries for the same channel are grouped and sorted by time. + let range = table.range(prefix.as_str()..).map_err(|error| { + crate::error::SettingsError::ReadFailed { + key: prefix.clone(), + details: error.to_string(), + } + })?; + + for entry in range { + let entry = entry.map_err(|error| crate::error::SettingsError::ReadFailed { + key: prefix.clone(), + details: error.to_string(), + })?; + let key = entry.0.value(); + if !key.starts_with(&prefix) { + break; // Past our channel's entries. + } + let data = entry.1.value(); + if let Ok(snapshot) = serde_json::from_slice::(data) { + summaries.push(PromptSnapshotSummary { + timestamp_ms: snapshot.timestamp_ms, + user_message: snapshot.user_message, + system_prompt_chars: snapshot.system_prompt_chars, + history_length: snapshot.history_length, + }); + } + } + + // Reverse to get newest first, then truncate. + summaries.reverse(); + summaries.truncate(limit); + + Ok(summaries) + } + + /// Retrieve a specific snapshot. + pub fn get( + &self, + channel_id: &str, + timestamp_ms: i64, + ) -> crate::error::Result> { + let key = Self::key(channel_id, timestamp_ms); + let read_txn = + self.db + .begin_read() + .map_err(|error| crate::error::SettingsError::ReadFailed { + key: key.clone(), + details: error.to_string(), + })?; + + let table = read_txn.open_table(SNAPSHOTS_TABLE).map_err(|error| { + crate::error::SettingsError::ReadFailed { + key: key.clone(), + details: error.to_string(), + } + })?; + + match table.get(key.as_str()) { + Ok(Some(data)) => { + let snapshot = + serde_json::from_slice::(data.value()).map_err(|error| { + crate::error::SettingsError::ReadFailed { + key, + details: format!("failed to deserialize snapshot: {error}"), + } + })?; + Ok(Some(snapshot)) + } + Ok(None) => Ok(None), + Err(error) => Err(crate::error::SettingsError::ReadFailed { + key, + details: error.to_string(), + } + .into()), + } + } + + /// Delete all snapshots for a channel. + pub fn clear_channel(&self, channel_id: &str) -> crate::error::Result { + let prefix = format!("{channel_id}:"); + let write_txn = + self.db + .begin_write() + .map_err(|error| crate::error::SettingsError::WriteFailed { + key: prefix.clone(), + details: error.to_string(), + })?; + + let mut removed = 0; + { + let mut table = write_txn.open_table(SNAPSHOTS_TABLE).map_err(|error| { + crate::error::SettingsError::WriteFailed { + key: prefix.clone(), + details: error.to_string(), + } + })?; + + // Collect keys to remove (can't mutate while iterating). + let keys: Vec = { + let range = table.range(prefix.as_str()..).map_err(|error| { + crate::error::SettingsError::ReadFailed { + key: prefix.clone(), + details: error.to_string(), + } + })?; + let mut result = Vec::new(); + for entry in range { + let entry = entry.map_err(|error| crate::error::SettingsError::ReadFailed { + key: prefix.clone(), + details: error.to_string(), + })?; + let key = entry.0.value(); + if !key.starts_with(&prefix) { + break; + } + result.push(key.to_string()); + } + result + }; + + for key in &keys { + table.remove(key.as_str()).map_err(|error| { + crate::error::SettingsError::WriteFailed { + key: key.clone(), + details: error.to_string(), + } + })?; + removed += 1; + } + } + + write_txn + .commit() + .map_err(|error| crate::error::SettingsError::WriteFailed { + key: prefix, + details: error.to_string(), + })?; + + Ok(removed) + } +} + +impl std::fmt::Debug for PromptSnapshotStore { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PromptSnapshotStore") + .finish_non_exhaustive() + } +} diff --git a/src/api/channels.rs b/src/api/channels.rs index fb1f4f423..4e128e787 100644 --- a/src/api/channels.rs +++ b/src/api/channels.rs @@ -385,9 +385,9 @@ pub(super) struct PromptInspectQuery { } /// Render the full prompt that the LLM would see on the next turn for a -/// given channel. Returns the system prompt and conversation history -/// exactly as they would be assembled — useful for debugging prompt -/// construction, status block content, and context window usage. +/// given channel. Returns the rendered system prompt and conversation +/// history — useful for debugging prompt construction, coalescing, +/// status block content, and context window usage. pub(super) async fn inspect_prompt( State(state): State>, Query(query): Query, @@ -397,11 +397,19 @@ pub(super) async fn inspect_prompt( states.get(&query.channel_id).cloned() }; - let channel_state = channel_state.ok_or(StatusCode::NOT_FOUND)?; + let channel_state = match channel_state { + Some(cs) => cs, + None => { + return Ok(Json(serde_json::json!({ + "error": "channel_not_active", + "message": "Channel is not currently active in memory. Send a new message to activate this channel.", + }))); + } + }; let rc = &channel_state.deps.runtime_config; let prompt_engine = rc.prompts.load(); - // ── Identity + memory bulletin ── + // ── Gather all dynamic sections ── let identity_context = rc.identity.load().render(); let memory_bulletin = rc.memory_bulletin.load(); let skills = rc.skills.load(); @@ -409,7 +417,6 @@ pub(super) async fn inspect_prompt( .render_channel_prompt(&prompt_engine) .unwrap_or_default(); - // ── Worker capabilities ── let browser_enabled = rc.browser_config.load().enabled; let web_search_enabled = rc.brave_search_key.load().is_some(); let opencode_enabled = rc.opencode.load().enabled; @@ -423,7 +430,6 @@ pub(super) async fn inspect_prompt( ) .unwrap_or_default(); - // ── Status block with system info ── let system_info = crate::agent::status::SystemInfo::from_runtime_config( rc.as_ref(), &channel_state.deps.sandbox, @@ -435,7 +441,6 @@ pub(super) async fn inspect_prompt( status.render_full(¤t_time_line, &system_info) }; - // ── Conversation context (from DB channel metadata) ── let conversation_context = match channel_state.channel_store.get(&query.channel_id).await { Ok(Some(info)) => { let server_name = info @@ -459,9 +464,12 @@ pub(super) async fn inspect_prompt( let sandbox_enabled = channel_state.deps.sandbox.containment_active(); + // ── Render the full system prompt ── + // This is a best-effort reconstruction from the API layer. It lacks + // available_channels, org_context, adapter_prompt, and project_context + // (those require Channel methods not available from ChannelState). + // Captured snapshots store the exact prompt the model received. let empty_to_none = |s: String| if s.is_empty() { None } else { Some(s) }; - - // ── Render system prompt ── let system_prompt = prompt_engine .render_channel_prompt_with_links( empty_to_none(identity_context), @@ -470,34 +478,177 @@ pub(super) async fn inspect_prompt( worker_capabilities, conversation_context, empty_to_none(status_text), - None, // coalesce_hint — only relevant during batched dispatch - None, // available_channels — requires messaging manager context + None, // coalesce_hint + None, // available_channels — not available from API layer sandbox_enabled, - None, // org_context — requires link resolution - None, // adapter_prompt — requires adapter context - None, // project_context — requires project store queries + None, // org_context — not available from API layer + None, // adapter_prompt — not available from API layer + None, // project_context — not available from API layer ) - .map_err(|error| { - tracing::warn!(%error, "failed to render channel prompt for inspect"); - StatusCode::INTERNAL_SERVER_ERROR - })?; + .unwrap_or_default(); + + let total_chars = system_prompt.len(); // ── History ── let history = channel_state.history.read().await; let history_json = serde_json::to_value(&*history).unwrap_or(serde_json::Value::Null); + // ── Capture toggle state ── + let capture_enabled = rc + .settings + .load() + .as_ref() + .as_ref() + .map(|s| s.prompt_capture_enabled(&query.channel_id)) + .unwrap_or(false); + // ── Build response ── let response = serde_json::json!({ "channel_id": query.channel_id, "system_prompt": system_prompt, - "system_prompt_chars": system_prompt.len(), + "total_chars": total_chars, "history_length": history.len(), "history": history_json, + "capture_enabled": capture_enabled, }); Ok(Json(response)) } +// ── Prompt Capture Toggle ────────────────────────────────────────── + +#[derive(Deserialize)] +pub(super) struct PromptCaptureBody { + channel_id: String, + enabled: bool, +} + +/// Enable or disable prompt capture for a specific channel. +pub(super) async fn set_prompt_capture( + State(state): State>, + Json(body): Json, +) -> Result, StatusCode> { + // Find the agent's runtime config that owns this channel. + let runtime_config = { + let configs = state.runtime_configs.load(); + let channel_state = state.channel_states.read().await; + channel_state + .get(&body.channel_id) + .map(|cs| cs.deps.runtime_config.clone()) + .or_else(|| { + // Fall back to first agent config if channel not active + configs.values().next().cloned() + }) + }; + + let rc = runtime_config.ok_or(StatusCode::NOT_FOUND)?; + let settings = rc.settings.load(); + let settings = settings.as_ref().as_ref().ok_or_else(|| { + tracing::warn!("no settings store available for prompt capture toggle"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + settings + .set_prompt_capture(&body.channel_id, body.enabled) + .map_err(|error| { + tracing::warn!(%error, "failed to set prompt capture"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(Json(serde_json::json!({ + "channel_id": body.channel_id, + "capture_enabled": body.enabled, + }))) +} + +// ── Prompt Snapshot History ──────────────────────────────────────── + +#[derive(Deserialize)] +pub(super) struct SnapshotListQuery { + channel_id: String, + #[serde(default = "default_snapshot_limit")] + limit: usize, +} + +fn default_snapshot_limit() -> usize { + 50 +} + +/// List prompt snapshots for a channel (newest first). +pub(super) async fn list_prompt_snapshots( + State(state): State>, + Query(query): Query, +) -> Result, StatusCode> { + let snapshot_store = find_snapshot_store(&state, &query.channel_id).await?; + + let summaries = snapshot_store + .list(&query.channel_id, query.limit) + .map_err(|error| { + tracing::warn!(%error, "failed to list prompt snapshots"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(Json(serde_json::json!({ + "channel_id": query.channel_id, + "snapshots": summaries, + }))) +} + +#[derive(Deserialize)] +pub(super) struct SnapshotGetQuery { + channel_id: String, + timestamp_ms: i64, +} + +/// Retrieve a specific prompt snapshot. +pub(super) async fn get_prompt_snapshot( + State(state): State>, + Query(query): Query, +) -> Result, StatusCode> { + let snapshot_store = find_snapshot_store(&state, &query.channel_id).await?; + + let snapshot = snapshot_store + .get(&query.channel_id, query.timestamp_ms) + .map_err(|error| { + tracing::warn!(%error, "failed to get prompt snapshot"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + match snapshot { + Some(snapshot) => Ok(Json(serde_json::to_value(&snapshot).unwrap_or_default())), + None => Err(StatusCode::NOT_FOUND), + } +} + +/// Find the prompt snapshot store for a channel. +async fn find_snapshot_store( + state: &ApiState, + channel_id: &str, +) -> Result, StatusCode> { + // Try to find via active channel state first. + let channel_state = { + let states = state.channel_states.read().await; + states.get(channel_id).cloned() + }; + + if let Some(cs) = channel_state + && let Some(store) = cs.prompt_snapshot_store.as_ref() + { + return Ok(store.clone()); + } + + // Fall back to runtime configs. + let configs = state.runtime_configs.load(); + for rc in configs.values() { + let store = rc.prompt_snapshots.load(); + if let Some(store) = store.as_ref().as_ref() { + return Ok(store.clone()); + } + } + + Err(StatusCode::NOT_FOUND) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/api/server.rs b/src/api/server.rs index a108977b7..4d4451d38 100644 --- a/src/api/server.rs +++ b/src/api/server.rs @@ -91,6 +91,18 @@ pub async fn start_http_server( .route("/channels/messages", get(channels::channel_messages)) .route("/channels/status", get(channels::channel_status)) .route("/channels/inspect", get(channels::inspect_prompt)) + .route( + "/channels/inspect/capture", + post(channels::set_prompt_capture), + ) + .route( + "/channels/inspect/snapshots", + get(channels::list_prompt_snapshots), + ) + .route( + "/channels/inspect/snapshot", + get(channels::get_prompt_snapshot), + ) .route("/agents/workers", get(workers::list_workers)) .route("/agents/workers/detail", get(workers::worker_detail)) .route( diff --git a/src/config/runtime.rs b/src/config/runtime.rs index 3c5b5e7ff..58c36ce47 100644 --- a/src/config/runtime.rs +++ b/src/config/runtime.rs @@ -63,6 +63,8 @@ pub struct RuntimeConfig { pub cron_scheduler: ArcSwap>>, /// Settings store for agent-specific configuration. pub settings: ArcSwap>>, + /// Prompt snapshot store for debugging prompt construction. + pub prompt_snapshots: ArcSwap>>, /// Tracks whether listen_only_mode is explicitly configured via agent/env. /// When set, channel-local persisted values must not override it. pub channel_listen_only_explicit: ArcSwap>, @@ -134,6 +136,7 @@ impl RuntimeConfig { cron_store: ArcSwap::from_pointee(None), cron_scheduler: ArcSwap::from_pointee(None), settings: ArcSwap::from_pointee(None), + prompt_snapshots: ArcSwap::from_pointee(None), channel_listen_only_explicit: ArcSwap::from_pointee(None), secrets: ArcSwap::from_pointee(None), sandbox: Arc::new(ArcSwap::from_pointee(agent_config.sandbox.clone())), diff --git a/src/cron/scheduler.rs b/src/cron/scheduler.rs index 15ff88652..776ccd000 100644 --- a/src/cron/scheduler.rs +++ b/src/cron/scheduler.rs @@ -856,6 +856,7 @@ async fn run_cron_job(job: &CronJob, context: &CronContext) -> Result<()> { event_rx, context.screenshot_dir.clone(), context.logs_dir.clone(), + None, // cron channels don't capture prompt snapshots ); // Spawn the channel's event loop diff --git a/src/main.rs b/src/main.rs index 3f032db9c..f532c70b5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1663,6 +1663,13 @@ async fn run( let event_rx = agent.deps.event_tx.subscribe(); let channel_id: spacebot::ChannelId = Arc::from(conversation_id.as_str()); + let snapshot_store = agent + .deps + .runtime_config + .prompt_snapshots + .load() + .as_ref() + .clone(); let (channel, channel_tx) = spacebot::agent::channel::Channel::new( channel_id, agent.deps.clone(), @@ -1670,6 +1677,7 @@ async fn run( event_rx, agent.config.screenshot_dir(), agent.config.logs_dir(), + snapshot_store, ); agent .deps @@ -1907,6 +1915,7 @@ async fn run( let channel_id: spacebot::ChannelId = Arc::from(conversation_id.as_str()); + let snapshot_store = agent.deps.runtime_config.prompt_snapshots.load().as_ref().clone(); let (channel, channel_tx) = spacebot::agent::channel::Channel::new( channel_id, agent.deps.clone(), @@ -1914,6 +1923,7 @@ async fn run( event_rx, agent.config.screenshot_dir(), agent.config.logs_dir(), + snapshot_store, ); agent .deps @@ -2449,6 +2459,18 @@ async fn initialize_agents( })?, ); + // Per-agent prompt snapshot store (separate redb, easy to delete) + let snapshot_path = agent_config.data_dir.join("prompt_snapshots.redb"); + let prompt_snapshot_store = Arc::new( + spacebot::agent::prompt_snapshot::PromptSnapshotStore::new(&snapshot_path) + .with_context(|| { + format!( + "failed to initialize prompt snapshot store for agent '{}'", + agent_config.id + ) + })?, + ); + // Per-agent memory system let memory_store = spacebot::memory::MemoryStore::with_agent_id(db.sqlite.clone(), &agent_config.id); @@ -2513,6 +2535,9 @@ async fn initialize_agents( .find(|agent| agent.id == agent_config.id) .and_then(|agent| agent.channel.map(|channel| channel.listen_only_mode)); runtime_config.set_settings(settings_store.clone(), explicit_listen_only); + runtime_config + .prompt_snapshots + .store(Arc::new(Some(prompt_snapshot_store.clone()))); if let Err(error) = settings_store.set_worker_log_mode(config.defaults.worker_log_mode) { tracing::warn!(%error, agent = %agent_config.id, "failed to set worker_log_mode from config"); } diff --git a/src/settings/store.rs b/src/settings/store.rs index 668236c6f..353363422 100644 --- a/src/settings/store.rs +++ b/src/settings/store.rs @@ -14,6 +14,7 @@ pub const WORKER_LOG_MODE_KEY: &str = "worker_log_mode"; /// Key for channel listen-only mode setting. pub const CHANNEL_LISTEN_ONLY_MODE_KEY: &str = "channel_listen_only_mode"; const CHANNEL_LISTEN_ONLY_MODE_PREFIX: &str = "channel_listen_only_mode:"; +const PROMPT_CAPTURE_PREFIX: &str = "prompt_capture:"; /// How worker execution logs are stored. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] @@ -217,6 +218,18 @@ impl SettingsStore { let key = Self::channel_listen_only_mode_key(channel_id); self.set_raw(&key, if enabled { "true" } else { "false" }) } + + /// Check whether prompt capture is enabled for a specific channel. + pub fn prompt_capture_enabled(&self, channel_id: &str) -> bool { + let key = format!("{PROMPT_CAPTURE_PREFIX}{channel_id}"); + matches!(self.get_raw(&key), Ok(v) if v == "true") + } + + /// Enable or disable prompt capture for a specific channel. + pub fn set_prompt_capture(&self, channel_id: &str, enabled: bool) -> Result<()> { + let key = format!("{PROMPT_CAPTURE_PREFIX}{channel_id}"); + self.set_raw(&key, if enabled { "true" } else { "false" }) + } } impl std::fmt::Debug for SettingsStore { diff --git a/tests/context_dump.rs b/tests/context_dump.rs index e6fb1a8bc..860cbb138 100644 --- a/tests/context_dump.rs +++ b/tests/context_dump.rs @@ -244,6 +244,7 @@ async fn dump_channel_context() { screenshot_dir: std::path::PathBuf::from("/tmp/screenshots"), logs_dir: std::path::PathBuf::from("/tmp/logs"), reply_target_message_id: Arc::new(tokio::sync::RwLock::new(None)), + prompt_snapshot_store: None, }; let tool_server = rig::tool::server::ToolServer::new().run(); @@ -476,6 +477,7 @@ async fn dump_all_contexts() { screenshot_dir: std::path::PathBuf::from("/tmp/screenshots"), logs_dir: std::path::PathBuf::from("/tmp/logs"), reply_target_message_id: Arc::new(tokio::sync::RwLock::new(None)), + prompt_snapshot_store: None, }; let channel_tool_server = rig::tool::server::ToolServer::new().run(); let skip_flag = spacebot::tools::new_skip_flag(); From e840d78eff4bfdd6912d1630fe8666ecdad7e093 Mon Sep 17 00:00:00 2001 From: James Pine Date: Mon, 9 Mar 2026 05:13:06 -0700 Subject: [PATCH 3/3] address PR #374 review feedback - Surface serde_json::to_value failure as 500 in inspect API - Use .chars().count() for system_prompt_chars (was bytes) - Add context window to render_for_worker (doc/impl mismatch) - Extract build_worker_status_text() helper to deduplicate - Inject temporal/status context into OpenCode workers - Handle snapshot history serialization failure (log + skip) - Make prompt snapshot store init non-fatal - Rename rc to runtime_config in build_system_info - Count only enabled jobs in job_count() - Remove unused PromptSnapshot import - Add aria-label to inspect button --- .../src/components/PromptInspectModal.tsx | 1 - interface/src/routes/ChannelDetail.tsx | 1 + src/agent/channel.rs | 20 ++++++--- src/agent/channel_dispatch.rs | 43 +++++++++++++------ src/agent/status.rs | 1 + src/api/channels.rs | 7 ++- src/cron/scheduler.rs | 9 +++- src/main.rs | 27 +++++++----- 8 files changed, 74 insertions(+), 35 deletions(-) diff --git a/interface/src/components/PromptInspectModal.tsx b/interface/src/components/PromptInspectModal.tsx index b396e8dff..a1f6ceced 100644 --- a/interface/src/components/PromptInspectModal.tsx +++ b/interface/src/components/PromptInspectModal.tsx @@ -4,7 +4,6 @@ import { api, type PromptInspectResponse, type PromptSnapshotSummary, - type PromptSnapshot, } from "@/api/client"; import { Button, Dialog, DialogContent, DialogHeader, DialogTitle, Toggle } from "@/ui"; diff --git a/interface/src/routes/ChannelDetail.tsx b/interface/src/routes/ChannelDetail.tsx index 35e135d69..6f520531a 100644 --- a/interface/src/routes/ChannelDetail.tsx +++ b/interface/src/routes/ChannelDetail.tsx @@ -384,6 +384,7 @@ export function ChannelDetail({ agentId, channelId, channel, liveState, onLoadMo )}