Skip to main content

warble_claude_code/emit/
types.rs

1//! Public dispatch-time knobs: render flavor, context injection, and hybrid realization.
2//! Pure config enums with no dependencies; re-exported from the crate root via `emit`.
3
4use crate::ir::WarbleIr;
5use serde::Serialize;
6
7/// Render flavor ([`ir-schema.md`][spec-ir] §v0.3 §4). `programmatic` (default): the agent stays
8/// read-only and emits a `{blocks}` envelope; a downstream renderer produces HTML deterministically.
9/// `prompt`: the plain-file fallback — the agent is granted scoped write and writes
10/// `dashboard.html` itself.
11///
12/// [spec-ir]: https://github.com/Canner/Warble/blob/main/docs/spec/ir-schema.md
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum RenderFlavor {
15    Programmatic,
16    Prompt,
17}
18
19pub const DEFAULT_RENDER_FLAVOR: RenderFlavor = RenderFlavor::Programmatic;
20
21/// Which normalized semantic context is embedded into emitted agent prompts.
22///
23/// This is a source-neutral runtime binding choice, not a context-provider identifier, component
24/// identity, or IR control flow. The payload carries a deterministic schema digest derived from
25/// the IR's resolved context and nothing else: the dispatcher never reads the bound project, and
26/// no mode embeds host-supplied business rules. There is no flag: the mode is not a caller's
27/// choice, and the type exists because [`ContextInjectionMode::as_str`] is what names the injected
28/// facet in the emitted prompt and the context report.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
30#[serde(rename_all = "kebab-case")]
31pub enum ContextInjectionMode {
32    SchemaOnly,
33}
34
35pub const DEFAULT_CONTEXT_INJECTION: ContextInjectionMode = ContextInjectionMode::SchemaOnly;
36
37impl ContextInjectionMode {
38    pub fn as_str(&self) -> &'static str {
39        match self {
40            ContextInjectionMode::SchemaOnly => "schema-only",
41        }
42    }
43}
44
45/// Normalized, runtime-neutral context payload supplied to the dispatcher by its host.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct ContextInjection {
48    mode: ContextInjectionMode,
49    schema_digest: String,
50    /// Whether the IR carried a resolved block at all — see [`ContextInjection::introspected`].
51    introspected: bool,
52    absent: bool,
53}
54
55/// Public report identity written beside every dispatched agent and copied into eval reports.
56/// It intentionally contains fingerprints/counts rather than private knowledge text.
57#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
58pub struct ContextInjectionReport {
59    pub mode: &'static str,
60    pub schema_digest_fingerprint: String,
61    pub knowledge_fingerprint: Option<String>,
62    pub knowledge_chars: usize,
63}
64
65impl ContextInjection {
66    /// Build the source-neutral payload from compiled IR. The dispatcher never reads the bound
67    /// project: everything it embeds comes from the IR it was handed.
68    pub fn from_ir(ir: &WarbleIr, mode: ContextInjectionMode) -> Self {
69        let introspected = ir.context_binding.resolved.is_some();
70        let schema_digest = build_schema_digest(ir.context_binding.resolved.as_ref());
71        Self {
72            mode,
73            schema_digest,
74            introspected,
75            absent: ir.context_binding.absent,
76        }
77    }
78
79    pub(crate) fn absent(&self) -> bool {
80        self.absent
81    }
82
83    pub fn mode(&self) -> ContextInjectionMode {
84        self.mode
85    }
86
87    /// Whether the bound context described itself at all. `false` means the compiled IR carries no
88    /// resolved block — the layer is elsewhere — and callers must not present its emptiness as
89    /// knowledge about that layer.
90    pub fn introspected(&self) -> bool {
91        self.introspected
92    }
93
94    pub fn prompt_section(&self) -> String {
95        if self.absent {
96            return String::new();
97        }
98        let knowledge = match self.mode {
99            // "answer from the injected schema" is only sound advice when one was injected; without
100            // a resolved block it points the agent at something that is not in its prompt.
101            ContextInjectionMode::SchemaOnly if !self.introspected => "Knowledge rules are intentionally excluded for this run. Do NOT call a context-instruction tool or read project knowledge files.".to_string(),
102            ContextInjectionMode::SchemaOnly => "Knowledge rules are intentionally excluded for this run. Do NOT call a context-instruction tool or read project knowledge files; answer from the injected schema and the question only.".to_string(),
103        };
104        // With no resolved block there is no digest to reason from, and printing an empty one would
105        // invite exactly the error it looks like an answer to: concluding that a layer nobody
106        // introspected contains nothing. Say that it is unknown instead.
107        if !self.introspected {
108            return format!(
109                "## Injected context\n\nContext injection mode: `{}`. The bound semantic layer was NOT introspected here — it is held elsewhere. You therefore know NOTHING about which models, metrics or dimensions exist. Never tell the user that something does not exist, and never rule a question out, on the strength of this absence; put the question to whatever tool answers it and report what comes back.\n\n{}",
110                self.mode.as_str(),
111                knowledge
112            );
113        }
114        format!(
115            "## Injected context\n\nContext injection mode: `{}`. Use this compiled schema digest before calling a semantic-introspection tool; introspect only when the question needs details absent from the digest.\n\n<schema_digest>\n{}\n</schema_digest>\n\n{}",
116            self.mode.as_str(),
117            self.schema_digest,
118            knowledge
119        )
120    }
121
122    pub fn report(&self) -> ContextInjectionReport {
123        ContextInjectionReport {
124            mode: if self.absent {
125                "none"
126            } else {
127                self.mode.as_str()
128            },
129            schema_digest_fingerprint: fingerprint(&self.schema_digest),
130            // Retained, and always empty: the report says the dispatcher embedded no business
131            // rules, which is a statement worth making rather than a field worth dropping.
132            knowledge_fingerprint: None,
133            knowledge_chars: 0,
134        }
135    }
136}
137
138/// Stable FNV-1a identity for report diagnostics. The eval cache does not trust this short
139/// fingerprint: it hashes the complete emitted directory independently for `agent_sha`.
140fn fingerprint(value: &str) -> String {
141    let mut hash = 0xcbf29ce484222325_u64;
142    for byte in value.as_bytes() {
143        hash ^= u64::from(*byte);
144        hash = hash.wrapping_mul(0x100000001b3);
145    }
146    format!("fnv1a64:{hash:016x}")
147}
148
149fn canonical_json(value: &serde_json::Value) -> String {
150    match value {
151        serde_json::Value::Null => "null".to_string(),
152        serde_json::Value::Bool(v) => v.to_string(),
153        serde_json::Value::Number(v) => v.to_string(),
154        serde_json::Value::String(v) => serde_json::to_string(v).expect("string serializes"),
155        serde_json::Value::Array(values) => format!(
156            "[{}]",
157            values
158                .iter()
159                .map(canonical_json)
160                .collect::<Vec<_>>()
161                .join(",")
162        ),
163        serde_json::Value::Object(values) => {
164            let mut entries = values.iter().collect::<Vec<_>>();
165            entries.sort_by_key(|(key, _)| *key);
166            format!(
167                "{{{}}}",
168                entries
169                    .into_iter()
170                    .map(|(key, value)| format!(
171                        "{}:{}",
172                        serde_json::to_string(key).expect("key serializes"),
173                        canonical_json(value)
174                    ))
175                    .collect::<Vec<_>>()
176                    .join(",")
177            )
178        }
179    }
180}
181
182fn sorted_entries(value: Option<&serde_json::Value>, key: &str) -> Vec<String> {
183    let mut entries = value
184        .and_then(|v| v.get(key))
185        .and_then(serde_json::Value::as_array)
186        .into_iter()
187        .flatten()
188        .map(|entry| match entry {
189            serde_json::Value::String(s) => s.clone(),
190            other => canonical_json(other),
191        })
192        .collect::<Vec<_>>();
193    entries.sort();
194    entries
195}
196
197fn digest_line(label: &str, entries: Vec<String>) -> String {
198    if entries.is_empty() {
199        format!("{label}: (none)")
200    } else {
201        format!("{label}: {}", entries.join(", "))
202    }
203}
204
205fn build_schema_digest(resolved: Option<&serde_json::Value>) -> String {
206    let lineage = resolved
207        .and_then(|v| v.get("lineage"))
208        .map(canonical_json)
209        .unwrap_or_else(|| "unavailable".to_string());
210    [
211        digest_line("Models", sorted_entries(resolved, "models")),
212        digest_line("Metrics", sorted_entries(resolved, "metrics")),
213        digest_line("Dimensions", sorted_entries(resolved, "dimensions")),
214        digest_line(
215            "Time dimensions",
216            sorted_entries(resolved, "time_dimensions"),
217        ),
218        format!("Lineage: {lineage}"),
219    ]
220    .join("\n")
221}
222
223impl RenderFlavor {
224    pub fn as_str(&self) -> &'static str {
225        match self {
226            RenderFlavor::Programmatic => "programmatic",
227            RenderFlavor::Prompt => "prompt",
228        }
229    }
230
231    pub fn parse(value: &str) -> Option<RenderFlavor> {
232        match value {
233            "programmatic" => Some(RenderFlavor::Programmatic),
234            "prompt" => Some(RenderFlavor::Prompt),
235            _ => None,
236        }
237    }
238}
239
240/// How the file target realizes a hybrid binding's LOCAL step (`llm:per_step_provider`, see
241/// [`capability-model.md`][spec-cap] §7.2): `BashScript` emits a Bash-run local-inference script
242/// (needs `bash` in the allowlist); `McpServer` emits a `.mcp.json` registering `warble mcp-serve`
243/// so the driver calls a `local_infer` MCP tool (a separate permission gate — no `bash`
244/// widening). Default `BashScript`.
245///
246/// [spec-cap]: https://github.com/Canner/Warble/blob/main/docs/spec/capability-model.md
247#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
248pub enum HybridRealization {
249    #[default]
250    BashScript,
251    McpServer,
252}
253
254impl HybridRealization {
255    pub fn parse(value: &str) -> Option<HybridRealization> {
256        match value {
257            "bash-script" => Some(HybridRealization::BashScript),
258            "mcp-server" => Some(HybridRealization::McpServer),
259            _ => None,
260        }
261    }
262}