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}