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}