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. A host adapter may source context from Wren MDL, OSI, dbt, or
25/// another provider before constructing this payload. Both modes carry the same deterministic
26/// schema digest; `schema+knowledge` additionally embeds host-supplied business rules. The
27/// dispatcher never reads the bound project itself.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
29#[serde(rename_all = "kebab-case")]
30pub enum ContextInjectionMode {
31    SchemaOnly,
32    #[serde(rename = "schema+knowledge")]
33    SchemaWithKnowledge,
34}
35
36pub const DEFAULT_CONTEXT_INJECTION: ContextInjectionMode = ContextInjectionMode::SchemaOnly;
37
38impl ContextInjectionMode {
39    pub fn as_str(&self) -> &'static str {
40        match self {
41            ContextInjectionMode::SchemaOnly => "schema-only",
42            ContextInjectionMode::SchemaWithKnowledge => "schema+knowledge",
43        }
44    }
45
46    pub fn parse(value: &str) -> Option<Self> {
47        match value {
48            "schema-only" => Some(Self::SchemaOnly),
49            "schema+knowledge" => Some(Self::SchemaWithKnowledge),
50            _ => None,
51        }
52    }
53}
54
55/// Normalized, runtime-neutral context payload supplied to the dispatcher by its host.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct ContextInjection {
58    mode: ContextInjectionMode,
59    schema_digest: String,
60    knowledge: String,
61    /// Whether the IR carried a resolved block at all — see [`ContextInjection::introspected`].
62    introspected: bool,
63}
64
65/// Public report identity written beside every dispatched agent and copied into eval reports.
66/// It intentionally contains fingerprints/counts rather than private knowledge text.
67#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
68pub struct ContextInjectionReport {
69    pub mode: &'static str,
70    pub schema_digest_fingerprint: String,
71    pub knowledge_fingerprint: Option<String>,
72    pub knowledge_chars: usize,
73}
74
75impl ContextInjection {
76    /// Build the source-neutral payload from compiled IR plus host-supplied knowledge. `knowledge`
77    /// is consumed only in `schema+knowledge`; callers should not read it for `schema-only`.
78    pub fn from_ir(ir: &WarbleIr, mode: ContextInjectionMode, knowledge: Option<String>) -> Self {
79        let introspected = ir.context_binding.resolved.is_some();
80        let schema_digest = build_schema_digest(ir.context_binding.resolved.as_ref());
81        let knowledge = match mode {
82            ContextInjectionMode::SchemaOnly => String::new(),
83            ContextInjectionMode::SchemaWithKnowledge => {
84                normalize_text(knowledge.as_deref().unwrap_or(""))
85            }
86        };
87        Self {
88            mode,
89            schema_digest,
90            knowledge,
91            introspected,
92        }
93    }
94
95    pub fn mode(&self) -> ContextInjectionMode {
96        self.mode
97    }
98
99    /// Whether the bound context described itself at all. `false` means the compiled IR carries no
100    /// resolved block — the layer is elsewhere — and callers must not present its emptiness as
101    /// knowledge about that layer.
102    pub fn introspected(&self) -> bool {
103        self.introspected
104    }
105
106    pub fn prompt_section(&self) -> String {
107        let knowledge = match self.mode {
108            // "answer from the injected schema" is only sound advice when one was injected; without
109            // a resolved block it points the agent at something that is not in its prompt.
110            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(),
111            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(),
112            ContextInjectionMode::SchemaWithKnowledge if self.knowledge.is_empty() => "Knowledge injection is enabled, but the host found no non-empty business rules. Do NOT call a context-instruction tool; there are no injected rules to recover.".to_string(),
113            ContextInjectionMode::SchemaWithKnowledge => format!(
114                "The host embedded the authoritative business rules below. Apply every relevant rule and do NOT retrieve context instructions again.\n\n<knowledge_rules>\n{}\n</knowledge_rules>",
115                self.knowledge
116            ),
117        };
118        // With no resolved block there is no digest to reason from, and printing an empty one would
119        // invite exactly the error it looks like an answer to: concluding that a layer nobody
120        // introspected contains nothing. Say that it is unknown instead.
121        if !self.introspected {
122            return format!(
123                "## 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{}",
124                self.mode.as_str(),
125                knowledge
126            );
127        }
128        format!(
129            "## 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{}",
130            self.mode.as_str(),
131            self.schema_digest,
132            knowledge
133        )
134    }
135
136    pub fn report(&self) -> ContextInjectionReport {
137        ContextInjectionReport {
138            mode: self.mode.as_str(),
139            schema_digest_fingerprint: fingerprint(&self.schema_digest),
140            knowledge_fingerprint: (self.mode == ContextInjectionMode::SchemaWithKnowledge)
141                .then(|| fingerprint(&self.knowledge)),
142            knowledge_chars: self.knowledge.chars().count(),
143        }
144    }
145}
146
147fn normalize_text(value: &str) -> String {
148    value
149        .replace("\r\n", "\n")
150        .replace('\r', "\n")
151        .trim()
152        .to_string()
153}
154
155/// Stable FNV-1a identity for report diagnostics. The eval cache does not trust this short
156/// fingerprint: it hashes the complete emitted directory independently for `agent_sha`.
157fn fingerprint(value: &str) -> String {
158    let mut hash = 0xcbf29ce484222325_u64;
159    for byte in value.as_bytes() {
160        hash ^= u64::from(*byte);
161        hash = hash.wrapping_mul(0x100000001b3);
162    }
163    format!("fnv1a64:{hash:016x}")
164}
165
166fn canonical_json(value: &serde_json::Value) -> String {
167    match value {
168        serde_json::Value::Null => "null".to_string(),
169        serde_json::Value::Bool(v) => v.to_string(),
170        serde_json::Value::Number(v) => v.to_string(),
171        serde_json::Value::String(v) => serde_json::to_string(v).expect("string serializes"),
172        serde_json::Value::Array(values) => format!(
173            "[{}]",
174            values
175                .iter()
176                .map(canonical_json)
177                .collect::<Vec<_>>()
178                .join(",")
179        ),
180        serde_json::Value::Object(values) => {
181            let mut entries = values.iter().collect::<Vec<_>>();
182            entries.sort_by_key(|(key, _)| *key);
183            format!(
184                "{{{}}}",
185                entries
186                    .into_iter()
187                    .map(|(key, value)| format!(
188                        "{}:{}",
189                        serde_json::to_string(key).expect("key serializes"),
190                        canonical_json(value)
191                    ))
192                    .collect::<Vec<_>>()
193                    .join(",")
194            )
195        }
196    }
197}
198
199fn sorted_entries(value: Option<&serde_json::Value>, key: &str) -> Vec<String> {
200    let mut entries = value
201        .and_then(|v| v.get(key))
202        .and_then(serde_json::Value::as_array)
203        .into_iter()
204        .flatten()
205        .map(|entry| match entry {
206            serde_json::Value::String(s) => s.clone(),
207            other => canonical_json(other),
208        })
209        .collect::<Vec<_>>();
210    entries.sort();
211    entries
212}
213
214fn digest_line(label: &str, entries: Vec<String>) -> String {
215    if entries.is_empty() {
216        format!("{label}: (none)")
217    } else {
218        format!("{label}: {}", entries.join(", "))
219    }
220}
221
222fn build_schema_digest(resolved: Option<&serde_json::Value>) -> String {
223    let lineage = resolved
224        .and_then(|v| v.get("lineage"))
225        .map(canonical_json)
226        .unwrap_or_else(|| "unavailable".to_string());
227    [
228        digest_line("Models", sorted_entries(resolved, "models")),
229        digest_line("Metrics", sorted_entries(resolved, "metrics")),
230        digest_line("Dimensions", sorted_entries(resolved, "dimensions")),
231        digest_line(
232            "Time dimensions",
233            sorted_entries(resolved, "time_dimensions"),
234        ),
235        format!("Lineage: {lineage}"),
236    ]
237    .join("\n")
238}
239
240impl RenderFlavor {
241    pub fn as_str(&self) -> &'static str {
242        match self {
243            RenderFlavor::Programmatic => "programmatic",
244            RenderFlavor::Prompt => "prompt",
245        }
246    }
247
248    pub fn parse(value: &str) -> Option<RenderFlavor> {
249        match value {
250            "programmatic" => Some(RenderFlavor::Programmatic),
251            "prompt" => Some(RenderFlavor::Prompt),
252            _ => None,
253        }
254    }
255}
256
257/// How the file target realizes a hybrid binding's LOCAL step (`llm:per_step_provider`, see
258/// [`capability-model.md`][spec-cap] §7.2): `BashScript` emits a Bash-run local-inference script
259/// (needs `bash` in the allowlist); `McpServer` emits a `.mcp.json` registering `warble mcp-serve`
260/// so the driver calls a `local_infer` MCP tool (a separate permission gate — no `bash`
261/// widening). Default `BashScript`.
262///
263/// [spec-cap]: https://github.com/Canner/Warble/blob/main/docs/spec/capability-model.md
264#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
265pub enum HybridRealization {
266    #[default]
267    BashScript,
268    McpServer,
269}
270
271impl HybridRealization {
272    pub fn parse(value: &str) -> Option<HybridRealization> {
273        match value {
274            "bash-script" => Some(HybridRealization::BashScript),
275            "mcp-server" => Some(HybridRealization::McpServer),
276            _ => None,
277        }
278    }
279}