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}
53
54/// Public report identity written beside every dispatched agent and copied into eval reports.
55/// It intentionally contains fingerprints/counts rather than private knowledge text.
56#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
57pub struct ContextInjectionReport {
58    pub mode: &'static str,
59    pub schema_digest_fingerprint: String,
60    pub knowledge_fingerprint: Option<String>,
61    pub knowledge_chars: usize,
62}
63
64impl ContextInjection {
65    /// Build the source-neutral payload from compiled IR. The dispatcher never reads the bound
66    /// project: everything it embeds comes from the IR it was handed.
67    pub fn from_ir(ir: &WarbleIr, mode: ContextInjectionMode) -> Self {
68        let introspected = ir.context_binding.resolved.is_some();
69        let schema_digest = build_schema_digest(ir.context_binding.resolved.as_ref());
70        Self {
71            mode,
72            schema_digest,
73            introspected,
74        }
75    }
76
77    pub fn mode(&self) -> ContextInjectionMode {
78        self.mode
79    }
80
81    /// Whether the bound context described itself at all. `false` means the compiled IR carries no
82    /// resolved block — the layer is elsewhere — and callers must not present its emptiness as
83    /// knowledge about that layer.
84    pub fn introspected(&self) -> bool {
85        self.introspected
86    }
87
88    pub fn prompt_section(&self) -> String {
89        let knowledge = match self.mode {
90            // "answer from the injected schema" is only sound advice when one was injected; without
91            // a resolved block it points the agent at something that is not in its prompt.
92            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(),
93            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(),
94        };
95        // With no resolved block there is no digest to reason from, and printing an empty one would
96        // invite exactly the error it looks like an answer to: concluding that a layer nobody
97        // introspected contains nothing. Say that it is unknown instead.
98        if !self.introspected {
99            return format!(
100                "## 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{}",
101                self.mode.as_str(),
102                knowledge
103            );
104        }
105        format!(
106            "## 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{}",
107            self.mode.as_str(),
108            self.schema_digest,
109            knowledge
110        )
111    }
112
113    pub fn report(&self) -> ContextInjectionReport {
114        ContextInjectionReport {
115            mode: self.mode.as_str(),
116            schema_digest_fingerprint: fingerprint(&self.schema_digest),
117            // Retained, and always empty: the report says the dispatcher embedded no business
118            // rules, which is a statement worth making rather than a field worth dropping.
119            knowledge_fingerprint: None,
120            knowledge_chars: 0,
121        }
122    }
123}
124
125/// Stable FNV-1a identity for report diagnostics. The eval cache does not trust this short
126/// fingerprint: it hashes the complete emitted directory independently for `agent_sha`.
127fn fingerprint(value: &str) -> String {
128    let mut hash = 0xcbf29ce484222325_u64;
129    for byte in value.as_bytes() {
130        hash ^= u64::from(*byte);
131        hash = hash.wrapping_mul(0x100000001b3);
132    }
133    format!("fnv1a64:{hash:016x}")
134}
135
136fn canonical_json(value: &serde_json::Value) -> String {
137    match value {
138        serde_json::Value::Null => "null".to_string(),
139        serde_json::Value::Bool(v) => v.to_string(),
140        serde_json::Value::Number(v) => v.to_string(),
141        serde_json::Value::String(v) => serde_json::to_string(v).expect("string serializes"),
142        serde_json::Value::Array(values) => format!(
143            "[{}]",
144            values
145                .iter()
146                .map(canonical_json)
147                .collect::<Vec<_>>()
148                .join(",")
149        ),
150        serde_json::Value::Object(values) => {
151            let mut entries = values.iter().collect::<Vec<_>>();
152            entries.sort_by_key(|(key, _)| *key);
153            format!(
154                "{{{}}}",
155                entries
156                    .into_iter()
157                    .map(|(key, value)| format!(
158                        "{}:{}",
159                        serde_json::to_string(key).expect("key serializes"),
160                        canonical_json(value)
161                    ))
162                    .collect::<Vec<_>>()
163                    .join(",")
164            )
165        }
166    }
167}
168
169fn sorted_entries(value: Option<&serde_json::Value>, key: &str) -> Vec<String> {
170    let mut entries = value
171        .and_then(|v| v.get(key))
172        .and_then(serde_json::Value::as_array)
173        .into_iter()
174        .flatten()
175        .map(|entry| match entry {
176            serde_json::Value::String(s) => s.clone(),
177            other => canonical_json(other),
178        })
179        .collect::<Vec<_>>();
180    entries.sort();
181    entries
182}
183
184fn digest_line(label: &str, entries: Vec<String>) -> String {
185    if entries.is_empty() {
186        format!("{label}: (none)")
187    } else {
188        format!("{label}: {}", entries.join(", "))
189    }
190}
191
192fn build_schema_digest(resolved: Option<&serde_json::Value>) -> String {
193    let lineage = resolved
194        .and_then(|v| v.get("lineage"))
195        .map(canonical_json)
196        .unwrap_or_else(|| "unavailable".to_string());
197    [
198        digest_line("Models", sorted_entries(resolved, "models")),
199        digest_line("Metrics", sorted_entries(resolved, "metrics")),
200        digest_line("Dimensions", sorted_entries(resolved, "dimensions")),
201        digest_line(
202            "Time dimensions",
203            sorted_entries(resolved, "time_dimensions"),
204        ),
205        format!("Lineage: {lineage}"),
206    ]
207    .join("\n")
208}
209
210impl RenderFlavor {
211    pub fn as_str(&self) -> &'static str {
212        match self {
213            RenderFlavor::Programmatic => "programmatic",
214            RenderFlavor::Prompt => "prompt",
215        }
216    }
217
218    pub fn parse(value: &str) -> Option<RenderFlavor> {
219        match value {
220            "programmatic" => Some(RenderFlavor::Programmatic),
221            "prompt" => Some(RenderFlavor::Prompt),
222            _ => None,
223        }
224    }
225}
226
227/// How the file target realizes a hybrid binding's LOCAL step (`llm:per_step_provider`, see
228/// [`capability-model.md`][spec-cap] §7.2): `BashScript` emits a Bash-run local-inference script
229/// (needs `bash` in the allowlist); `McpServer` emits a `.mcp.json` registering `warble mcp-serve`
230/// so the driver calls a `local_infer` MCP tool (a separate permission gate — no `bash`
231/// widening). Default `BashScript`.
232///
233/// [spec-cap]: https://github.com/Canner/Warble/blob/main/docs/spec/capability-model.md
234#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
235pub enum HybridRealization {
236    #[default]
237    BashScript,
238    McpServer,
239}
240
241impl HybridRealization {
242    pub fn parse(value: &str) -> Option<HybridRealization> {
243        match value {
244            "bash-script" => Some(HybridRealization::BashScript),
245            "mcp-server" => Some(HybridRealization::McpServer),
246            _ => None,
247        }
248    }
249}