import { AgentPresetOptions } from "std/agent/options_types"
/**
* The option surface, derived from the type that declares it.
*
* `agent_loop` options used to be read by lookup alone: each validator
* reached for the fields it knew about and nothing looked at what else was
* passed. A misspelled key and a real key at a depth where nothing reads it
* were both accepted in silence, and the run then reported exactly what a
* correctly configured run without that feature would report. The two are
* indistinguishable from the outside, which is how a probe measures the
* absence of the thing it was written to test.
*
* The allowed keys come from `schema_of(AgentPresetOptions)` rather than a list
* maintained beside the validators. A hand-written list is a second copy of
* the declaration and drifts from it exactly the way the per-validator field
* reads already have.
*/
/**
* Flatten a schema node into the branches that could describe a dict.
*
* An option's declared type is often a union (`bool | JudgeConfig`) or an
* intersection (`AgentSpec` itself). A key is legitimate if any branch
* declares it, so the branches are unioned rather than intersected: refusing
* a key some branch accepts would be a false rejection, and this check exists
* to be trusted.
*
* @effects: []
* @errors: []
*/
fn __option_schema_branches(node: any) -> list<any> {
if type_of(node) != "dict" {
return []
}
let branches = [node]
for group in ["all_of", "any_of", "one_of", "union"] {
const value = node?.[group]
if type_of(value) == "list" {
for item in value {
branches = branches + __option_schema_branches(item)
}
} else if type_of(value) == "dict" {
branches = branches + __option_schema_branches(value)
}
}
return branches
}
/**
* Every key any branch of this node declares as a property.
*
* An empty result means the node declares no properties at all, which is a
* free-form `dict` option rather than a typed shape. Nothing is known about
* its keys, so nothing about them can be refused.
*
* @effects: []
* @errors: []
*/
fn __option_declared_keys(node: any) -> list<string> {
// Concatenate whole key lists rather than appending one name at a time. The
// element-wise version was quadratic in the number of declared keys and cost
// 82ms per `agent_loop_options` call, on a path every sub-agent, worker and
// workflow stage takes.
let names: list<string> = []
for branch in __option_schema_branches(node) {
const properties = branch?.properties
if type_of(properties) == "dict" {
names = names + keys(properties)
}
}
if len(names) == 0 {
return names
}
return unique(names)
}
/**
* The schema for one child key, unioned across the branches that declare it.
*
* @effects: []
* @errors: []
*/
fn __option_child_schema(node: any, key: string) -> any {
let found = []
for branch in __option_schema_branches(node) {
const properties = branch?.properties
if type_of(properties) == "dict" && contains(properties.keys(), key) {
found = found + [properties[key]]
}
}
if len(found) == 0 {
return nil
}
if len(found) == 1 {
return found[0]
}
return {union: found}
}
/**
* Every declared option path, as `[key, dotted path]` pairs.
*
* @effects: []
* @errors: []
*/
fn __option_paths(node: any, path: string) -> list<list<string>> {
let pairs = []
for key in __option_declared_keys(node) {
const child_path = if path == "" {
key
} else {
path + "." + key
}
pairs = pairs + [[key, child_path]]
pairs = pairs + __option_paths(__option_child_schema(node, key), child_path)
}
return pairs
}
/**
* Where else this key is accepted, as a readable clause, or "" if nowhere.
*
* @effects: []
* @errors: []
*/
fn __option_key_accepted_elsewhere(key: string, at_path: string) -> string {
// Only reached on a rejection, which ends the run, so this walk of the whole
// declared surface is off the hot path.
let elsewhere = []
for pair in __option_paths(schema_of(AgentPresetOptions), "") {
const name = to_string(pair[0])
const full = to_string(pair[1])
if name == key && full != at_path {
elsewhere = elsewhere + ["`" + full + "`"]
}
}
elsewhere = unique(elsewhere)
if len(elsewhere) == 0 {
return ""
}
return "; it is read at " + join(elsewhere, " or ")
}
/**
* Collect every option key nothing on the surface declares.
*
* Walks one option dict against its schema node and returns the dotted path of
* every key no branch declares. A node that declares no properties is a
* free-form `dict` option: nothing is known about its keys, so the walk stops
* rather than refusing them.
*
* @effects: []
* @errors: []
*/
fn __collect_unread_keys(value: any, node: any, path: string) -> list<string> {
if type_of(value) != "dict" {
return []
}
const allowed = __option_declared_keys(node)
if len(allowed) == 0 {
return []
}
let unread = []
for key in keys(value) {
// Runtime-internal plumbing. The stdlib writes these into an option dict
// itself — `std/agent/lanes` adds `_lane_verdict`, `std/workflow/stage`
// adds `_nested_kind` — and they arrive here as ordinary caller keys.
// Declaring them on the public type would advertise private plumbing as
// API. The cost is that a typo inside the underscore namespace is still
// accepted, which is a smaller surface than the one this check closes.
if starts_with(key, "_") {
continue
}
const here = if path == "" {
key
} else {
path + "." + key
}
if !contains(allowed, key) {
unread = unread + [here]
} else {
unread = unread + __collect_unread_keys(value[key], __option_child_schema(node, key), here)
}
}
return unread
}
/**
* Refuse every option key nothing on the surface declares, in one message.
*
* A rejection rather than a report, unlike the host event payload census:
* there the emitters are inside the runtime and a refusal would kill a live
* path, while a misplaced option is a caller's own configuration and the
* caller is the one who can fix it. The whole failure here is a signal that
* was present and read as ordinary, so it has to stop the run.
*
* All of them at once, not the first: a caller with three misplaced keys
* should learn three, and finding them one rebuild at a time is how this
* check's own bring-up went.
*
* @effects: []
* @errors: [ValueError]
*/
pub fn __validate_agent_loop_option_keys(opts: any) -> nil {
if type_of(opts) != "dict" {
return
}
const unread = __collect_unread_keys(opts, schema_of(AgentPresetOptions), "")
if len(unread) == 0 {
return
}
__reject_removed_option_keys(unread)
let parts = []
for full in unread {
const key = __option_path_leaf(to_string(full))
const at = if to_string(full) == key {
"the top level"
} else {
"`" + __option_path_parent(to_string(full)) + "`"
}
parts = parts
+ [
"`"
+ key
+ "` at "
+ at
+ __option_key_accepted_elsewhere(key, to_string(full)),
]
}
throw "agent_loop: no option reads "
+ join(parts, ", ")
+ ". A key nothing reads is accepted in silence otherwise, and the run then reports what a run without that option would report."
}
/**
* The last segment of a dotted option path.
*
* @effects: []
* @errors: []
*/
fn __option_path_leaf(full: string) -> string {
const parts = split(full, ".")
return to_string(parts[len(parts) - 1])
}
/**
* Everything before the last segment of a dotted option path.
*
* @effects: []
* @errors: []
*/
fn __option_path_parent(full: string) -> string {
const parts = split(full, ".")
let head = []
for (index, part) in iter(parts).enumerate() {
if index < len(parts) - 1 {
head = head + [to_string(part)]
}
}
return join(head, ".")
}
/**
* Refuse a key the model-call registry already records as removed, using the
* registry's own wording.
*
* The removed vocabulary has one owner, the registry the dispatch layer reads.
* That layer only sees a key once a run is under way, which is well past the
* point a caller can act on it, and the surface check would otherwise reach
* the key first and report it as unread. A removed key is not unread: it was
* read until it was taken away, and the registry knows what replaced it.
*
* @effects: []
* @errors: [ValueError]
*/
fn __reject_removed_option_keys(unread: list<string>) {
const removed = __llm_call_option_registry().removed
for full in unread {
const key = __option_path_leaf(to_string(full))
if removed[key] != nil {
throw "agent_loop: option `" + key + "` was removed — " + removed[key]
}
}
}