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 { message, kind, code, user_message, retry_after_ms, source } => {
262                // Wire shape: legacy keys (`FatalError`, `error_kind`) for
263                // SDK back-compat plus the richer envelope under
264                // `error_detail` so consumers can opt into code /
265                // user_message / retry_after_ms / source. The standalone
266                // `code` key is also kept for back-compat with the v0.16
267                // string-code format.
268                serde_json::json!({
269                    "FatalError": message,
270                    "error_kind": kind,
271                    "code": code.as_wire(),
272                    "error_detail": {
273                        "kind": kind,
274                        "code": code.as_wire(),
275                        "message": message,
276                        "user_message": user_message,
277                        "retry_after_ms": retry_after_ms,
278                        "source": source,
279                    },
280                })
281            }
282            Value::AgentRef(data) => serde_json::json!({ "agent": data.name }),
283            Value::Json(j) => j.clone(),
284        }
285    }
286
287    /// Convert from plain JSON into a Value.
288    ///
289    /// Does *not* auto-detect the `Unable` envelope — callers that want
290    /// to discriminate a `T | Unable` result should first consult
291    /// `akribes_core::unable::is_unable_envelope` and then construct
292    /// [`Value::Unable`] explicitly. This keeps `from_json` a pure
293    /// shape-preserving decoder and avoids surprising callers who
294    /// legitimately want an `Object` with an `"unable"` key.
295    ///
296    /// Symmetrically, [`Value::Union`] is not auto-detected from the
297    /// `{"kind": "<variant>", ...}` wire shape because user records may
298    /// legitimately carry a `kind` field. A `Value::Union { variant,
299    /// payload }` produced by [`Value::to_json`] round-trips back as a
300    /// `Value::Object` whose `kind` key survives in the data — the type
301    /// tag is lost. Callers that have the static return type available
302    /// should use [`Value::from_json_with_union_arms`] to reconstruct
303    /// the `Value::Union` tag deterministically (#1289).
304    pub fn from_json(v: &serde_json::Value) -> Self {
305        match v {
306            serde_json::Value::String(s) => Value::String(s.clone()),
307            serde_json::Value::Number(n) => {
308                if let Some(i) = n.as_i64() {
309                    Value::Int(i)
310                } else if let Some(f) = n.as_f64() {
311                    Value::Decimal(f)
312                } else {
313                    // JSON number that doesn't fit i64 or f64 (e.g. u64::MAX via
314                    // arbitrary_precision). Route through `Value::Json` so the
315                    // original numeric shape is preserved for downstream
316                    // consumers (issue #1031).
317                    Value::Json(serde_json::Value::Number(n.clone()))
318                }
319            }
320            serde_json::Value::Bool(b) => Value::Bool(*b),
321            serde_json::Value::Null => Value::Null,
322            serde_json::Value::Array(arr) => {
323                Value::List(arr.iter().map(Value::from_json).collect())
324            }
325            serde_json::Value::Object(map) => {
326                Value::Object(map.iter().map(|(k, v)| (k.clone(), Value::from_json(v))).collect())
327            }
328        }
329    }
330
331    /// Decode plain JSON into a Value, lifting variant-union payloads
332    /// when the wire shape `{"kind": "<variant>", ...}` matches one of
333    /// the declared `arm_names`. Symmetric inverse of [`Value::to_json`]
334    /// for `Value::Union { variant, payload }`.
335    ///
336    /// Falls back to [`Value::from_json`] when:
337    /// - the value is not an object;
338    /// - the object has no `"kind"` string field;
339    /// - `kind` does not name any of `arm_names`.
340    pub fn from_json_with_union_arms(v: &serde_json::Value, arm_names: &[&str]) -> Self {
341        if let serde_json::Value::Object(map) = v {
342            if let Some(serde_json::Value::String(kind)) = map.get("kind") {
343                if arm_names.iter().any(|n| *n == kind.as_str()) {
344                    let mut stripped = map.clone();
345                    stripped.remove("kind");
346                    let payload =
347                        Value::from_json(&serde_json::Value::Object(stripped));
348                    return Value::Union {
349                        variant: kind.clone(),
350                        payload: Box::new(payload),
351                    };
352                }
353            }
354        }
355        Value::from_json(v)
356    }
357
358    /// Build a [`Value::FatalError`] from a fully-formed [`ErrorDetail`].
359    /// Prefer [`Value::fatal_kind`] / [`Value::fatal_code`] for the common
360    /// quick-construction cases.
361    pub fn fatal(detail: ErrorDetail) -> Self {
362        Value::FatalError {
363            message: detail.message,
364            kind: detail.kind,
365            code: detail.code,
366            user_message: detail.user_message,
367            retry_after_ms: detail.retry_after_ms,
368            source: detail.source,
369        }
370    }
371
372    /// Quick-construct a fatal error from a kind + developer message.
373    /// Code and user_message are derived via [`ErrorDetail::from_kind`].
374    /// Use this at sites that don't yet have a specific [`ErrorCode`];
375    /// reach for [`Value::fatal_code`] as soon as you do.
376    pub fn fatal_kind(kind: ErrorKind, message: impl Into<String>) -> Self {
377        Value::fatal(ErrorDetail::from_kind(kind, message))
378    }
379
380    /// Quick-construct a fatal error from a specific [`ErrorCode`].
381    /// Kind and user_message are derived from the code.
382    pub fn fatal_code(code: ErrorCode, message: impl Into<String>) -> Self {
383        Value::fatal(ErrorDetail::new(code, message))
384    }
385
386    /// Build an [`ErrorDetail`] from a `FatalError` value, or `None` for
387    /// any other variant. Clones — use for cross-boundary handoff (engine
388    /// event emission, DB serialization).
389    pub fn as_fatal_detail(&self) -> Option<ErrorDetail> {
390        if let Value::FatalError { message, kind, code, user_message, retry_after_ms, source } = self {
391            Some(ErrorDetail {
392                kind: *kind,
393                code: *code,
394                message: message.clone(),
395                user_message: user_message.clone(),
396                retry_after_ms: *retry_after_ms,
397                source: source.clone(),
398            })
399        } else {
400            None
401        }
402    }
403
404    /// Back-compat shim for the legacy string-coded `fatal_with_code`
405    /// helper (#429). Internally normalises the string to an
406    /// [`ErrorCode`] via [`ErrorCode::from_wire`]; unrecognised codes
407    /// fall through to [`ErrorCode::Other`].
408    #[deprecated(note = "use Value::fatal_code(ErrorCode::X, msg) instead")]
409    pub fn fatal_with_code(
410        message: impl Into<String>,
411        kind: ErrorKind,
412        code: impl AsRef<str>,
413    ) -> Self {
414        let code = ErrorCode::from_wire(code.as_ref()).unwrap_or(ErrorCode::Other);
415        let detail = ErrorDetail::new(code, message);
416        // Override the kind so legacy callers that paired the wrong
417        // kind+code keep their original kind. Prefer `fatal_code` at
418        // the new sites.
419        Value::FatalError {
420            message: detail.message,
421            kind,
422            code: detail.code,
423            user_message: detail.user_message,
424            retry_after_ms: detail.retry_after_ms,
425            source: detail.source,
426        }
427    }
428}
429
430/// Normalize an `f64` to a canonical bit pattern for hashing purposes
431/// (issue #1012). Two correctness hazards in the naive `f.to_bits()`
432/// approach:
433///
434/// 1. `-0.0` and `+0.0` compare equal under `PartialEq` but have
435///    distinct bit patterns; using `to_bits()` directly violates the
436///    `Hash` contract ("equal values MUST hash equal").
437/// 2. Any NaN — quiet, signalling, with different payloads — has many
438///    distinct bit patterns; we canonicalise to one quiet-NaN repr so
439///    cache lookups behave deterministically across runs.
440///
441/// `Value::Decimal(NaN)` still hashes (unlike `Hash` on a bare `f64`,
442/// which has no impl). Two NaNs are not `PartialEq::eq` to each other,
443/// so the contract "equal values must hash equal" is vacuously
444/// satisfied for NaN — collapsing every NaN bit-pattern to a single
445/// hash slot is permitted ("unequal values MAY hash equal").
446pub fn normalized_f64_bits(f: f64) -> u64 {
447    if f.is_nan() {
448        // Canonical quiet-NaN bit pattern. Payload-stripped,
449        // sign-stripped so every NaN flavor hashes identically.
450        f64::NAN.to_bits()
451    } else if f == 0.0 {
452        // Collapse -0.0 and +0.0 to +0.0's bit pattern. `f == 0.0`
453        // matches both signs; `0.0_f64.to_bits()` is the all-zero
454        // pattern by IEEE-754.
455        0u64
456    } else {
457        f.to_bits()
458    }
459}
460
461/// Deterministic `Hash` for `Value`, used for task-cache keys.
462///
463/// Notes:
464/// - `Value::Decimal` (f64-backed per §D1) is hashed through
465///   [`normalized_f64_bits`] so `-0.0`/`+0.0` collide and every NaN
466///   payload collapses to a single canonical key. See issue #1012.
467/// - `Object` entries are sorted by key before hashing since `HashMap` iteration
468///   order is not stable across runs or insertions.
469impl Hash for Value {
470    fn hash<H: Hasher>(&self, state: &mut H) {
471        std::mem::discriminant(self).hash(state);
472        match self {
473            Value::String(s) | Value::Document(s) => s.hash(state),
474            Value::Int(i) => i.hash(state),
475            Value::Decimal(f) => normalized_f64_bits(*f).hash(state),
476            Value::Bool(b) => b.hash(state),
477            Value::Null => {}
478            Value::List(items) => items.hash(state),
479            Value::Object(map) => {
480                let mut keys: Vec<&String> = map.keys().collect();
481                keys.sort();
482                for k in keys {
483                    k.hash(state);
484                    map[k].hash(state);
485                }
486            }
487            Value::AgentRef(a) => a.hash(state),
488            Value::Unable(rec) => rec.hash(state),
489            Value::Union { variant, payload } => {
490                variant.hash(state);
491                payload.hash(state);
492            }
493            Value::FatalError { message, kind, code, .. } => {
494                message.hash(state);
495                kind.hash(state);
496                code.hash(state);
497            }
498            // Opaque JSON — hash the canonical compact-string repr so
499            // semantically-equal payloads (including reordered object
500            // keys, which `serde_json::Value` does not normalise) would
501            // ideally collide, but serde_json preserves insertion order
502            // in its default `Map<String, Value>`. Acceptable for cache
503            // keys today; cache-hit rate is a polish concern.
504            Value::Json(j) => j.to_string().hash(state),
505        }
506    }
507}
508
509impl Hash for AgentData {
510    fn hash<H: Hasher>(&self, state: &mut H) {
511        self.name.hash(state);
512        self.provider.hash(state);
513        self.model_name.hash(state);
514        self.system_prompt.hash(state);
515        self.thinking.hash(state);
516        // f64 has no Hash impl; route through `normalized_f64_bits` so
517        // `-0.0` and `+0.0` collide and every NaN payload collapses to
518        // one canonical key (issue #1012). Same convention as
519        // `Value::Decimal` above.
520        self.temperature.map(normalized_f64_bits).hash(state);
521    }
522}
523
524impl fmt::Display for Value {
525    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
526        match self {
527            Value::String(s) | Value::Document(s) => write!(f, "{}", s),
528            Value::Int(i) => write!(f, "{}", i),
529            Value::Decimal(fl) => write!(f, "{}", fl),
530            Value::Bool(b) => write!(f, "{}", b),
531            Value::Null => write!(f, "null"),
532            Value::List(items) => {
533                write!(f, "[")?;
534                for (i, item) in items.iter().enumerate() {
535                    if i > 0 { write!(f, ", ")?; }
536                    write!(f, "{}", item)?;
537                }
538                write!(f, "]")
539            }
540            Value::Object(map) => {
541                // Iterate by sorted key — `HashMap` iteration order is
542                // not stable across runs or insertion orders, so a bare
543                // `map.iter()` produces nondeterministic Display output
544                // (issue #1081). Mirrors the Hash impl above, which
545                // already sorts keys for the same reason.
546                write!(f, "{{")?;
547                let mut keys: Vec<&String> = map.keys().collect();
548                keys.sort();
549                for (i, k) in keys.iter().enumerate() {
550                    if i > 0 { write!(f, ", ")?; }
551                    write!(f, "{}: {}", k, map[*k])?;
552                }
553                write!(f, "}}")
554            }
555            Value::Unable(rec) => {
556                write!(
557                    f,
558                    "Unable({}: {})",
559                    rec.category.as_wire_str(),
560                    rec.reason
561                )
562            }
563            Value::Union { variant, payload } => write!(f, "{}({})", variant, payload),
564            Value::FatalError { message, .. } => write!(f, "{}", message),
565            Value::AgentRef(data) => write!(f, "<agent:{}>", data.name),
566            Value::Json(j) => write!(f, "{}", j),
567        }
568    }
569}