supercode_harness/context_injection.rs
1//! BP-4 (catalog:91 "Synthetic context-injection blocks", design §1.4:
2//! "harness-spliced reminders/nudges … the ambient nudge class is core"):
3//! the injection REGISTRY behind `core.context_injections`.
4//!
5//! Before BP-4 the key gated exactly one thing — a static, caller-populated
6//! [`ContextInjectionBlock`] list appended once at construction — so a
7//! preset turning it on got nothing, because no preset (and nothing in the
8//! product) ever populated the list. Neither parity preset set the key at
9//! all, which made the gap invisible.
10//!
11//! The registry has three sources, spliced in this order:
12//!
13//! 1. **Built-in blocks** ([`builtin_blocks`]) — derived from the RESOLVED
14//! config, so a block only appears when the capability it talks about is
15//! actually armed. This is the "~25 block types" class cx's own
16//! `context/` library and cc's `<system-reminder>` blocks occupy: ambient
17//! statements about the harness the model is running inside, which no
18//! instruction file can know.
19//! 2. **User blocks** — [`crate::Config::context_injection_blocks`], the
20//! pre-existing embedder-populated list, unchanged.
21//! 3. **Spliced blocks** — [`crate::Agent::inject_context_block`], added
22//! mid-session (a hook's `additionalContext`, a frontend's nudge, an
23//! orchestrator's brief). This is the half that makes the mechanism a
24//! SEAM rather than a startup constant.
25//!
26//! Everything here is a no-op when `core.context_injections` is false (the
27//! default): [`assemble`] returns an empty string and nothing is read.
28
29use crate::config::{Config, ContextInjectionBlock};
30use crate::modules::ModuleId;
31
32/// The built-in ambient blocks armed by `config`, in a stable order.
33///
34/// Each block states something true about THIS resolved configuration that
35/// the model cannot otherwise know, and each is gated on the capability it
36/// describes — a config with none of them armed contributes no blocks at
37/// all, so this is never boilerplate the model has to ignore.
38pub fn builtin_blocks(config: &Config) -> Vec<ContextInjectionBlock> {
39 let mut blocks = Vec::new();
40 let active = |id: ModuleId| config.module_registry && config.module_activation.is_active(id);
41
42 if active(ModuleId::Todos) {
43 blocks.push(ContextInjectionBlock::new(
44 "Task list",
45 "A persistent task list is available through the plan/todo tool. Keep it current: \
46 write the plan out before starting multi-step work, mark each step completed as \
47 you finish it, and add work you discover along the way. The list survives \
48 compaction, so it is the durable record of where this session is.",
49 ));
50 }
51 if active(ModuleId::PlanMode) {
52 blocks.push(ContextInjectionBlock::new(
53 "Plan mode",
54 "This session can enter a read-only planning mode. While it is active, do not edit \
55 files, write files, or run state-changing commands — investigate, then present the \
56 plan and wait for it to be accepted.",
57 ));
58 }
59 if !config.permissions_protected_paths.is_empty() {
60 blocks.push(ContextInjectionBlock::new(
61 "Protected paths",
62 format!(
63 "Writes to these paths are never auto-approved and will stop for the user's \
64 decision: {}. Prefer a route that doesn't touch them.",
65 config.permissions_protected_paths.join(", ")
66 ),
67 ));
68 }
69 if active(ModuleId::ToolsBackground) {
70 blocks.push(ContextInjectionBlock::new(
71 "Background work",
72 "Long-running commands can be started in the background instead of blocking the \
73 turn. Start them detached, keep working, and read their output when it matters — \
74 never sit on a foreground command waiting for it to finish.",
75 ));
76 }
77 blocks
78}
79
80/// Every block a system prompt should carry, in splice order: built-ins,
81/// then the config's own list, then anything spliced in at runtime.
82/// Empty (and free of any work) when `core.context_injections` is off.
83pub fn blocks(config: &Config, spliced: &[ContextInjectionBlock]) -> Vec<ContextInjectionBlock> {
84 if !config.context_injections {
85 return Vec::new();
86 }
87 let mut out = builtin_blocks(config);
88 out.extend(config.context_injection_blocks.iter().cloned());
89 out.extend(spliced.iter().cloned());
90 out
91}
92
93/// Render blocks as the `\n\n# {name}\n{content}` sections the assembly site
94/// appends to the system prompt — the exact shape P4e's static list used, so
95/// a config that only set `context_injection_blocks` renders identically.
96pub fn render(blocks: &[ContextInjectionBlock]) -> String {
97 let mut out = String::new();
98 for block in blocks {
99 out.push_str(&format!("\n\n# {}\n{}", block.name, block.content));
100 }
101 out
102}
103
104/// [`blocks`] + [`render`] — the whole injection contribution to a system
105/// prompt.
106pub fn assemble(config: &Config, spliced: &[ContextInjectionBlock]) -> String {
107 render(&blocks(config, spliced))
108}