warble 0.16.0

Warble front-end compiler: parse/resolve/validate a behavior profile into language-neutral IR (sans-IO).
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
//! Deserialized authoring types — the on-disk Warble project shapes (`profile.yml`,
//! `component.yml`, `context/binding.yml`) that the host reads and hands to [`crate::compile`].
//!
//! These derive `Deserialize` only; no filesystem access lives here (sans-IO — see the crate
//! docs). The host (CLI / a binding) is responsible for reading the bytes.

use serde::Deserialize;
use std::collections::HashMap;

/// The root of `profile.yml`: which components are mounted, the context this profile binds
/// against, and profile-level config defaults.
#[derive(Debug, Deserialize, Clone)]
pub struct ProfileFile {
    pub profile: String,
    pub context: ProfileContext,
    /// A system prompt shared by every component this profile mounts.
    ///
    /// The place for framing that belongs to the harness as a whole rather than to any single
    /// behavior: how the agent identifies itself, what it declines, the conventions every mounted
    /// behavior must follow. A component's [`ComponentFile::brief`] frames one behavior; this
    /// frames all of them, so authoring it here is the alternative to repeating the same text in
    /// every mount's `brief`.
    ///
    /// It composes with `brief` rather than replacing it — the resolved text is this prompt
    /// followed by the component's effective `brief` — and takes the same placeholder
    /// substitution. A profile without one compiles to exactly the IR it did before this field
    /// existed.
    #[serde(default)]
    pub system_prompt: Option<String>,
    /// Named positions in this profile's own prompt text — that is, in
    /// [`Self::system_prompt`] — whose wording is chosen at dispatch. Same shape as a component's
    /// [`ComponentFile::slots`]; see [`SlotDecl`] for the mechanism and for why a profile-level
    /// slot name may not collide with a component-level one.
    #[serde(default)]
    pub slots: Vec<SlotDecl>,
    #[serde(default)]
    pub config: ProfileConfig,
    pub components: Vec<ProfileComponentMount>,
}

/// Where the bound context lives, relative to the profile.
#[derive(Debug, Deserialize, Clone)]
pub struct ProfileContext {
    /// Path (relative to the project-dir) to the `binding.yml` file. Resolved by the caller.
    pub project: String,
}

/// The profile's global `config` block — settings that apply to the profile as a whole.
///
/// It held `tier_policy` until IR `0.6`: a profile-wide tier stance (`cost_sensitive`) that no
/// back-end ever read, so a profile declaring it got cost control it did not have. The stance is
/// a real layer of the tier design and this block is where it belongs — but the rule it needs
/// (which steps are safe to downgrade) is a property of the *bound context*, not of the profile,
/// and eval showed the naive blanket rule is harmful: downgrading `answer_query` is free on a
/// clean schema and costs ~33 accuracy points on a messy one. So the block stayed, empty, for
/// profile-level config that can actually be honored — `capability_ceiling` below is the first
/// such field.
#[derive(Debug, Deserialize, Clone, Default)]
pub struct ProfileConfig {
    /// An upper bound on which capabilities this profile's mounted components may require.
    ///
    /// This is a compile-time *authorization* gate, distinct from the dispatch-time capability
    /// *resolution* that `required_capabilities` on a component already goes through (native /
    /// realize-via / degrade / fail against a target's profile — see
    /// [`ComponentFile::required_capabilities`]). The ceiling answers "may this profile's
    /// components ask for this at all"; resolution answers "can the target honor it". A component
    /// requiring a capability the ceiling does not list fails compile, before dispatch is ever
    /// reached.
    ///
    /// Checked by exact string-set containment only — there is no hierarchy or prefix inference
    /// on the `:` qualifier some capability names use. A ceiling of `sql_execution` does **not**
    /// admit a requirement of `sql_execution:read_only`; a profile that means to allow both must
    /// list both.
    ///
    /// Absent (the default), no ceiling check runs at all and every component's
    /// `required_capabilities` is accepted as-is, exactly as if this field did not exist. This is
    /// different from an explicit empty list, which is a real ceiling that admits nothing.
    #[serde(default)]
    pub capability_ceiling: Option<Vec<String>>,
}

/// One `components:` entry: which component to mount, plus the config/binding/tier-override/
/// guardrail patches layered on top of that component's own defaults.
#[derive(Debug, Deserialize, Clone)]
pub struct ProfileComponentMount {
    #[serde(rename = "use")]
    pub use_id: String,
    /// Whether this mount may be started directly as an entry component. Callee-only mounts stay
    /// present in the resolved profile and may still be reached through an authorized
    /// `ComponentCall`. Profiles authored before this field existed remain entry-eligible.
    #[serde(default = "default_true")]
    pub entrypoint: bool,
    #[serde(default)]
    pub config: Option<serde_yaml::Value>,
    #[serde(default)]
    pub bind: Option<HashMap<String, serde_yaml::Value>>,
    #[serde(default)]
    pub tier_overrides: Option<HashMap<String, String>>,
    #[serde(default)]
    pub realization_kind: Option<String>,
    /// Profile syntax for touching a component's guardrails: a map of guardrail name to a
    /// patch. Patching a guardrail whose component default is `locked: true` is always a
    /// compile error, regardless of what the patch requests.
    #[serde(default)]
    pub guardrails: Option<HashMap<String, GuardrailPatch>>,
    /// A profile-level replacement for the mounted component's `brief` (see
    /// [`ComponentFile::brief`]). When present, it replaces the component's own `brief` entirely
    /// (never merged); when absent, the component's `brief` (if any) is used unchanged.
    #[serde(default)]
    pub brief: Option<String>,
}

/// A profile-level override to a mounted component's guardrail. Only `locked` is patchable
/// today; patching a guardrail whose component default is `locked: true` is always a compile
/// error, regardless of what the patch requests.
#[derive(Debug, Deserialize, Clone, Default)]
pub struct GuardrailPatch {
    #[serde(default)]
    pub locked: Option<bool>,
}

/// The root of `context/binding.yml`: which context a profile binds against.
///
/// `kind` is what the host resolves on. Before it existed, a host had to *infer* which adapter a
/// binding wanted by inspecting the bound directory, which cannot express a context that is not a
/// directory at all — a semantic layer held by a service, say. Declaring it also makes the choice
/// reviewable in git rather than a property of whatever happens to be on disk.
#[derive(Debug, Deserialize, Clone)]
pub struct BindingFile {
    /// Which kind of context this binds. An **open string**, opaque to the compiler — like `tier`
    /// and `provider` elsewhere — because the set of context kinds is a host's to extend.
    /// Required: there is no default, because no one kind is the obvious meaning of silence once
    /// core reads no semantic format itself.
    pub kind: String,
    /// As-authored locator for the bound context. A path relative to the Warble project-dir for the
    /// kinds a host reads off disk; for any other kind, whatever that host's resolver understands
    /// (e.g. a service-qualified id). The compiler only ever echoes it — into the IR's
    /// `context_binding.project` and the `{{project}}` placeholder — never interprets it.
    pub project: String,
    /// Fields a host's own `kind` declares that the compiler knows nothing about, kept so its
    /// resolver can read them. Empty for the kinds warble resolves itself.
    #[serde(flatten)]
    pub extra: HashMap<String, serde_yaml::Value>,
}

impl BindingFile {
    /// A raw source with no semantic layer over it yet: the input shape of the constitutive family,
    /// whose *output* is the MDL.
    pub const RAW_SOURCE: &'static str = "raw_source";

    /// A semantic layer that is not here — held by whatever will answer the questions. `project` is
    /// a locator naming it, never a path, and nothing is read: the compiler binds
    /// [`crate::ExternalContext`], which answers no predicate at all.
    pub const EXTERNAL: &'static str = "external";

    /// A context **already resolved by the host** into Warble's own projection. `project` stays
    /// what it is for every other kind — the bound layer's identity — and the prepared-context
    /// document (see [`crate::PreparedContext`]) is named by a separate `document` field. The host
    /// produced that document by reading whatever semantic format it speaks, which is how a format
    /// warble has no adapter for — any format at all — binds without teaching core about it.
    pub const PREPARED: &'static str = "prepared";
}

/// The root of a `component.yml`: identity, anatomy (`type` / `realization_kind` / `trigger`),
/// the LLM steps that realize it, and everything that gates or governs how it runs
/// (guardrails, preconditions, capabilities).
#[derive(Debug, Deserialize, Clone)]
#[serde(deny_unknown_fields)]
pub struct ComponentFile {
    pub id: String,
    pub verb: String,
    #[serde(rename = "type")]
    pub component_type: String,
    pub realization_kind: String,
    pub binding_mode: String,
    #[serde(default)]
    pub context_requirements: Vec<String>,
    #[serde(default)]
    pub context_precondition: Vec<Precondition>,
    #[serde(default)]
    pub params: Vec<Param>,
    pub llm_steps: Vec<LlmStep>,
    pub trigger: Trigger,
    pub guardrails: Vec<Guardrail>,
    #[serde(default)]
    pub required_capabilities: Vec<String>,
    #[serde(default)]
    pub borrowed_actions: Vec<String>,
    pub effect: Effect,
    #[serde(default)]
    pub eval: Option<EvalSpec>,
    /// Optional free-form framing shared by every step of this component — what steps/*.md leave
    /// unsaid because it applies to all of them rather than to one. Supports the same
    /// `{{project}}`/`{{project_name}}` placeholders as step bodies. A profile mount may replace it
    /// wholesale via [`ProfileComponentMount::brief`]. Absent by default; only present on components
    /// an author has explicitly given one.
    #[serde(default)]
    pub brief: Option<String>,
    /// What this component is *for*, in a sentence or two — the one field written for a reader
    /// deciding whether to send work here, rather than for the agent doing the work.
    ///
    /// Distinct from [`Self::brief`], which frames how the steps run for the agent already running
    /// them. Every consumer of this field is a selector: the runtime's own agent-selection, a
    /// scope's inventory of what it holds, and a remote agent reading a published skill list. A
    /// description that restates the component's shape ("analytical skill, outcome none") is
    /// therefore worse than none — it is what the back-ends synthesize when this is absent.
    #[serde(default)]
    pub description: Option<String>,
    /// Example requests this component is the right destination for. Same audience as
    /// [`Self::description`]: they discriminate between components whose descriptions read alike.
    #[serde(default)]
    pub examples: Vec<String>,
    /// Named positions in this component's prompt text whose wording is chosen at dispatch rather
    /// than at compile — see [`SlotDecl`]. Absent by default; a component without any compiles to
    /// exactly the IR it did before this field existed.
    #[serde(default)]
    pub slots: Vec<SlotDecl>,
    /// Files this component needs present on disk at run time — see [`AssetDecl`]. Absent by
    /// default; a component without any compiles to exactly the IR it did before this field
    /// existed.
    #[serde(default)]
    pub assets: Vec<AssetDecl>,
}

/// A `context_precondition` entry: a closed-vocabulary predicate the bound context must
/// satisfy before the component runs (e.g. `has_metric`), plus its predicate-specific `args`.
#[derive(Debug, Deserialize, Clone)]
#[serde(deny_unknown_fields)]
pub struct Precondition {
    pub predicate: String,
    #[serde(default)]
    pub args: Option<HashMap<String, serde_yaml::Value>>,
}

/// An authored evaluation spec: which eval template to run for this component, and which
/// metrics it scores.
#[derive(Debug, Deserialize, Clone)]
#[serde(deny_unknown_fields)]
pub struct EvalSpec {
    pub template_ref: String,
    #[serde(default)]
    pub metrics: Vec<String>,
}

/// A component parameter, either bound at dispatch time (`bind`, with an optional `default`)
/// or sourced from context (`source`). Exactly one of `bind`/`source` is expected to be
/// present per the schema, but both are optional here since this is a Deserialize-only view.
#[derive(Debug, Deserialize, Clone)]
#[serde(deny_unknown_fields)]
pub struct Param {
    pub name: String,
    #[serde(default)]
    pub bind: Option<String>,
    #[serde(default)]
    pub source: Option<String>,
    #[serde(default)]
    pub default: Option<serde_yaml::Value>,
}

/// One `llm_steps` entry: a single LLM call — its tier, its prompt reference, and the
/// artifact-flow (`consumes`/`produces`) linking it to other steps.
#[derive(Debug, Deserialize, Clone)]
#[serde(deny_unknown_fields)]
pub struct LlmStep {
    pub name: String,
    pub tier: String,
    pub prompt_ref: String,
    #[serde(default)]
    pub consumes: Vec<String>,
    #[serde(default)]
    pub produces: Option<String>,
    #[serde(default)]
    pub conditional: bool,
    /// The guard deciding whether this conditional step runs (closed vocabulary — see
    /// `compile::GUARD_VOCABULARY`). A step with `conditional: true` must declare one; compile
    /// refuses to guess the condition (see `compile::check_when_guards`).
    #[serde(default)]
    pub when: Option<WhenGuard>,
    /// The subset of the component's `required_capabilities` this step is allowed to reach for.
    /// Must be a subset — a name outside the component's own set is a compile-time loud-fail (see
    /// `compile::check_step_capabilities`). Left empty, a step implicitly shares the component's
    /// whole `required_capabilities` set, matching behavior from before this field existed.
    #[serde(default)]
    pub capabilities: Vec<String>,
    /// Marks this step's `produces` artifact as exclusive to it: only this step may write it.
    /// Purely a provenance marker on the artifact-flow already expressed by `produces` — it does
    /// not reshape `produces` itself or introduce any actor/identity concept.
    #[serde(default)]
    pub produces_exclusive: bool,
    /// Step-local aliases for mounted components this call may invoke. The prompt decides whether
    /// and how often to use an alias; these declarations only authorize static graph edges.
    #[serde(default)]
    pub component_calls: Vec<ComponentCall>,
}

/// One authorized same-profile component call exposed to a single [`LlmStep`].
#[derive(Debug, Deserialize, Clone)]
#[serde(deny_unknown_fields)]
pub struct ComponentCall {
    /// The only callee name exposed to the model/runtime for this step.
    pub alias: String,
    /// The exact `components[].use` identity of the target mount.
    pub component: String,
}

fn default_true() -> bool {
    true
}

/// A closed-vocabulary predicate gating a conditional `llm_step`: `guard` names one of
/// `on_failure` / `on_flag` / `on_missing`, and `target` is the guard-specific argument (a step
/// name, a dotted `artifact.field`, or an artifact name — see `compile::check_when_guards`).
/// No boolean algebra, no expressions, no imperative logic — mirrors the `context_precondition`
/// closed-vocabulary philosophy (invariant #3: no DSL in the composition layer).
#[derive(Debug, Deserialize, Clone)]
#[serde(deny_unknown_fields)]
pub struct WhenGuard {
    pub guard: String,
    pub target: String,
}

/// How this component starts (`kind`: `one_shot` / `scheduled` / `event`).
#[derive(Debug, Deserialize, Clone)]
#[serde(deny_unknown_fields)]
pub struct Trigger {
    pub kind: String,
}

/// A guardrail attached to a component: whether it is `locked` (unpatchable by a mounting
/// profile) or `overridable`, its scope, and an optional threshold.
#[derive(Debug, Deserialize, Clone)]
#[serde(deny_unknown_fields)]
pub struct Guardrail {
    pub name: String,
    #[serde(default)]
    pub locked: Option<bool>,
    #[serde(default)]
    pub overridable: Option<bool>,
    #[serde(default)]
    pub scope: Option<String>,
    #[serde(default)]
    pub threshold: Option<serde_yaml::Value>,
}

/// What a component's steps produce for the host to act on: any render blocks plus the
/// outcome classification.
#[derive(Debug, Deserialize, Clone)]
#[serde(deny_unknown_fields)]
pub struct Effect {
    pub render_blocks: Vec<RenderBlock>,
    pub outcome: Outcome,
}

/// A render block entry, authored either as a bare type name (shorthand, no fields) or as a
/// mapping with an explicit `fields` schema. Always normalized to `{type, fields}` in the IR.
#[derive(Debug, Clone)]
pub struct RenderBlock {
    pub block_type: String,
    pub fields: HashMap<String, String>,
}

impl<'de> Deserialize<'de> for RenderBlock {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: serde::Deserializer<'de>,
    {
        #[derive(Deserialize)]
        #[serde(untagged)]
        enum Repr {
            Bare(String),
            Typed {
                #[serde(rename = "type")]
                block_type: String,
                #[serde(default)]
                fields: HashMap<String, String>,
            },
        }

        Ok(match Repr::deserialize(deserializer)? {
            Repr::Bare(block_type) => RenderBlock {
                block_type,
                fields: HashMap::new(),
            },
            Repr::Typed { block_type, fields } => RenderBlock { block_type, fields },
        })
    }
}

/// The component's outcome classification (`kind`: `none` / `assertion` / `mutation` /
/// `dispatch`) and the outcome-kind-specific fields that go with it.
#[derive(Debug, Deserialize, Clone)]
#[serde(deny_unknown_fields)]
pub struct Outcome {
    pub kind: String,
    #[serde(default)]
    pub verdict_type: Option<String>,
    #[serde(default)]
    pub emits: Option<Vec<String>>,
    #[serde(default)]
    pub target: Option<String>,
    #[serde(default)]
    pub change_type: Option<String>,
    #[serde(default)]
    pub routable_scope: Option<serde_yaml::Value>,
}

/// One `slots:` entry: a named position in prompt text, a set of alternative wordings for it, and
/// optionally a condition that removes it entirely.
///
/// A slot is referenced from prompt text as `{{ slot.<name> }}` — Jinja-native attribute access,
/// deliberately a different syntax from the `{{project}}` / `{{project_name}}` *value*
/// substitutions, so a slot reference and a mistyped placeholder cannot be confused for one
/// another. Compile carries every variant into the IR and selects none of them: which variant a
/// given run uses is decided at dispatch, by the host.
///
/// Slot names are unique across the whole project: a profile-level slot may not reuse a
/// component-level name. Both layers' slots reach the IR as flat, separately-addressed lists, and
/// a single global name space is what lets a consumer resolve `{{ slot.x }}` to one declaration
/// without first having to know which layer the surrounding text came from.
#[derive(Debug, Deserialize, Clone)]
#[serde(deny_unknown_fields)]
pub struct SlotDecl {
    /// The name used in prompt text as `{{ slot.<name> }}`.
    pub name: String,
    /// Alternative wordings, keyed by a label that is **opaque to Warble**: it is neither
    /// interpreted, validated, nor ordered here. Which key a run selects is the host's decision,
    /// expressed in its own dispatch fragment — which is how this mechanism carries wording that
    /// varies per bound model without Warble holding any model knowledge.
    ///
    /// Each value is a file reference, resolved by the same rule as `prompt_ref` (relative to the
    /// directory that owns the declaration, no escaping it, must exist).
    pub variants: HashMap<String, String>,
    /// Which variant to use when the host's selection rules pick none. Required: without it, an
    /// unmatched dispatch would have nothing to put in the slot and could only silently leave it
    /// empty. Must name a key that exists in [`Self::variants`].
    pub default: String,
    /// An optional runtime condition; when it does not hold, the slot is removed entirely rather
    /// than filled with any variant. Shipping instructions for a tool that was withheld is worse
    /// than saying nothing, which is why removal is a first-class outcome and not an empty
    /// variant.
    ///
    /// Deliberately not named `when`: that word is already taken by `llm_steps[].when`, whose
    /// closed guard vocabulary means something else entirely.
    #[serde(default)]
    pub present_when: Option<String>,
}

/// One `assets:` entry: a file this component needs present on disk at run time — a themed
/// template, a stylesheet, a whole scaffold tree — that is never read into the IR.
///
/// This is the deliberate counterpart to [`SlotDecl`]: a slot variant's *content* is prompt text,
/// so it belongs in the IR; an asset's content is arbitrary (often binary, sometimes dozens of
/// files) and is only ever needed on disk at dispatch time, so the IR carries just enough to
/// identify and verify it — the path plus a content hash and size — never the bytes themselves.
/// How an asset actually lands on disk (copied, symlinked, baked into an image) is a target
/// decision that this declaration does not make.
///
/// `hash` and `bytes` are never authored: core is sans-IO, so the host reads the file, computes
/// them, and fills them in before the declaration reaches [`crate::compile`]. They exist here
/// (rather than being threaded through as a separate parameter, the way slot-variant content is)
/// because — unlike a slot variant — nothing about compiling an asset needs core to see the file's
/// content, only its identity.
#[derive(Debug, Deserialize, Clone)]
#[serde(deny_unknown_fields)]
pub struct AssetDecl {
    /// A file reference, resolved by the same rule as `prompt_ref` (relative to the directory
    /// that owns the declaration, no escaping it, must exist).
    pub path: String,
    /// The asset's content identity as `sha256:<hex>`. Populated by the host after it resolves and
    /// reads `path`, before compile.
    ///
    /// **Not authorable.** `skip` keeps it out of deserialization entirely, so together with this
    /// struct's `deny_unknown_fields` a `hash:` written in `component.yml` is a loud parse failure
    /// rather than a value silently replaced by the computed one. An author-written hash could
    /// only ever rot: it is a claim about file content that nothing keeps true, and accepting one
    /// would mean the IR sometimes carries a fingerprint that does not match the file it names.
    #[serde(skip)]
    pub hash: Option<String>,
    /// The asset's size in bytes. Populated alongside `hash`, and not authorable for the same
    /// reason.
    #[serde(skip)]
    pub bytes: Option<u64>,
}

/// Slot-variant file contents, read by the host and handed to [`crate::compile`] — the slot
/// counterpart of the `step_contents` map, and for the same reason: core is sans-IO, so every
/// authored file is read by the caller and passed in.
///
/// Keys mirror where the declaration lives, because the two layers are addressed separately all
/// the way into the IR.
#[derive(Debug, Clone, Default)]
pub struct SlotContents {
    /// component id -> slot name -> variant key -> raw authored content.
    pub components: HashMap<String, HashMap<String, HashMap<String, String>>>,
    /// profile-level slot name -> variant key -> raw authored content.
    pub profile: HashMap<String, HashMap<String, String>>,
}