Skip to main content

akribes_types/
value.rs

1//! Value type carried by every workflow input/output and engine event.
2
3use crate::error::{ErrorCode, ErrorDetail, ErrorKind, ErrorSource};
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6use std::fmt;
7use std::hash::{Hash, Hasher};
8
9/// JSON envelope key for an `Unable` payload — `{ "unable": { ... } }`.
10///
11/// Re-exported from `akribes_core::unable::UNABLE_ENVELOPE_KEY` for
12/// backwards compatibility; the canonical definition lives here so the
13/// SDK can produce / consume the envelope without depending on core.
14pub const UNABLE_ENVELOPE_KEY: &str = "unable";
15
16/// Default `ErrorCode` for `Value::FatalError` payloads that came in
17/// without one — older wire formats / hand-built fatals.
18fn default_error_code_other() -> ErrorCode {
19    ErrorCode::Other
20}
21
22#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
23pub struct AgentData {
24    pub name: String,
25    pub provider: String,
26    pub model_name: String,
27    pub system_prompt: Option<String>,
28    /// Whether extended reasoning / thinking is enabled for this agent.
29    /// Resolved by the engine from the agent's `thinking` property and the
30    /// backing model's capability. See `models::is_thinking_capable`.
31    #[serde(default)]
32    pub thinking: bool,
33    /// Sampling temperature, when the user pinned one via `temperature:
34    /// <float>` on the agent block. `None` means "use the provider
35    /// default" — the engine will not set the field on outgoing request
36    /// bodies in that case. Per-task overrides are computed at the call
37    /// site from `Stmt::TaskDef::temperature` (issue #330).
38    #[serde(default)]
39    pub temperature: Option<f64>,
40}
41
42/// Categorical tag for an `Unable` response. Mirrors the choice variants
43/// on the built-in `Unable` record — keep in lock-step with
44/// `akribes_core::unable::unable_typedef_stmt`. Serializes to the lower-case
45/// snake form on the wire.
46#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)]
47#[serde(rename_all = "snake_case")]
48pub enum UnableCategory {
49    InputMissing,
50    InputAmbiguous,
51    InputConflicts,
52    Capability,
53    Other,
54    /// Synthetic category produced by the engine (not an agent) when a task
55    /// with `allow_partial: true` exhausts its validation retry budget. The
56    /// engine folds the exhaustion into a canonical `UnableRecord` so the
57    /// existing `on unable <target>` / `on_validation_exhausted` routing
58    /// pipes can carry it without a bespoke `SuspendTrigger` variant. See
59    /// `Engine::build_partial_retry_unable` and issue #202.
60    PartialRetry,
61}
62
63impl UnableCategory {
64    /// Wire string: `"input_missing"`, `"input_ambiguous"`,
65    /// `"input_conflicts"`, `"capability"`, `"other"`, `"partial_retry"`.
66    pub fn as_wire_str(&self) -> &'static str {
67        match self {
68            UnableCategory::InputMissing => "input_missing",
69            UnableCategory::InputAmbiguous => "input_ambiguous",
70            UnableCategory::InputConflicts => "input_conflicts",
71            UnableCategory::Capability => "capability",
72            UnableCategory::Other => "other",
73            UnableCategory::PartialRetry => "partial_retry",
74        }
75    }
76
77    /// Parse a wire string back to a category. Returns `None` for any
78    /// unrecognized category — callers may choose to fall back to
79    /// [`UnableCategory::Other`] or surface a validation error depending
80    /// on how strict they need to be.
81    pub fn from_wire_str(s: &str) -> Option<Self> {
82        match s {
83            "input_missing" => Some(UnableCategory::InputMissing),
84            "input_ambiguous" => Some(UnableCategory::InputAmbiguous),
85            "input_conflicts" => Some(UnableCategory::InputConflicts),
86            "capability" => Some(UnableCategory::Capability),
87            "other" => Some(UnableCategory::Other),
88            "partial_retry" => Some(UnableCategory::PartialRetry),
89            _ => None,
90        }
91    }
92}
93
94/// Structured `I can't` response from an agent. The wire envelope is
95/// `{ "unable": { "reason": str, "missing": [str], "category": str } }`;
96/// this type is the payload after the envelope key is stripped.
97/// `missing` defaults to `[]` on both wire and runtime so callers never
98/// have to branch on `Option<Vec<_>>`.
99#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)]
100pub struct UnableRecord {
101    pub reason: String,
102    #[serde(default)]
103    pub missing: Vec<String>,
104    pub category: UnableCategory,
105}
106
107#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
108pub enum Value {
109    String(String),
110    Int(i64),
111    /// Fractional numeric value. Backed by `f64` for now (master plan §D1
112    /// — "we do not need more digits right now"). The variant name decouples
113    /// the value-layer rename (`Float` → `Decimal`) from a future swap to
114    /// `rust_decimal::Decimal`, which is a non-breaking value-layer-only
115    /// change. JSON serialisation always emits a JSON number — there is
116    /// no envelope.
117    Decimal(f64),
118    List(Vec<Value>),
119    Document(String),
120    AgentRef(AgentData),
121    Object(HashMap<String, Value>),
122    Bool(bool),
123    /// Structured `Unable` payload from a task declared `T | Unable`.
124    /// On-wire shape is the envelope `{ "unable": { ... } }` — see
125    /// `to_json` / `from_json` below for the exact round-trip.
126    Unable(UnableRecord),
127    /// Discriminated-union payload from a task whose declared return type
128    /// is `A | B | ... | Unable` (#226). `variant` is the canonical record
129    /// name (e.g. `"Feature"`, `"ClaimErr"`) and `payload` is the parsed
130    /// record with the `kind` discriminator stripped. The `Unable` arm is
131    /// still represented as [`Value::Unable`] — this variant only carries
132    /// non-Unable arms. The wire shape is `{"kind": "<variant>", ...}`.
133    Union {
134        variant: String,
135        payload: Box<Value>,
136    },
137    /// Failure value carrying full structured detail. Construct via
138    /// [`Value::fatal`], [`Value::fatal_kind`], or [`Value::fatal_code`] —
139    /// they fill `code`, `user_message`, etc. consistently. Existing
140    /// pattern matches like `Value::FatalError { message, kind, .. }`
141    /// continue to work; reach for the extra fields when surfacing
142    /// errors externally (engine events, OTel, DB).
143    FatalError {
144        message: String,
145        kind: ErrorKind,
146        /// Stable [`ErrorCode`] (e.g. `ProviderRateLimit`,
147        /// `ScriptDepthExceeded`). Defaults to [`ErrorCode::Other`] for
148        /// legacy paths that haven't been migrated, and serializes via
149        /// the canonical AKRIBES-E-XXX wire form.
150        #[serde(default = "default_error_code_other")]
151        code: ErrorCode,
152        /// User-facing single-paragraph summary + suggested action.
153        /// Defaults to [`ErrorCode::default_user_message`] when not
154        /// explicitly overridden.
155        #[serde(default)]
156        user_message: String,
157        /// When the upstream provider supplied a `Retry-After` (or
158        /// equivalent), the suggested wait in milliseconds. Skipped on
159        /// the wire when absent.
160        #[serde(default, skip_serializing_if = "Option::is_none")]
161        retry_after_ms: Option<u64>,
162        /// Where in the workflow the error originated (task/agent/
163        /// provider/model/tool_ref/script/line). Skipped on the wire
164        /// when no fields are set.
165        #[serde(default, skip_serializing_if = "ErrorSource::is_empty")]
166        source: ErrorSource,
167    },
168    /// Opaque JSON payload. Emitted by stdlib builtins that accept or
169    /// return loosely-typed JSON (e.g. a future `std.json_parse`). The
170    /// engine does not introspect it; consumers (Studio, SDKs) render
171    /// it as collapsible pretty-printed JSON. Not produced by any M1
172    /// builtin — the variant ships now so every `match` site downstream
173    /// is exhaustive before M7/M8 land.
174    Json(serde_json::Value),
175    Null,
176}
177
178impl Value {
179    /// Project this Value to its canonical wire-format JSON shape.
180    ///
181    /// Alias for [`Value::to_json`] — kept as a named entry point so
182    /// `EngineEvent` serialization sites that carry workflow output
183    /// values across the wire can call a method whose name says exactly
184    /// what it does. Both methods produce the same JSON; new code that
185    /// is specifically about wire-format projection should prefer this
186    /// one for readability at the call site.
187    ///
188    /// Spec: `docs/src/content/docs/reference/engine-events.mdx` — the
189    /// wire format never exposes the internal tagged-`Value` form to
190    /// consumers. See [`Value::to_json`] for the per-variant projection
191    /// rules.
192    pub fn to_wire_json(&self) -> serde_json::Value {
193        self.to_json()
194    }
195
196    /// Convert to plain JSON without Rust enum tags.
197    ///
198    /// The default serde serialization wraps every variant in its tag:
199    /// `Value::String("hi")` → `{"String":"hi"}`. This method produces
200    /// clean JSON: `"hi"`, `42`, `[...]`, `{...}`, etc.
201    ///
202    /// For [`Value::Unable`], the output is the canonical envelope
203    /// `{ "unable": { "reason": ..., "missing": [...], "category": ... } }`
204    /// — the same shape the schema advertises and that providers return.
205    ///
206    /// **Object key order is NOT a guarantee of this method.**
207    /// [`Value::Object`] is `HashMap`-backed, so iteration order is
208    /// indeterminate, and the resulting `serde_json::Map`'s order
209    /// further depends on whether `serde_json` was compiled with
210    /// `preserve_order`. Callers that need stable key ordering for
211    /// hashing / golden output / wire-compat should route through
212    /// `akribes_core::stdlib::lookup`(`"json_stringify"`) — issue #866 —
213    /// which canonically alpha-sorts every nested object via its
214    /// internal `canonicalize` pass.
215    pub fn to_json(&self) -> serde_json::Value {
216        match self {
217            Value::String(s) | Value::Document(s) => serde_json::Value::String(s.clone()),
218            Value::Int(i) => serde_json::json!(i),
219            Value::Decimal(f) => serde_json::Number::from_f64(*f)
220                .map(serde_json::Value::Number)
221                .unwrap_or(serde_json::Value::Null),
222            Value::Bool(b) => serde_json::json!(b),
223            Value::Null => serde_json::Value::Null,
224            Value::List(items) => {
225                serde_json::Value::Array(items.iter().map(|v| v.to_json()).collect())
226            }
227            Value::Object(map) => {
228                let obj: serde_json::Map<String, serde_json::Value> =
229                    map.iter().map(|(k, v)| (k.clone(), v.to_json())).collect();
230                serde_json::Value::Object(obj)
231            }
232            Value::Unable(rec) => {
233                serde_json::json!({
234                    UNABLE_ENVELOPE_KEY: {
235                        "reason": rec.reason,
236                        "missing": rec.missing,
237                        "category": rec.category.as_wire_str(),
238                    }
239                })
240            }
241            Value::Union { variant, payload } => {
242                // Re-emit as `{"kind": "<variant>", ...<payload>}` — the
243                // mirror image of the engine's lift step. Non-record
244                // payloads (shouldn't happen given analyzer rules) get
245                // emitted under a single `payload` key so callers never
246                // see a malformed value.
247                let mut inner = match payload.to_json() {
248                    serde_json::Value::Object(m) => m,
249                    other => {
250                        let mut m = serde_json::Map::new();
251                        m.insert("payload".to_string(), other);
252                        m
253                    }
254                };
255                inner.insert(
256                    "kind".to_string(),
257                    serde_json::Value::String(variant.clone()),
258                );
259                serde_json::Value::Object(inner)
260            }
261            Value::FatalError {
262                message,
263                kind,
264                code,
265                user_message,
266                retry_after_ms,
267                source,
268            } => {
269                // Wire shape: legacy keys (`FatalError`, `error_kind`) for
270                // SDK back-compat plus the richer envelope under
271                // `error_detail` so consumers can opt into code /
272                // user_message / retry_after_ms / source. The standalone
273                // `code` key is also kept for back-compat with the v0.16
274                // string-code format.
275                serde_json::json!({
276                    "FatalError": message,
277                    "error_kind": kind,
278                    "code": code.as_wire(),
279                    "error_detail": {
280                        "kind": kind,
281                        "code": code.as_wire(),
282                        "message": message,
283                        "user_message": user_message,
284                        "retry_after_ms": retry_after_ms,
285                        "source": source,
286                    },
287                })
288            }
289            Value::AgentRef(data) => serde_json::json!({ "agent": data.name }),
290            Value::Json(j) => j.clone(),
291        }
292    }
293
294    /// Convert from plain JSON into a Value.
295    ///
296    /// Does *not* auto-detect the `Unable` envelope — callers that want
297    /// to discriminate a `T | Unable` result should first consult
298    /// `akribes_core::unable::is_unable_envelope` and then construct
299    /// [`Value::Unable`] explicitly. This keeps `from_json` a pure
300    /// shape-preserving decoder and avoids surprising callers who
301    /// legitimately want an `Object` with an `"unable"` key.
302    ///
303    /// Symmetrically, [`Value::Union`] is not auto-detected from the
304    /// `{"kind": "<variant>", ...}` wire shape because user records may
305    /// legitimately carry a `kind` field. A `Value::Union { variant,
306    /// payload }` produced by [`Value::to_json`] round-trips back as a
307    /// `Value::Object` whose `kind` key survives in the data — the type
308    /// tag is lost. Callers that have the static return type available
309    /// should use [`Value::from_json_with_union_arms`] to reconstruct
310    /// the `Value::Union` tag deterministically (#1289).
311    pub fn from_json(v: &serde_json::Value) -> Self {
312        match v {
313            serde_json::Value::String(s) => Value::String(s.clone()),
314            serde_json::Value::Number(n) => {
315                if let Some(i) = n.as_i64() {
316                    Value::Int(i)
317                } else if let Some(f) = n.as_f64() {
318                    Value::Decimal(f)
319                } else {
320                    // JSON number that doesn't fit i64 or f64 (e.g. u64::MAX via
321                    // arbitrary_precision). Route through `Value::Json` so the
322                    // original numeric shape is preserved for downstream
323                    // consumers (issue #1031).
324                    Value::Json(serde_json::Value::Number(n.clone()))
325                }
326            }
327            serde_json::Value::Bool(b) => Value::Bool(*b),
328            serde_json::Value::Null => Value::Null,
329            serde_json::Value::Array(arr) => {
330                Value::List(arr.iter().map(Value::from_json).collect())
331            }
332            serde_json::Value::Object(map) => Value::Object(
333                map.iter()
334                    .map(|(k, v)| (k.clone(), Value::from_json(v)))
335                    .collect(),
336            ),
337        }
338    }
339
340    /// Decode plain JSON into a Value, lifting variant-union payloads
341    /// when the wire shape `{"kind": "<variant>", ...}` matches one of
342    /// the declared `arm_names`. Symmetric inverse of [`Value::to_json`]
343    /// for `Value::Union { variant, payload }`.
344    ///
345    /// Falls back to [`Value::from_json`] when:
346    /// - the value is not an object;
347    /// - the object has no `"kind"` string field;
348    /// - `kind` does not name any of `arm_names`.
349    pub fn from_json_with_union_arms(v: &serde_json::Value, arm_names: &[&str]) -> Self {
350        if let serde_json::Value::Object(map) = v {
351            if let Some(serde_json::Value::String(kind)) = map.get("kind") {
352                if arm_names.contains(&kind.as_str()) {
353                    let mut stripped = map.clone();
354                    stripped.remove("kind");
355                    let payload = Value::from_json(&serde_json::Value::Object(stripped));
356                    return Value::Union {
357                        variant: kind.clone(),
358                        payload: Box::new(payload),
359                    };
360                }
361            }
362        }
363        Value::from_json(v)
364    }
365
366    /// Build a [`Value::FatalError`] from a fully-formed [`ErrorDetail`].
367    /// Prefer [`Value::fatal_kind`] / [`Value::fatal_code`] for the common
368    /// quick-construction cases.
369    pub fn fatal(detail: ErrorDetail) -> Self {
370        Value::FatalError {
371            message: detail.message,
372            kind: detail.kind,
373            code: detail.code,
374            user_message: detail.user_message,
375            retry_after_ms: detail.retry_after_ms,
376            source: detail.source,
377        }
378    }
379
380    /// Quick-construct a fatal error from a kind + developer message.
381    /// Code and user_message are derived via [`ErrorDetail::from_kind`].
382    /// Use this at sites that don't yet have a specific [`ErrorCode`];
383    /// reach for [`Value::fatal_code`] as soon as you do.
384    pub fn fatal_kind(kind: ErrorKind, message: impl Into<String>) -> Self {
385        Value::fatal(ErrorDetail::from_kind(kind, message))
386    }
387
388    /// Quick-construct a fatal error from a specific [`ErrorCode`].
389    /// Kind and user_message are derived from the code.
390    pub fn fatal_code(code: ErrorCode, message: impl Into<String>) -> Self {
391        Value::fatal(ErrorDetail::new(code, message))
392    }
393
394    /// Build an [`ErrorDetail`] from a `FatalError` value, or `None` for
395    /// any other variant. Clones — use for cross-boundary handoff (engine
396    /// event emission, DB serialization).
397    pub fn as_fatal_detail(&self) -> Option<ErrorDetail> {
398        if let Value::FatalError {
399            message,
400            kind,
401            code,
402            user_message,
403            retry_after_ms,
404            source,
405        } = self
406        {
407            Some(ErrorDetail {
408                kind: *kind,
409                code: *code,
410                message: message.clone(),
411                user_message: user_message.clone(),
412                retry_after_ms: *retry_after_ms,
413                source: source.clone(),
414            })
415        } else {
416            None
417        }
418    }
419
420    /// Back-compat shim for the legacy string-coded `fatal_with_code`
421    /// helper (#429). Internally normalises the string to an
422    /// [`ErrorCode`] via [`ErrorCode::from_wire`]; unrecognised codes
423    /// fall through to [`ErrorCode::Other`].
424    #[deprecated(note = "use Value::fatal_code(ErrorCode::X, msg) instead")]
425    pub fn fatal_with_code(
426        message: impl Into<String>,
427        kind: ErrorKind,
428        code: impl AsRef<str>,
429    ) -> Self {
430        let code = ErrorCode::from_wire(code.as_ref()).unwrap_or(ErrorCode::Other);
431        let detail = ErrorDetail::new(code, message);
432        // Override the kind so legacy callers that paired the wrong
433        // kind+code keep their original kind. Prefer `fatal_code` at
434        // the new sites.
435        Value::FatalError {
436            message: detail.message,
437            kind,
438            code: detail.code,
439            user_message: detail.user_message,
440            retry_after_ms: detail.retry_after_ms,
441            source: detail.source,
442        }
443    }
444}
445
446/// Normalize an `f64` to a canonical bit pattern for hashing purposes
447/// (issue #1012). Two correctness hazards in the naive `f.to_bits()`
448/// approach:
449///
450/// 1. `-0.0` and `+0.0` compare equal under `PartialEq` but have
451///    distinct bit patterns; using `to_bits()` directly violates the
452///    `Hash` contract ("equal values MUST hash equal").
453/// 2. Any NaN — quiet, signalling, with different payloads — has many
454///    distinct bit patterns; we canonicalise to one quiet-NaN repr so
455///    cache lookups behave deterministically across runs.
456///
457/// `Value::Decimal(NaN)` still hashes (unlike `Hash` on a bare `f64`,
458/// which has no impl). Two NaNs are not `PartialEq::eq` to each other,
459/// so the contract "equal values must hash equal" is vacuously
460/// satisfied for NaN — collapsing every NaN bit-pattern to a single
461/// hash slot is permitted ("unequal values MAY hash equal").
462pub fn normalized_f64_bits(f: f64) -> u64 {
463    if f.is_nan() {
464        // Canonical quiet-NaN bit pattern. Payload-stripped,
465        // sign-stripped so every NaN flavor hashes identically.
466        f64::NAN.to_bits()
467    } else if f == 0.0 {
468        // Collapse -0.0 and +0.0 to +0.0's bit pattern. `f == 0.0`
469        // matches both signs; `0.0_f64.to_bits()` is the all-zero
470        // pattern by IEEE-754.
471        0u64
472    } else {
473        f.to_bits()
474    }
475}
476
477/// Deterministic `Hash` for `Value`, used for task-cache keys.
478///
479/// Notes:
480/// - `Value::Decimal` (f64-backed per §D1) is hashed through
481///   [`normalized_f64_bits`] so `-0.0`/`+0.0` collide and every NaN
482///   payload collapses to a single canonical key. See issue #1012.
483/// - `Object` entries are sorted by key before hashing since `HashMap` iteration
484///   order is not stable across runs or insertions.
485impl Hash for Value {
486    fn hash<H: Hasher>(&self, state: &mut H) {
487        std::mem::discriminant(self).hash(state);
488        match self {
489            Value::String(s) | Value::Document(s) => s.hash(state),
490            Value::Int(i) => i.hash(state),
491            Value::Decimal(f) => normalized_f64_bits(*f).hash(state),
492            Value::Bool(b) => b.hash(state),
493            Value::Null => {}
494            Value::List(items) => items.hash(state),
495            Value::Object(map) => {
496                let mut keys: Vec<&String> = map.keys().collect();
497                keys.sort();
498                for k in keys {
499                    k.hash(state);
500                    map[k].hash(state);
501                }
502            }
503            Value::AgentRef(a) => a.hash(state),
504            Value::Unable(rec) => rec.hash(state),
505            Value::Union { variant, payload } => {
506                variant.hash(state);
507                payload.hash(state);
508            }
509            Value::FatalError {
510                message,
511                kind,
512                code,
513                ..
514            } => {
515                message.hash(state);
516                kind.hash(state);
517                code.hash(state);
518            }
519            // Opaque JSON — hash the canonical compact-string repr so
520            // semantically-equal payloads (including reordered object
521            // keys, which `serde_json::Value` does not normalise) would
522            // ideally collide, but serde_json preserves insertion order
523            // in its default `Map<String, Value>`. Acceptable for cache
524            // keys today; cache-hit rate is a polish concern.
525            Value::Json(j) => j.to_string().hash(state),
526        }
527    }
528}
529
530impl Hash for AgentData {
531    fn hash<H: Hasher>(&self, state: &mut H) {
532        self.name.hash(state);
533        self.provider.hash(state);
534        self.model_name.hash(state);
535        self.system_prompt.hash(state);
536        self.thinking.hash(state);
537        // f64 has no Hash impl; route through `normalized_f64_bits` so
538        // `-0.0` and `+0.0` collide and every NaN payload collapses to
539        // one canonical key (issue #1012). Same convention as
540        // `Value::Decimal` above.
541        self.temperature.map(normalized_f64_bits).hash(state);
542    }
543}
544
545impl fmt::Display for Value {
546    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
547        match self {
548            Value::String(s) | Value::Document(s) => write!(f, "{}", s),
549            Value::Int(i) => write!(f, "{}", i),
550            Value::Decimal(fl) => write!(f, "{}", fl),
551            Value::Bool(b) => write!(f, "{}", b),
552            Value::Null => write!(f, "null"),
553            Value::List(items) => {
554                write!(f, "[")?;
555                for (i, item) in items.iter().enumerate() {
556                    if i > 0 {
557                        write!(f, ", ")?;
558                    }
559                    write!(f, "{}", item)?;
560                }
561                write!(f, "]")
562            }
563            Value::Object(map) => {
564                // Iterate by sorted key — `HashMap` iteration order is
565                // not stable across runs or insertion orders, so a bare
566                // `map.iter()` produces nondeterministic Display output
567                // (issue #1081). Mirrors the Hash impl above, which
568                // already sorts keys for the same reason.
569                write!(f, "{{")?;
570                let mut keys: Vec<&String> = map.keys().collect();
571                keys.sort();
572                for (i, k) in keys.iter().enumerate() {
573                    if i > 0 {
574                        write!(f, ", ")?;
575                    }
576                    write!(f, "{}: {}", k, map[*k])?;
577                }
578                write!(f, "}}")
579            }
580            Value::Unable(rec) => {
581                write!(f, "Unable({}: {})", rec.category.as_wire_str(), rec.reason)
582            }
583            Value::Union { variant, payload } => write!(f, "{}({})", variant, payload),
584            Value::FatalError { message, .. } => write!(f, "{}", message),
585            Value::AgentRef(data) => write!(f, "<agent:{}>", data.name),
586            Value::Json(j) => write!(f, "{}", j),
587        }
588    }
589}