Skip to main content

areev_loop/
llm.rs

1//! Optional LLM enrichment (proposal §9).
2//!
3//! The engine's deterministic output stays a pure function of `(store, params,
4//! now)`. This layer is strictly **additive**: with a backend attached the
5//! pipeline gains two optional stages —
6//!
7//! ```text
8//! ANALYZE (deterministic) → DISCOVER (LLM) → ENRICH (LLM) → VALIDATE+DEDUP → STORE
9//! ```
10//!
11//! and with no backend those stages are the identity function, so the no-LLM
12//! path is byte-for-byte the deterministic path. The LLM can only:
13//!   - **DISCOVER**: propose *new* draft recommendations, which enter through
14//!     the ordinary candidate/dedup/store path stamped `origin = llm` — so they
15//!     can **never auto-apply** and never target prompt/host surfaces. A draft
16//!     MAY author a `lesson` (one capped imperative line); a lesson-bearing
17//!     draft that survives GROUND + VERIFY stamps as an *applicable*,
18//!     rollbackable `ADD fact` proposal instead of an advisory flag — still
19//!     `origin = llm`, so applying it always takes a human review with a
20//!     BECAUSE plus an explicit apply; and
21//!   - **ENRICH**: add a whitelisted `guidance` note to a deterministic
22//!     recommendation. The engine-templated summary is always kept; the model
23//!     never rewrites it.
24//!
25//! Trust floor (enforced by the engine, not the backend): responses are parsed
26//! to a fixed schema (unknown fields dropped, strings capped), DISCOVER drafts
27//! must cite evidence hashes present in the bundle, instructions never
28//! interleave with evidence, and a failed/timed-out/garbled call drops the LLM
29//! contribution for the run rather than failing it.
30//!
31//! `CommandLlm` mirrors the shipped `CommandEmbed`: whitespace-split argv (no
32//! shell), one process per call, a JSON request on stdin and a JSON response on
33//! stdout, and a construction-time probe that fails loud.
34
35use crate::error::{Error, Result};
36use serde::{Deserialize, Serialize};
37use serde_json::Value;
38
39/// Caps that bound what a single LLM contribution can inject (defense in depth;
40/// the engine enforces them after parsing).
41pub const MAX_LLM_DRAFTS: usize = 8;
42pub const MAX_GUIDANCE_LEN: usize = 600;
43pub const MAX_SUMMARY_LEN: usize = 200;
44/// An authored lesson is one imperative line — anything longer is a document,
45/// not a lesson, and a bound on what a single approved apply can put into
46/// every future prompt.
47pub const MAX_LESSON_LEN: usize = 240;
48/// Caps on the rest of the proposal vocabulary. Each one bounds what a single
49/// approved apply can put into every future run of the agent, so they are part
50/// of the trust floor rather than tuning knobs.
51pub const MAX_RELATION_LEN: usize = 64;
52pub const MAX_OBJECT_LEN: usize = 480;
53pub const MAX_QUERY_BODY_LEN: usize = 2_000;
54pub const MAX_PLAN_EDITS: usize = 8;
55pub const MAX_CODE_LEN: usize = 20_000;
56
57/// A backend that answers one JSON request with one JSON response. Object-safe
58/// so the engine can hold a `Box<dyn LlmBackend>`.
59pub trait LlmBackend: Send + Sync {
60    /// Model identifier, stamped as provenance on `origin = llm` grains.
61    fn model(&self) -> &str;
62    /// Run one request. `request` is a JSON string; the returned text is
63    /// expected to be JSON and is validated by the caller.
64    fn complete(&self, request: &str) -> Result<String>;
65}
66
67/// Boxed backends forward — lets decorators wrap `Box<dyn LlmBackend>`
68/// without knowing the concrete type.
69impl<T: LlmBackend + ?Sized> LlmBackend for Box<T> {
70    fn model(&self) -> &str {
71        (**self).model()
72    }
73    fn complete(&self, request: &str) -> Result<String> {
74        (**self).complete(request)
75    }
76}
77
78// ---- wire schema (request) -------------------------------------------------
79
80/// One deterministic finding, handed to DISCOVER as context (never as an
81/// instruction — see `LlmRequest`).
82#[derive(Debug, Clone, Serialize)]
83pub struct FindingBrief {
84    pub analyzer: String,
85    pub summary: String,
86    pub target: String,
87    pub severity: String,
88}
89
90/// One evidence grain, provenance-tagged.
91#[derive(Debug, Clone, Serialize)]
92pub struct EvidenceItem {
93    pub hash: String,
94    pub grain_type: String,
95    pub text: String,
96}
97
98/// The request envelope. `op` selects the stage; `instructions` is a fixed
99/// engine string kept in its own field so it never interleaves with evidence.
100#[derive(Debug, Clone, Serialize)]
101pub struct LlmRequest<'a> {
102    #[serde(rename = "loop")]
103    pub loop_proto: u8,
104    pub op: &'a str,
105    pub instructions: &'a str,
106    #[serde(skip_serializing_if = "Vec::is_empty")]
107    pub findings: Vec<FindingBrief>,
108    #[serde(skip_serializing_if = "Vec::is_empty")]
109    pub evidence: Vec<EvidenceItem>,
110    /// The operator's recent decisions — what they reject/approve — so the
111    /// model learns this reviewer's taste. (Bounded by the engine.)
112    #[serde(skip_serializing_if = "Vec::is_empty")]
113    pub rejected: Vec<String>,
114    #[serde(skip_serializing_if = "Vec::is_empty")]
115    pub approved: Vec<String>,
116}
117
118// ---- wire schema (response) ------------------------------------------------
119
120/// One DISCOVER draft as returned by the model. Unknown fields are dropped by
121/// serde; the engine further validates (cite-check, caps, target class,
122/// grounding, and independent verification before it is ever stored).
123#[derive(Debug, Clone, Deserialize, Default)]
124#[serde(default)]
125pub struct LlmDraft {
126    pub summary: String,
127    pub target: String,
128    pub guidance: String,
129    pub evidence: Vec<String>,
130    /// The model's self-reported confidence 0.0–1.0 that this finding is both
131    /// correct and materially useful (§5.1). Missing/garbled → 0.0 (rejected by
132    /// the confidence floor), a safe default.
133    pub confidence: f64,
134    /// Optional authored lesson: one imperative rule the model proposes to
135    /// record as a Fact grain. Empty (the default) keeps the draft advisory.
136    /// A non-empty lesson makes the surviving recommendation *applicable* —
137    /// through human review + apply only, never auto-apply — and the lesson
138    /// text is folded into the GROUND claim and VERIFY summary so both gates
139    /// judge exactly what an apply would write.
140    pub lesson: String,
141    /// The generalized proposal vocabulary (§9.1): what change this draft asks
142    /// a reviewer to make. Held as raw JSON so one draft naming an unknown or
143    /// malformed `kind` degrades to advisory instead of dropping the whole
144    /// response — read it through [`LlmDraft::parsed_proposal`].
145    pub proposal: Option<Value>,
146}
147
148/// One field-level edit to a Workflow plan grain. `from` is a staleness check
149/// (it must equal what the live plan holds at `path`), which is what stops a
150/// proposal authored against a superseded plan from applying to a newer one —
151/// the role `base_digest` plays on [`super::recommendation::Proposal::Edit`].
152#[derive(Debug, Clone, Deserialize, Default, PartialEq)]
153#[serde(default)]
154pub struct PlanEdit {
155    /// A dotted path into the plan body, e.g. `edges.2.max_cycles`. The
156    /// engine's allowlist decides which paths are editable at all.
157    pub path: String,
158    pub from: Value,
159    pub to: Value,
160}
161
162/// What a DISCOVER draft proposes to change. Closed vocabulary: every variant
163/// maps onto an apply path that already records an inverse, and anything the
164/// model returns outside it leaves the draft advisory.
165///
166/// Note what each variant does NOT carry. The subject of a `Fact`, the name of
167/// a `QueryRevision`, the hash of a `PlanRevision` and the tool of a
168/// `CodeRevision` all come from the draft's `target`, and the evalset a
169/// `CodeRevision` is gated against comes from the substrate — so the model
170/// names the change but never names its own scope or its own grader.
171#[derive(Debug, Clone, Deserialize, PartialEq)]
172#[serde(tag = "kind", rename_all = "snake_case")]
173pub enum DraftProposal {
174    /// One imperative line recorded as a Fact with `relation = "lesson"`.
175    /// The pre-vocabulary shape, and still the default one.
176    Lesson {
177        #[serde(default)]
178        lesson: String,
179    },
180    /// A durable fact under a model-chosen relation — the "stop making a
181    /// person re-supply this every time" proposal.
182    Fact {
183        #[serde(default)]
184        relation: String,
185        #[serde(default)]
186        object: String,
187    },
188    /// A rewrite of the saved CAL query or template named by the target: the
189    /// agent changing how it assembles its own context.
190    QueryRevision {
191        #[serde(default)]
192        body: String,
193    },
194    /// Field-level edits to the Workflow plan named by the target. Node
195    /// topology is not expressible here by construction — only the paths the
196    /// engine's allowlist admits.
197    PlanRevision {
198        #[serde(default)]
199        edits: Vec<PlanEdit>,
200    },
201    /// New source for the executable tool named by the target. Applies only
202    /// through §7.4's recorded evalset-run edge (Rule E1).
203    CodeRevision {
204        #[serde(default)]
205        source: String,
206    },
207}
208
209impl LlmDraft {
210    /// The proposal this draft makes, or `None` when it is advisory.
211    ///
212    /// An explicit `proposal` decides on its own: if it names an unknown kind
213    /// or fails to parse, the draft is advisory rather than being quietly
214    /// re-read as something the model did not ask for. `lesson` is the
215    /// fallback only when no `proposal` was sent at all, which is what keeps
216    /// every transcript recorded before the vocabulary existed parsing — and
217    /// therefore keeps the published runs comparable.
218    pub fn parsed_proposal(&self) -> Option<DraftProposal> {
219        if let Some(v) = &self.proposal {
220            return serde_json::from_value::<DraftProposal>(v.clone()).ok();
221        }
222        if self.lesson.trim().is_empty() {
223            None
224        } else {
225            Some(DraftProposal::Lesson {
226                lesson: self.lesson.clone(),
227            })
228        }
229    }
230}
231
232/// The DISCOVER response.
233#[derive(Debug, Clone, Deserialize, Default)]
234#[serde(default)]
235pub struct DiscoverResponse {
236    pub recommendations: Vec<LlmDraft>,
237}
238
239/// The ENRICH response: guidance keyed by target_ref of a deterministic rec.
240#[derive(Debug, Clone, Deserialize, Default)]
241#[serde(default)]
242pub struct EnrichResponse {
243    /// `[{ "target": "...", "guidance": "..." }]`
244    pub notes: Vec<EnrichNote>,
245}
246
247#[derive(Debug, Clone, Deserialize, Default)]
248#[serde(default)]
249pub struct EnrichNote {
250    pub target: String,
251    pub guidance: String,
252}
253
254// ---- verifier stages (§5.2 GROUND, §5.3 VERIFY) ----------------------------
255
256/// GROUND request: for each candidate draft, does its cited evidence actually
257/// *entail* the claim? Decompose-then-entail is asked of the model here; a
258/// stronger deployment can swap a dedicated entailment checker behind the same
259/// shape. Kept a separate op/call from DISCOVER (proposer ≠ grounder).
260#[derive(Debug, Clone, Serialize)]
261pub struct GroundRequest<'a> {
262    #[serde(rename = "loop")]
263    pub loop_proto: u8,
264    pub op: &'a str, // "ground"
265    pub instructions: &'a str,
266    pub claims: Vec<GroundItem>,
267}
268
269#[derive(Debug, Clone, Serialize)]
270pub struct GroundItem {
271    pub id: usize,
272    pub claim: String,
273    pub evidence: Vec<EvidenceItem>,
274}
275
276#[derive(Debug, Clone, Deserialize, Default)]
277#[serde(default)]
278pub struct GroundResponse {
279    pub results: Vec<GroundResult>,
280}
281
282#[derive(Debug, Clone, Deserialize, Default)]
283#[serde(default)]
284pub struct GroundResult {
285    pub id: usize,
286    pub supported: bool,
287    pub reason: String,
288}
289
290/// VERIFY request: an **independent** adversarial pass (a separate call from the
291/// proposer — the anti-Goodhart rule) that tries to refute each grounded draft
292/// on novelty / reality / out-of-context grounds and returns keep/kill + a
293/// calibrated confidence. Deterministic findings are passed as context so the
294/// verifier can reject drafts that merely restate them.
295#[derive(Debug, Clone, Serialize)]
296pub struct VerifyRequest<'a> {
297    #[serde(rename = "loop")]
298    pub loop_proto: u8,
299    pub op: &'a str, // "verify"
300    pub instructions: &'a str,
301    pub findings: Vec<VerifyItem>,
302}
303
304#[derive(Debug, Clone, Serialize)]
305pub struct VerifyItem {
306    pub id: usize,
307    pub summary: String,
308    pub target: String,
309    pub evidence: Vec<EvidenceItem>,
310}
311
312#[derive(Debug, Clone, Deserialize, Default)]
313#[serde(default)]
314pub struct VerifyResponse {
315    pub results: Vec<VerifyResult>,
316}
317
318#[derive(Debug, Clone, Deserialize, Default)]
319#[serde(default)]
320pub struct VerifyResult {
321    pub id: usize,
322    pub keep: bool,
323    pub confidence: f64,
324    pub reason: String,
325}
326
327/// The probe response.
328#[derive(Debug, Clone, Deserialize, Default)]
329#[serde(default)]
330struct ProbeResponse {
331    model: String,
332}
333
334/// A subprocess LLM backend. One process per call; argv is whitespace-split
335/// with no shell (identical rules to `CommandEmbed`).
336pub struct CommandLlm {
337    argv: Vec<String>,
338    model: String,
339}
340
341impl CommandLlm {
342    /// Construct and probe. The probe (`{"loop":1,"op":"probe"}`) must return
343    /// JSON with a `model` (or one is supplied), so a misconfigured command
344    /// fails at construction, not mid-run.
345    pub fn new(cmd: &str, model: Option<&str>) -> Result<Self> {
346        let argv: Vec<String> = cmd.split_whitespace().map(str::to_string).collect();
347        if argv.is_empty() {
348            return Err(Error::LlmBackend("--llm-cmd is empty".into()));
349        }
350        let mut me = CommandLlm {
351            argv,
352            model: model.unwrap_or("").to_string(),
353        };
354        let probe = me.run(r#"{"loop":1,"op":"probe"}"#)?;
355        let parsed: ProbeResponse = serde_json::from_str(probe.trim()).map_err(|e| {
356            Error::LlmBackend(format!("--llm-cmd probe did not return JSON with a model: {e}"))
357        })?;
358        if me.model.is_empty() {
359            me.model = if parsed.model.is_empty() {
360                "unspecified".to_string()
361            } else {
362                parsed.model
363            };
364        }
365        Ok(me)
366    }
367
368    fn run(&self, request: &str) -> Result<String> {
369        let out = crate::proc::run_argv(&self.argv, request, Some(crate::proc::DEFAULT_TIMEOUT))
370            .map_err(|e| Error::LlmBackend(format!("spawn --llm-cmd {:?}: {e}", self.argv[0])))?;
371        if let Some(why) = out.failure("--llm-cmd") {
372            return Err(Error::LlmBackend(why));
373        }
374        String::from_utf8(out.stdout)
375            .map_err(|e| Error::LlmBackend(format!("--llm-cmd stdout not UTF-8: {e}")))
376    }
377}
378
379impl LlmBackend for CommandLlm {
380    fn model(&self) -> &str {
381        &self.model
382    }
383    fn complete(&self, request: &str) -> Result<String> {
384        self.run(request)
385    }
386}
387
388/// Parse a DISCOVER response, dropping anything malformed. Never errors on
389/// model garbage — a bad response yields no drafts.
390pub fn parse_discover(raw: &str) -> DiscoverResponse {
391    serde_json::from_str(raw.trim()).unwrap_or_default()
392}
393
394/// Parse an ENRICH response, dropping anything malformed.
395pub fn parse_enrich(raw: &str) -> EnrichResponse {
396    serde_json::from_str(raw.trim()).unwrap_or_default()
397}
398
399/// Parse a GROUND response; garbage → no results (⇒ every draft is treated as
400/// ungrounded and dropped, the safe default).
401pub fn parse_ground(raw: &str) -> GroundResponse {
402    serde_json::from_str(raw.trim()).unwrap_or_default()
403}
404
405/// Parse a VERIFY response; garbage → no results (⇒ every draft is dropped).
406pub fn parse_verify(raw: &str) -> VerifyResponse {
407    serde_json::from_str(raw.trim()).unwrap_or_default()
408}
409
410/// Truncate to a char cap without splitting a UTF-8 boundary.
411pub fn cap(s: &str, max: usize) -> String {
412    if s.chars().count() <= max {
413        s.to_string()
414    } else {
415        s.chars().take(max).collect()
416    }
417}
418
419#[cfg(test)]
420mod tests {
421    use super::*;
422
423    #[test]
424    fn parse_discover_drops_garbage() {
425        assert!(parse_discover("not json").recommendations.is_empty());
426        let r = parse_discover(r#"{"recommendations":[{"summary":"s","target":"entity:x/y","evidence":["h1"],"junk":1}]}"#);
427        assert_eq!(r.recommendations.len(), 1);
428        assert_eq!(r.recommendations[0].summary, "s");
429        assert_eq!(r.recommendations[0].evidence, vec!["h1"]);
430    }
431
432    #[test]
433    fn parse_enrich_reads_notes() {
434        let r = parse_enrich(r#"{"notes":[{"target":"entity:a/b","guidance":"g"}]}"#);
435        assert_eq!(r.notes.len(), 1);
436        assert_eq!(r.notes[0].guidance, "g");
437    }
438
439    #[test]
440    fn lesson_desugars_when_no_proposal_is_sent() {
441        // Every transcript recorded before the vocabulary existed must still
442        // resolve to the same change, or the published runs stop being
443        // comparable with anything measured after it.
444        let r = parse_discover(
445            r#"{"recommendations":[{"summary":"s","target":"entity:a/b","evidence":["h"],"lesson":"Do the thing"}]}"#,
446        );
447        assert_eq!(
448            r.recommendations[0].parsed_proposal(),
449            Some(DraftProposal::Lesson { lesson: "Do the thing".into() })
450        );
451    }
452
453    #[test]
454    fn an_explicit_proposal_wins_over_lesson() {
455        let r = parse_discover(
456            r#"{"recommendations":[{"summary":"s","target":"entity:a/b","evidence":["h"],
457                "lesson":"ignored","proposal":{"kind":"fact","relation":"alias_of","object":"Cobalt Cloud"}}]}"#,
458        );
459        assert_eq!(
460            r.recommendations[0].parsed_proposal(),
461            Some(DraftProposal::Fact {
462                relation: "alias_of".into(),
463                object: "Cobalt Cloud".into()
464            })
465        );
466    }
467
468    #[test]
469    fn an_unparseable_proposal_is_advisory_not_reinterpreted() {
470        // A garbled proposal must NOT fall back to the lesson: applying an
471        // `ADD fact` when the model asked for something we could not read is
472        // doing something it never proposed.
473        for body in [
474            r#""proposal":{"kind":"teleport","x":1}"#,
475            r#""proposal":{"kind":"plan_revision","edits":"not-a-list"}"#,
476            r#""proposal":42"#,
477        ] {
478            let raw = format!(
479                r#"{{"recommendations":[{{"summary":"s","target":"entity:a/b","evidence":["h"],"lesson":"L",{body}}}]}}"#
480            );
481            let r = parse_discover(&raw);
482            assert_eq!(r.recommendations.len(), 1, "{body}");
483            assert_eq!(r.recommendations[0].parsed_proposal(), None, "{body}");
484        }
485    }
486
487    #[test]
488    fn one_bad_proposal_does_not_drop_its_siblings() {
489        // Per-draft tolerance: the whole response surviving is what keeps a
490        // single malformed kind from silently costing a run its findings.
491        let r = parse_discover(
492            r#"{"recommendations":[
493                {"summary":"a","target":"entity:a/b","evidence":["h"],"proposal":{"kind":"nope"}},
494                {"summary":"b","target":"entity:a/c","evidence":["h"],"proposal":{"kind":"lesson","lesson":"Keep me"}}
495            ]}"#,
496        );
497        assert_eq!(r.recommendations.len(), 2);
498        assert_eq!(r.recommendations[0].parsed_proposal(), None);
499        assert_eq!(
500            r.recommendations[1].parsed_proposal(),
501            Some(DraftProposal::Lesson { lesson: "Keep me".into() })
502        );
503    }
504
505    #[test]
506    fn plan_edits_carry_their_staleness_check() {
507        let r = parse_discover(
508            r#"{"recommendations":[{"summary":"s","target":"grain:abc","evidence":["h"],
509                "proposal":{"kind":"plan_revision","edits":[{"path":"retries.fetch","from":null,"to":3}]}}]}"#,
510        );
511        let Some(DraftProposal::PlanRevision { edits }) =
512            r.recommendations[0].parsed_proposal()
513        else {
514            panic!("expected a plan revision");
515        };
516        assert_eq!(edits.len(), 1);
517        assert_eq!(edits[0].path, "retries.fetch");
518        assert_eq!(edits[0].from, Value::Null);
519        assert_eq!(edits[0].to, Value::from(3));
520    }
521
522    #[test]
523    fn cap_respects_char_boundaries() {
524        assert_eq!(cap("hello", 3), "hel");
525        assert_eq!(cap("héllo", 2), "hé");
526        assert_eq!(cap("hi", 5), "hi");
527    }
528}