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/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..a1f6ceced --- /dev/null +++ b/interface/src/components/PromptInspectModal.tsx @@ -0,0 +1,327 @@ +import { useState, useCallback } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { + api, + type PromptInspectResponse, + type PromptSnapshotSummary, +} 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..6f520531a 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,15 @@ export function ChannelDetail({ agentId, channelId, channel, liveState, onLoadMo )}
+
+ + {/* Cortex chat panel */} {cortexOpen && ( 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.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 9373d6287..da2347605 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}; @@ -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 @@ -1453,9 +1457,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 +2071,24 @@ impl Channel { } } + /// Build a snapshot of the system configuration for status block injection. + async fn build_system_info(&self) -> SystemInfo { + let runtime_config = &self.deps.runtime_config; + let mut info = SystemInfo::from_runtime_config(runtime_config, &self.deps.sandbox); + + // Add async-only fields that the base constructor can't populate + let cron_job_count = { + let scheduler_guard = runtime_config.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 +2113,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; @@ -2234,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 @@ -2963,8 +2990,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. @@ -3000,6 +3028,72 @@ 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 = match serde_json::to_value(history) { + Ok(value) => value, + Err(error) => { + tracing::warn!( + channel_id = %self.id, + %error, + "failed to serialize prompt history; skipping snapshot capture" + ); + return; + } + }; + let history_length = history.len(); + let system_prompt_chars = system_prompt.chars().count(); + + 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/channel_dispatch.rs b/src/agent/channel_dispatch.rs index c243a5d73..fa3cc29a2 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}; @@ -97,6 +97,21 @@ pub(crate) fn map_worker_completion_result( (result_text, notify, success) } +/// Build the worker status text (time + system info) used in worker system prompts. +/// +/// Centralises the `SystemInfo` + `TemporalContext` assembly so every worker +/// spawn/resume path produces identical status context. +fn build_worker_status_text( + runtime_config: &crate::config::RuntimeConfig, + sandbox: &crate::sandbox::Sandbox, +) -> Option { + let system_info = + crate::agent::status::SystemInfo::from_runtime_config(runtime_config, sandbox); + let temporal_context = TemporalContext::from_runtime(runtime_config); + let current_time_line = temporal_context.current_time_line(); + Some(system_info.render_for_worker(¤t_time_line)) +} + /// Spawn a branch from a ChannelState. Used by the BranchTool. pub async fn spawn_branch_from_state( state: &ChannelState, @@ -414,10 +429,9 @@ async fn spawn_worker_inner( ) -> std::result::Result { 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 worker_status_text = build_worker_status_text(rc.as_ref(), &state.deps.sandbox); + 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 +454,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 +477,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 +500,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 +601,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 { @@ -614,11 +624,15 @@ async fn spawn_opencode_worker_inner( let oc_secrets_store = state.deps.runtime_config.secrets.load().as_ref().clone(); + // Build temporal/status context so OpenCode workers get the same system + // info (time, model, context window) as builtin workers. + let worker_status_text = build_worker_status_text(rc.as_ref(), &state.deps.sandbox); + let worker = if interactive { 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(), @@ -629,6 +643,10 @@ async fn spawn_opencode_worker_inner( .write() .await .insert(worker_id, input_tx); + let worker = match worker_status_text { + Some(ref prompt) => worker.with_system_prompt(prompt), + None => worker, + }; let worker = match &oc_secrets_store { Some(store) => worker.with_secrets_store(store.clone()), None => worker, @@ -638,11 +656,15 @@ 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(), ); + let worker = match worker_status_text { + Some(ref prompt) => worker.with_system_prompt(prompt), + None => worker, + }; let worker = match &oc_secrets_store { Some(store) => worker.with_secrets_store(store.clone()), None => worker, @@ -986,6 +1008,9 @@ pub async fn resume_idle_worker_into_state( let rc = &state.deps.runtime_config; let prompt_engine = rc.prompts.load(); + + let worker_status_text = build_worker_status_text(rc.as_ref(), &state.deps.sandbox); + 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 +1031,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/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/agent/status.rs b/src/agent/status.rs index d909cb6f7..655b287b1 100644 --- a/src/agent/status.rs +++ b/src/agent/status.rs @@ -3,6 +3,130 @@ 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.push_str(&format!("Context: {} tokens\n", self.context_window)); + output + } +} + /// Live status block injected into channel context. #[derive(Debug, Clone, Default, serde::Serialize)] pub struct StatusBlock { @@ -228,14 +352,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 +460,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 +516,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 +694,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..81e863986 100644 --- a/src/api/channels.rs +++ b/src/api/channels.rs @@ -377,6 +377,281 @@ 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 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, +) -> Result, StatusCode> { + let channel_state = { + let states = state.channel_states.read().await; + states.get(&query.channel_id).cloned() + }; + + 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(); + + // ── Gather all dynamic sections ── + 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(); + + 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(); + + 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) + }; + + 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(); + + // ── 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) }; + 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 + None, // available_channels — not available from API layer + sandbox_enabled, + None, // org_context — not available from API layer + None, // adapter_prompt — not available from API layer + None, // project_context — not available from API layer + ) + .unwrap_or_default(); + + let total_chars = system_prompt.chars().count(); + + // ── History ── + let history = channel_state.history.read().await; + let history_json = serde_json::to_value(&*history).map_err(|error| { + tracing::warn!(%error, "failed to serialize channel history for inspect"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + // ── 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, + "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 e84e5535e..4d4451d38 100644 --- a/src/api/server.rs +++ b/src/api/server.rs @@ -90,6 +90,19 @@ 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( + "/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 e3be04cda..1b78f6d22 100644 --- a/src/cron/scheduler.rs +++ b/src/cron/scheduler.rs @@ -473,6 +473,16 @@ impl Scheduler { jobs.contains_key(job_id) } + /// Return the number of enabled (active) cron jobs. + pub async fn job_count(&self) -> usize { + self.jobs + .read() + .await + .values() + .filter(|job| job.enabled) + .count() + } + /// Trigger a cron job immediately, outside the timer loop. pub async fn trigger_now(&self, job_id: &str) -> Result<()> { let job = { @@ -851,6 +861,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..2969bcc4e 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,23 @@ async fn initialize_agents( })?, ); + // Per-agent prompt snapshot store (separate redb, easy to delete). + // Non-fatal: a corrupt/unwritable DB disables snapshotting for this agent. + let snapshot_path = agent_config.data_dir.join("prompt_snapshots.redb"); + let prompt_snapshot_store = + match spacebot::agent::prompt_snapshot::PromptSnapshotStore::new(&snapshot_path) { + Ok(store) => Some(Arc::new(store)), + Err(error) => { + tracing::warn!( + agent_id = %agent_config.id, + path = %snapshot_path.display(), + %error, + "failed to initialize prompt snapshot store; prompt snapshots disabled" + ); + None + } + }; + // Per-agent memory system let memory_store = spacebot::memory::MemoryStore::with_agent_id(db.sqlite.clone(), &agent_config.id); @@ -2513,6 +2540,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(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/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/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/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..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(); @@ -371,6 +372,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); @@ -475,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(); @@ -546,6 +549,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();