// @harn-entrypoint-category llm.stdlib
//
// std/llm/safe — DRY consolidations for envelope-shaped llm_call results,
// case-insensitive dict access, and judge-payload reconstruction.
import { agent_session_messages } from "std/agent/state"
/**
* Try-wrap llm_call into the canonical envelope shape:
* {ok: true, value: <llm dict>} on success
* {ok: false, status: <"budget_exhausted" | "exception">, error?} on error.
*/
pub fn safe_call(prompt, system, options) {
let result = try {
llm_call(prompt, system, options)
}
if !is_err(result) {
return {ok: true, value: unwrap(result)}
}
let err = unwrap_err(result)
let reason = if type_of(err) == "dict" {
err?.reason ?? ""
} else {
""
}
if reason == "budget_exceeded" {
return {ok: false, status: "budget_exhausted", error: err}
}
return {ok: false, status: "exception", error: err}
}
/** Direct case-insensitive single-key lookup on a dict. Returns nil on miss. */
pub fn dict_get_ci(d, key) {
if type_of(d) != "dict" {
return nil
}
let target = lowercase(to_string(key))
for k in d.keys() {
if lowercase(to_string(k)) == target {
return d[k]
}
}
return nil
}
fn __value_is_present(value) {
if value == nil {
return false
}
let kind = type_of(value)
if kind == "string" {
return value != ""
}
if kind == "list" {
return len(value) > 0
}
if kind == "dict" {
return len(value.keys()) > 0
}
return true
}
/**
* Case-insensitive top-level dict lookup. Tries each name in order; returns
* the first non-nil non-empty value, else default. Top-level keys only.
*/
pub fn safe_field(envelope, names, default) {
if type_of(envelope) != "dict" {
return default
}
if type_of(names) != "list" {
return default
}
for name in names {
let value = dict_get_ci(envelope, name)
if __value_is_present(value) {
return value
}
}
return default
}
/**
* Recursively normalize all dict keys to lowercase. Lists pass through
* unchanged, but dicts within lists are recursed into. Idempotent.
*/
pub fn with_case_insensitive_keys(envelope) {
if type_of(envelope) == "dict" {
var out = {}
for k in envelope.keys() {
let new_key = lowercase(to_string(k))
out = out + {[new_key]: with_case_insensitive_keys(envelope[k])}
}
return out
}
if type_of(envelope) == "list" {
var out = []
for item in envelope {
out = out.push(with_case_insensitive_keys(item))
}
return out
}
return envelope
}
/**
* Merges defaults UNDER the envelope's data field. The envelope wins per-key.
* If envelope.ok is false or envelope is nil, returns {ok: false, ...defaults}.
*/
pub fn structured_envelope_or_default(envelope, defaults) {
let base = if type_of(defaults) == "dict" {
defaults
} else {
{}
}
if envelope == nil {
return {ok: false} + base
}
if type_of(envelope) != "dict" {
return {ok: false} + base
}
if !(envelope?.ok ?? false) {
return {ok: false} + base + envelope
}
let data = if type_of(envelope?.data) == "dict" {
envelope.data
} else {
{}
}
return envelope + {data: base + data}
}
fn __unique_names(names) {
var unique = []
for name in names {
if name != "" && !contains(unique, name) {
unique = unique.push(name)
}
}
return unique
}
fn __session_tool_names(messages) {
var names = []
for message in messages {
if message?.role == "tool" {
names = names.push(message?.name ?? "")
}
}
return __unique_names(names)
}
/**
* Build the canonical judge payload. Mirrors agent/judge.__judge_payload but
* callable from non-agent_loop contexts (e.g. parallel_judge in ensemble).
* Returns {session_id, task, stop_reason, text, visible_text, last_text,
* transcript, all_tools_used, successful_tools_used, iteration}.
*/
pub fn judge_payload(session, _opts, stop_reason, text, iteration) {
let session_id = session?.session_id ?? ""
let messages = if session_id != "" {
let m = try {
agent_session_messages(session_id)
}
if is_err(m) {
[]
} else {
unwrap(m)
}
} else {
[]
}
let tool_names = __session_tool_names(messages)
return {
session_id: session_id,
task: session?.task ?? "",
stop_reason: stop_reason,
text: text,
visible_text: text,
last_text: text,
transcript: json_stringify(messages),
all_tools_used: join(tool_names, ", "),
successful_tools_used: join(tool_names, ", "),
iteration: iteration,
}
}
/**
* Normalize a judge verdict string. Lowercases, trims, optionally maps via
* alias_groups (a list of {canonical, aliases}). Returns the canonical form
* or the lowered/trimmed original.
*/
pub fn verdict_normalize(text, alias_groups) {
let normalized = lowercase(trim(to_string(text ?? "")))
if alias_groups == nil || type_of(alias_groups) != "list" || len(alias_groups) == 0 {
return normalized
}
for group in alias_groups {
let canonical = group?.canonical ?? ""
let aliases = group?.aliases ?? []
if canonical != "" && type_of(aliases) == "list" && contains(aliases, normalized) {
return canonical
}
}
return normalized
}
/**
* Build a deterministic schema-retry nudge string from a JSON Schema.
* Lists required fields (sorted), enforces lowercase keys, no markdown,
* no fences. Optional hint string is appended.
*/
pub fn schema_retry_nudge_for(schema, hint) {
let required = if type_of(schema) == "dict" && type_of(schema?.required) == "list" {
schema.required
} else {
[]
}
let sorted_required = required.sort()
let required_line = if len(sorted_required) > 0 {
"Required keys (lowercase): " + join(sorted_required, ", ") + "."
} else {
"No required keys are declared."
}
var lines = [
"Your previous response did not pass schema validation.",
"Re-emit valid JSON only:",
"- Use lowercase keys exactly as specified.",
"- Do not wrap in markdown fences.",
"- Do not include prose, commentary, or trailing text.",
required_line,
]
if hint != nil && to_string(hint) != "" {
lines = lines.push("Hint: " + to_string(hint))
}
return join(lines, "\n")
}
/**
* One-shot structured-output helper that bundles schema retries, an
* automatic repair pass, judge-friendly defaults, and case-insensitive
* key normalization on the result.
*
* Replaces the recurring 80-120 LOC structured-output dance in judges
* and analyzers. Returns the canonical envelope from
* `llm_call_structured_result` augmented with `value` (alias for
* lowercase-key-normalized `data`) and `ok` already populated. Callers
* dispatch on `result.ok` and read structured fields off `result.value`.
*
* Conceptually equivalent to the structured-output preset
*
* compose([with_coerce({})])(__structured_caller(schema))
*
* with `__apply_judge_defaults` baking in judge-friendly options before
* the call. Schema retries + the repair pass are owned by
* `llm_call_structured_result` (the structured base caller), so this
* function is the canonical preset; it stays in `std/llm/safe` rather
* than `std/llm/handlers` because callers consume it as a one-shot,
* not as a caller-seam middleware.
*
* Defaults applied when the corresponding option is unset:
* temperature -> 0.0
* schema_retries -> 2
* repair.enabled -> true
* repair.max_tokens-> 600
* repair.temperature -> 0.0
* schema_retry_nudge -> derived via `schema_retry_nudge_for`
*/
pub fn safe_structured_call(prompt, schema, options) {
let user_options = if type_of(options) == "dict" {
options
} else {
{}
}
let resolved = __apply_judge_defaults(schema, user_options)
let envelope = llm_call_structured_result(prompt, schema, resolved)
return __augment_envelope(envelope)
}
fn __apply_judge_defaults(schema, options) {
var resolved = options
if resolved?.temperature == nil {
resolved = resolved + {temperature: 0.0}
}
if resolved?.schema_retries == nil {
resolved = resolved + {schema_retries: 2}
}
if resolved?.schema_retry_nudge == nil {
resolved = resolved + {schema_retry_nudge: schema_retry_nudge_for(schema, nil)}
}
let user_repair = if type_of(resolved?.repair) == "dict" {
resolved.repair
} else {
{}
}
var repair = user_repair
if repair?.enabled == nil {
repair = repair + {enabled: true}
}
if repair?.max_tokens == nil {
repair = repair + {max_tokens: 600}
}
if repair?.temperature == nil {
repair = repair + {temperature: 0.0}
}
return resolved + {repair: repair}
}
fn __augment_envelope(envelope) {
if type_of(envelope) != "dict" {
return {ok: false, status: "exception", value: {}, error: envelope}
}
let ok_flag = envelope?.ok ?? false
let data = if type_of(envelope?.data) == "dict" {
envelope.data
} else {
{}
}
let normalized = with_case_insensitive_keys(data)
let status = if ok_flag {
"ok"
} else {
let category = envelope?.error_category
if category != nil {
to_string(category)
} else {
"error"
}
}
return envelope + {value: normalized, status: status}
}