Skip to main content

kernex_core/
context.rs

1//! Conversation context passed to AI providers.
2
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5
6/// Strategy applied when conversation history exceeds `max_context_messages`.
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
8pub enum CompactionStrategy {
9    /// Drop the oldest messages silently (default, preserves existing behavior).
10    #[default]
11    Drop,
12    /// Summarize overflow messages and prepend the summary to the system prompt.
13    ///
14    /// Requires a [`Summarizer`](crate::traits::Summarizer) to be injected at
15    /// `build_context` time. Falls back to `Drop` if none is provided.
16    Summarize,
17}
18
19/// Controls which optional context blocks are loaded and injected.
20///
21/// Used by the runtime to skip expensive DB queries and prompt sections
22/// when the user's message doesn't need them — reducing token overhead.
23#[derive(Debug, Clone)]
24pub struct ContextNeeds {
25    /// Load semantic recall (FTS5 related past messages).
26    pub recall: bool,
27    /// Load and inject pending scheduled tasks.
28    pub pending_tasks: bool,
29    /// Inject user profile (facts) into the system prompt.
30    pub profile: bool,
31    /// Load and inject recent conversation summaries.
32    pub summaries: bool,
33    /// Load and inject recent reward outcomes.
34    pub outcomes: bool,
35    /// How to handle history overflow (default: silently drop oldest).
36    pub compact: CompactionStrategy,
37}
38
39impl Default for ContextNeeds {
40    fn default() -> Self {
41        Self {
42            recall: true,
43            pending_tasks: true,
44            profile: true,
45            summaries: true,
46            outcomes: true,
47            compact: CompactionStrategy::default(),
48        }
49    }
50}
51
52/// A single entry in the conversation history.
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct ContextEntry {
55    /// "user" or "assistant".
56    pub role: String,
57    /// The message content.
58    pub content: String,
59}
60
61/// An MCP server declared by a skill.
62#[derive(Debug, Clone, Serialize, Deserialize, Default)]
63pub struct McpServer {
64    /// Server name (used as the key in provider settings).
65    pub name: String,
66    /// Command to launch the server.
67    pub command: String,
68    /// Command-line arguments.
69    pub args: Vec<String>,
70    /// Environment variables passed to the server process.
71    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
72    pub env: HashMap<String, String>,
73}
74
75/// A simple script-based tool that runs without a full MCP server.
76///
77/// The script receives tool arguments as JSON on stdin and returns its
78/// result on stdout. Exit code 0 means success; non-zero means error.
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct Toolbox {
81    /// Tool name exposed to the AI model.
82    pub name: String,
83    /// Human-readable description shown in tool definitions.
84    pub description: String,
85    /// JSON Schema for the tool's input parameters.
86    #[serde(default = "default_object_schema")]
87    pub parameters: serde_json::Value,
88    /// Command to execute (e.g. "bash", "python3").
89    pub command: String,
90    /// Command-line arguments (e.g. ["scripts/lint.sh"]).
91    #[serde(default)]
92    pub args: Vec<String>,
93    /// Environment variables passed to the script process.
94    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
95    pub env: HashMap<String, String>,
96    /// Whether the tool subprocess may open network connections. Defaults to
97    /// `false`: sandboxed tool subprocesses are denied network egress unless
98    /// the tool declares `network = true`. Enforced at the OS sandbox layer
99    /// (full coverage on macOS Seatbelt; TCP bind/connect on Linux 6.7+).
100    #[serde(default)]
101    pub network: bool,
102    /// Parent environment variable NAMES this tool may receive, resolved at
103    /// spawn time. The spawn boundary clears the inherited environment; this
104    /// list is the declared, user-approvable opt-in for what gets re-added
105    /// (e.g. a skill that needs `GITHUB_TOKEN`). Empty = nothing extra.
106    #[serde(default, skip_serializing_if = "Vec::is_empty")]
107    pub env_passthrough: Vec<String>,
108    /// Command allow-list this tool's `command` must satisfy at execution
109    /// time, carried from the owning skill's declared permissions. Empty =
110    /// unrestricted (no allow-list was declared). Enforced by the executor
111    /// before spawning, as defense in depth behind the load-time checks.
112    #[serde(default, skip_serializing_if = "Vec::is_empty")]
113    pub allowed_commands: Vec<String>,
114    /// Keywords for dynamic tool discovery via tool search.
115    #[serde(default, skip_serializing_if = "Vec::is_empty")]
116    pub search_hints: Vec<String>,
117}
118
119/// Does `command` satisfy a declared command allow-list?
120///
121/// - An empty `allowed` list means no restriction was declared: `true`.
122/// - Entries containing `/` are full paths and must equal `command` exactly.
123/// - Entries without `/` are basenames and match any command whose final
124///   path segment equals the entry (`npx` permits `/usr/bin/npx`).
125///
126/// Single source of truth for allow-list semantics: the skills loader and
127/// the tool executor both call this, so load-time and run-time enforcement
128/// cannot drift apart.
129pub fn command_matches_allowlist(allowed: &[String], command: &str) -> bool {
130    if allowed.is_empty() {
131        return true;
132    }
133    let basename = command.rsplit('/').next().unwrap_or(command);
134    allowed.iter().any(|entry| {
135        if entry.contains('/') {
136            entry == command
137        } else {
138            entry == basename
139        }
140    })
141}
142
143fn default_object_schema() -> serde_json::Value {
144    serde_json::json!({"type": "object"})
145}
146
147fn is_false(b: &bool) -> bool {
148    !b
149}
150
151/// Conversation context passed to an AI provider.
152#[derive(Clone, Serialize, Deserialize)]
153pub struct Context {
154    /// System prompt prepended to every request.
155    pub system_prompt: String,
156    /// Conversation history (oldest first).
157    pub history: Vec<ContextEntry>,
158    /// The current user message.
159    pub current_message: String,
160    /// MCP servers to activate for this request.
161    #[serde(default)]
162    pub mcp_servers: Vec<McpServer>,
163    /// Script-based tools to activate for this request.
164    #[serde(default, skip_serializing_if = "Vec::is_empty")]
165    pub toolboxes: Vec<Toolbox>,
166    /// Override the provider's default max_turns.
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub max_turns: Option<u32>,
169    /// Stop the agentic loop once cumulative billed tokens reach this budget.
170    /// `None` means unlimited. Checked between turns: a completed final answer
171    /// is always returned even if it crosses the budget; the budget only
172    /// prevents the loop from starting further turns.
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    pub token_budget: Option<u64>,
175    /// Override the provider's default allowed tools.
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    pub allowed_tools: Option<Vec<String>>,
178    /// Override the provider's default model.
179    #[serde(default, skip_serializing_if = "Option::is_none")]
180    pub model: Option<String>,
181    /// Session ID for conversation continuity (e.g. Claude Code CLI).
182    #[serde(default, skip_serializing_if = "Option::is_none")]
183    pub session_id: Option<String>,
184    /// Agent name for agent-mode providers. When set, the provider loads
185    /// the agent definition and `to_prompt_string()` emits only `current_message`.
186    #[serde(default, skip_serializing_if = "Option::is_none")]
187    pub agent_name: Option<String>,
188    /// Hook runner for tool lifecycle events. Not serialized.
189    #[serde(skip)]
190    pub hook_runner: Option<std::sync::Arc<dyn crate::hooks::HookRunner>>,
191    /// Declarative allow/deny permission rules applied before each tool call.
192    /// Not serialized — set at runtime by the caller.
193    #[serde(skip)]
194    pub permission_rules: Option<std::sync::Arc<crate::permissions::PermissionRules>>,
195    /// Request thinking (chain-of-thought) for Anthropic requests. When true,
196    /// the Anthropic provider sends `thinking: {"type": "adaptive"}` in the
197    /// request body (GA on the Claude 4.6+ family; it also enables interleaved
198    /// thinking between tool calls). When false, the field is omitted and
199    /// thinking is off.
200    #[serde(default, skip_serializing_if = "is_false")]
201    pub extended_thinking: bool,
202}
203
204// Manual Debug impl: HookRunner is no longer required to be Debug (lifted in
205// kernex-core::hooks so SDK clients without Debug derives can be wired in
206// directly). We surface a placeholder for the runner / rules so the rest of
207// Context still prints usefully in tracing spans.
208impl std::fmt::Debug for Context {
209    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
210        f.debug_struct("Context")
211            .field("system_prompt", &self.system_prompt)
212            .field("history", &self.history)
213            .field("current_message", &self.current_message)
214            .field("mcp_servers", &self.mcp_servers)
215            .field("toolboxes", &self.toolboxes)
216            .field("max_turns", &self.max_turns)
217            .field("token_budget", &self.token_budget)
218            .field("allowed_tools", &self.allowed_tools)
219            .field("model", &self.model)
220            .field("session_id", &self.session_id)
221            .field("agent_name", &self.agent_name)
222            .field(
223                "hook_runner",
224                &self.hook_runner.as_ref().map(|_| "<runner>"),
225            )
226            .field(
227                "permission_rules",
228                &self.permission_rules.as_ref().map(|_| "<rules>"),
229            )
230            .field("extended_thinking", &self.extended_thinking)
231            .finish()
232    }
233}
234
235/// A structured message for API-based providers (OpenAI, Anthropic, etc.).
236#[derive(Debug, Clone, Serialize, Deserialize)]
237pub struct ApiMessage {
238    /// "user" or "assistant".
239    pub role: String,
240    /// The message content.
241    pub content: String,
242}
243
244impl Context {
245    /// Create a new context with a current message and empty system prompt.
246    pub fn new(message: &str) -> Self {
247        Self {
248            system_prompt: String::new(),
249            history: Vec::new(),
250            current_message: message.to_string(),
251            mcp_servers: Vec::new(),
252            toolboxes: Vec::new(),
253            max_turns: None,
254            token_budget: None,
255            allowed_tools: None,
256            model: None,
257            session_id: None,
258            agent_name: None,
259            hook_runner: None,
260            permission_rules: None,
261            extended_thinking: false,
262        }
263    }
264
265    /// Attach a hook runner to this context.
266    pub fn with_hooks(mut self, runner: std::sync::Arc<dyn crate::hooks::HookRunner>) -> Self {
267        self.hook_runner = Some(runner);
268        self
269    }
270
271    /// Flatten the context into a single prompt string for providers
272    /// that accept a single text input (e.g. Claude Code CLI).
273    ///
274    /// When `agent_name` is set, returns only the current message.
275    /// When `session_id` is set (continuation), skips full system prompt and history.
276    pub fn to_prompt_string(&self) -> String {
277        if self.agent_name.is_some() {
278            return self.current_message.clone();
279        }
280
281        let mut parts = Vec::new();
282
283        if self.session_id.is_none() {
284            if !self.system_prompt.is_empty() {
285                parts.push(format!("[System]\n{}", self.system_prompt));
286            }
287            for entry in &self.history {
288                let role = if entry.role == "user" {
289                    "User"
290                } else {
291                    "Assistant"
292                };
293                parts.push(format!("[{}]\n{}", role, entry.content));
294            }
295            parts.push(format!("[User]\n{}", self.current_message));
296        } else {
297            if !self.system_prompt.is_empty() {
298                parts.push(format!(
299                    "[User]\n{}\n\n{}",
300                    self.system_prompt, self.current_message
301                ));
302            } else {
303                parts.push(format!("[User]\n{}", self.current_message));
304            }
305        }
306
307        parts.join("\n\n")
308    }
309
310    /// Convert context to structured API messages.
311    ///
312    /// Returns `(system_prompt, messages)` — the system prompt is separated
313    /// because Anthropic and Gemini require it outside the messages array.
314    pub fn to_api_messages(&self) -> (String, Vec<ApiMessage>) {
315        let mut messages = Vec::with_capacity(self.history.len() + 1);
316
317        for entry in &self.history {
318            messages.push(ApiMessage {
319                role: entry.role.clone(),
320                content: entry.content.clone(),
321            });
322        }
323
324        messages.push(ApiMessage {
325            role: "user".to_string(),
326            content: self.current_message.clone(),
327        });
328
329        (self.system_prompt.clone(), messages)
330    }
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336
337    #[test]
338    fn test_context_new_defaults() {
339        let ctx = Context::new("hello");
340        assert!(ctx.system_prompt.is_empty());
341        assert!(ctx.history.is_empty());
342        assert!(ctx.mcp_servers.is_empty());
343        assert!(ctx.toolboxes.is_empty());
344        assert_eq!(ctx.current_message, "hello");
345        assert!(ctx.session_id.is_none());
346        assert!(ctx.agent_name.is_none());
347    }
348
349    #[test]
350    fn test_mcp_server_serde_round_trip() {
351        let server = McpServer {
352            name: "playwright".into(),
353            command: "npx".into(),
354            args: vec!["@playwright/mcp".into(), "--headless".into()],
355            env: HashMap::new(),
356        };
357        let json = serde_json::to_string(&server).unwrap();
358        let deserialized: McpServer = serde_json::from_str(&json).unwrap();
359        assert_eq!(deserialized.name, "playwright");
360        assert_eq!(deserialized.args, vec!["@playwright/mcp", "--headless"]);
361    }
362
363    #[test]
364    fn test_context_serde_without_optional_fields() {
365        let json = r#"{"system_prompt":"test","history":[],"current_message":"hi"}"#;
366        let ctx: Context = serde_json::from_str(json).unwrap();
367        assert!(ctx.mcp_servers.is_empty());
368        assert!(ctx.session_id.is_none());
369        assert!(ctx.agent_name.is_none());
370    }
371
372    #[test]
373    fn test_to_api_messages_basic() {
374        let ctx = Context::new("hello");
375        let (system, messages) = ctx.to_api_messages();
376        assert!(system.is_empty());
377        assert_eq!(messages.len(), 1);
378        assert_eq!(messages[0].role, "user");
379        assert_eq!(messages[0].content, "hello");
380    }
381
382    #[test]
383    fn test_to_api_messages_with_history() {
384        let ctx = Context {
385            system_prompt: "Be helpful.".into(),
386            history: vec![
387                ContextEntry {
388                    role: "user".into(),
389                    content: "Hi".into(),
390                },
391                ContextEntry {
392                    role: "assistant".into(),
393                    content: "Hello!".into(),
394                },
395            ],
396            current_message: "How are you?".into(),
397            mcp_servers: Vec::new(),
398            toolboxes: Vec::new(),
399            max_turns: None,
400            token_budget: None,
401            allowed_tools: None,
402            model: None,
403            session_id: None,
404            agent_name: None,
405            hook_runner: None,
406            permission_rules: None,
407            extended_thinking: false,
408        };
409        let (system, messages) = ctx.to_api_messages();
410        assert_eq!(system, "Be helpful.");
411        assert_eq!(messages.len(), 3);
412    }
413
414    #[test]
415    fn test_to_prompt_string_no_session() {
416        let ctx = Context {
417            system_prompt: "Be helpful.".into(),
418            history: vec![ContextEntry {
419                role: "user".into(),
420                content: "Hi".into(),
421            }],
422            current_message: "How are you?".into(),
423            mcp_servers: Vec::new(),
424            toolboxes: Vec::new(),
425            max_turns: None,
426            token_budget: None,
427            allowed_tools: None,
428            model: None,
429            session_id: None,
430            agent_name: None,
431            hook_runner: None,
432            permission_rules: None,
433            extended_thinking: false,
434        };
435        let prompt = ctx.to_prompt_string();
436        assert!(prompt.contains("[System]\nBe helpful."));
437        assert!(prompt.contains("[User]\nHi"));
438        assert!(prompt.contains("[User]\nHow are you?"));
439    }
440
441    #[test]
442    fn test_to_prompt_string_with_session() {
443        let ctx = Context {
444            system_prompt: "Current time: 2026-03-06".into(),
445            history: vec![ContextEntry {
446                role: "user".into(),
447                content: "Hi".into(),
448            }],
449            current_message: "How are you?".into(),
450            mcp_servers: Vec::new(),
451            toolboxes: Vec::new(),
452            max_turns: None,
453            token_budget: None,
454            allowed_tools: None,
455            model: None,
456            session_id: Some("sess-abc".into()),
457            agent_name: None,
458            hook_runner: None,
459            permission_rules: None,
460            extended_thinking: false,
461        };
462        let prompt = ctx.to_prompt_string();
463        assert!(!prompt.contains("[System]"));
464        assert!(prompt.contains("[User]\nCurrent time: 2026-03-06\n\nHow are you?"));
465    }
466
467    #[test]
468    fn test_to_prompt_string_with_agent_name() {
469        let ctx = Context {
470            system_prompt: "You are a build analyst...".into(),
471            history: vec![ContextEntry {
472                role: "user".into(),
473                content: "prev".into(),
474            }],
475            current_message: "Build me a task tracker.".into(),
476            mcp_servers: Vec::new(),
477            toolboxes: Vec::new(),
478            max_turns: None,
479            token_budget: None,
480            allowed_tools: None,
481            model: None,
482            session_id: None,
483            agent_name: Some("build-analyst".into()),
484            hook_runner: None,
485            permission_rules: None,
486            extended_thinking: false,
487        };
488        let prompt = ctx.to_prompt_string();
489        assert_eq!(prompt, "Build me a task tracker.");
490    }
491
492    #[test]
493    fn test_agent_name_takes_precedence_over_session_id() {
494        let ctx = Context {
495            system_prompt: "system".into(),
496            history: Vec::new(),
497            current_message: "Build something.".into(),
498            mcp_servers: Vec::new(),
499            toolboxes: Vec::new(),
500            max_turns: None,
501            token_budget: None,
502            allowed_tools: None,
503            model: None,
504            session_id: Some("sess-456".into()),
505            agent_name: Some("build-architect".into()),
506            hook_runner: None,
507            permission_rules: None,
508            extended_thinking: false,
509        };
510        assert_eq!(ctx.to_prompt_string(), "Build something.");
511    }
512
513    #[test]
514    fn test_session_id_serde_round_trip() {
515        let ctx = Context {
516            system_prompt: "test".into(),
517            history: Vec::new(),
518            current_message: "hi".into(),
519            mcp_servers: Vec::new(),
520            toolboxes: Vec::new(),
521            max_turns: None,
522            token_budget: None,
523            allowed_tools: None,
524            model: None,
525            session_id: Some("sess-123".into()),
526            agent_name: None,
527            hook_runner: None,
528            permission_rules: None,
529            extended_thinking: false,
530        };
531        let json = serde_json::to_string(&ctx).unwrap();
532        let deserialized: Context = serde_json::from_str(&json).unwrap();
533        assert_eq!(deserialized.session_id, Some("sess-123".into()));
534    }
535
536    #[test]
537    fn test_optional_fields_skipped_in_serialization() {
538        let ctx = Context::new("hello");
539        let json = serde_json::to_string(&ctx).unwrap();
540        assert!(!json.contains("session_id"));
541        assert!(!json.contains("agent_name"));
542        assert!(!json.contains("max_turns"));
543        assert!(!json.contains("toolboxes"));
544    }
545
546    #[test]
547    fn test_toolbox_serde_round_trip() {
548        let tb = Toolbox {
549            name: "lint".into(),
550            description: "Run linter on a file.".into(),
551            parameters: serde_json::json!({
552                "type": "object",
553                "properties": {"file": {"type": "string"}},
554                "required": ["file"]
555            }),
556            command: "bash".into(),
557            args: vec!["scripts/lint.sh".into()],
558            env: HashMap::new(),
559            network: false,
560            env_passthrough: Vec::new(),
561            allowed_commands: Vec::new(),
562            search_hints: Vec::new(),
563        };
564        let json = serde_json::to_string(&tb).unwrap();
565        let deserialized: Toolbox = serde_json::from_str(&json).unwrap();
566        assert_eq!(deserialized.name, "lint");
567        assert_eq!(deserialized.command, "bash");
568        assert_eq!(deserialized.args, vec!["scripts/lint.sh"]);
569    }
570
571    #[test]
572    fn test_toolbox_default_parameters() {
573        let json = r#"{"name":"test","description":"Test tool.","command":"echo"}"#;
574        let tb: Toolbox = serde_json::from_str(json).unwrap();
575        assert_eq!(tb.parameters, serde_json::json!({"type": "object"}));
576        assert!(tb.args.is_empty());
577        assert!(tb.env.is_empty());
578    }
579
580    #[test]
581    fn test_context_serde_with_toolboxes() {
582        let mut ctx = Context::new("run lint");
583        ctx.toolboxes.push(Toolbox {
584            name: "lint".into(),
585            description: "Lint a file.".into(),
586            parameters: serde_json::json!({"type": "object"}),
587            command: "bash".into(),
588            args: vec!["lint.sh".into()],
589            env: HashMap::new(),
590            network: false,
591            env_passthrough: Vec::new(),
592            allowed_commands: Vec::new(),
593            search_hints: Vec::new(),
594        });
595        let json = serde_json::to_string(&ctx).unwrap();
596        assert!(json.contains("toolboxes"));
597        let deserialized: Context = serde_json::from_str(&json).unwrap();
598        assert_eq!(deserialized.toolboxes.len(), 1);
599        assert_eq!(deserialized.toolboxes[0].name, "lint");
600    }
601}