import { AgentLoopCancelledFailure } from "std/agent/failure_types"
pub import { MissingToolCallClassifier, MissingToolCallVerdict } from "std/agent/options_types"
import { MissingToolCallRecoveryOptions } from "std/agent/options_types"
import { EvaluationOutcome, EvaluationPolicy, boolean, choice } from "std/predicate"
type MissingToolCallAction = "tool_call_intended" | "no_tool_call_intended" | "ambiguous"
type MissingToolCallTool = {name: string, description?: string}
type MissingToolCallRoute = {provider: string, model: string}
type MissingToolCallCapabilities = {
decision_protocol: string?,
reasoning_effort_levels: list<string>?,
}
fn __missing_tool_call_policy(
llm: HarnessLlm,
cfg: MissingToolCallRecoveryOptions,
) -> EvaluationPolicy {
if cfg.evaluation != nil {
return cfg.evaluation + {threshold: 0.0}
}
const route: MissingToolCallRoute = schema_expect(
llm.resolved_options({provider: cfg.provider ?? "", model: cfg.model ?? ""}),
schema_of(MissingToolCallRoute),
)
const capabilities: MissingToolCallCapabilities = schema_expect(
llm.provider_capabilities(route.provider, route.model),
schema_of(MissingToolCallCapabilities),
)
const native = capabilities.decision_protocol != nil
&& capabilities.decision_protocol != "structured_llm"
let policy: EvaluationPolicy = {
backend: if native {
"native_decision"
} else {
"structured_llm"
},
provider: route.provider,
model: route.model,
threshold: 0.0,
}
if !native {
const levels = capabilities.reasoning_effort_levels ?? []
// The catalog lists supported effort levels from lowest to highest.
// Keep this bounded classifier at the lowest declared effort instead of
// inventing an off switch that the route may reject.
policy = policy + {max_tokens: 192}
if len(levels) > 0 {
policy = policy + {effort: levels[0]}
}
}
if cfg.effort != nil {
policy = policy + {effort: cfg.effort}
}
if cfg.temperature != nil {
policy = policy + {temperature: cfg.temperature}
}
if cfg.max_tokens != nil {
policy = policy + {max_tokens: cfg.max_tokens}
}
if cfg.top_p != nil {
policy = policy + {top_p: cfg.top_p}
}
if cfg.seed != nil {
policy = policy + {seed: cfg.seed}
}
if cfg.timeout_ms != nil {
policy = policy + {timeout_ms: cfg.timeout_ms}
}
return policy
}
fn __missing_tool_call_string(value: any) -> string {
if value == nil {
return ""
}
return trim(to_string(value))
}
fn __missing_tool_call_float(value: float?, fallback: float) {
const parsed = to_float(value ?? fallback)
if parsed == nil {
return fallback
}
if !(parsed >= 0.0 && parsed <= 1.0) {
return fallback
}
return parsed
}
fn __missing_tool_call_config_error(code: string, message: string, fields: dict? = {}) {
return {
error: code,
category: "invalid_config",
component: "missing_tool_call_classifier",
message: message,
}
+ (fields ?? {})
}
fn __missing_tool_call_threshold(value: float?) -> float {
const threshold = to_float(value ?? 0.65)
if threshold == nil || !(threshold >= 0.0 && threshold <= 1.0) {
throw __missing_tool_call_config_error(
"missing_tool_call.invalid_confidence_threshold",
"confidence_threshold must be between 0.0 and 1.0",
{field: "confidence_threshold", expected: "0.0..1.0", actual: value, min: 0.0, max: 1.0},
)
}
return threshold
}
fn __missing_tool_call_candidate_tool_entries(payload: dict) {
const registry = payload?.tools
if type_of(registry) == "dict" {
return registry?.tools ?? []
}
if type_of(registry) == "list" {
return registry
}
return payload?.candidate_tools ?? []
}
fn __missing_tool_call_candidate_tools(payload: dict) -> list<MissingToolCallTool> {
let out = []
let names = []
for entry in __missing_tool_call_candidate_tool_entries(payload) {
const name = __missing_tool_call_string(entry?.name ?? entry?.function?.name)
if name == "" || contains(names, name) {
continue
}
names = names.appending(name)
out = out.appending(
{
name: name,
description: __missing_tool_call_string(entry?.description ?? entry?.function?.description),
},
)
}
for name in payload?.tool_names ?? [] {
const text = __missing_tool_call_string(name)
if text != "" && !contains(names, text) {
names = names.appending(text)
out = out.appending({name: text, description: ""})
}
}
return out
}
fn __missing_tool_call_tool_names(candidate_tools: list<MissingToolCallTool>) -> list<string> {
let names = []
for entry in candidate_tools {
const name = __missing_tool_call_string(entry.name)
if name != "" && !contains(names, name) {
names = names.appending(name)
}
}
return names
}
fn __missing_tool_call_tool_name(value: any, candidate_tools: list<MissingToolCallTool>) -> string {
const requested = __missing_tool_call_string(value)
if requested == "" {
return ""
}
for name in __missing_tool_call_tool_names(candidate_tools) {
if requested == name || lowercase(requested) == lowercase(name) {
return name
}
}
return ""
}
fn __missing_tool_call_action(value: any) -> MissingToolCallAction {
const action = __missing_tool_call_string(value)
if action == "tool_call_intended" || action == "no_tool_call_intended" || action == "ambiguous" {
return action
}
return "ambiguous"
}
fn __missing_tool_call_normalize(
raw: any,
candidate_tools: list<MissingToolCallTool>,
threshold: float,
) -> MissingToolCallVerdict {
if type_of(raw) != "dict" {
return {
action: "ambiguous",
original_action: "invalid",
tool_name: "",
confidence: 0.0,
confidence_threshold: threshold,
evidence: "classifier returned " + type_of(raw) + ", not a dict",
}
}
const original_action = __missing_tool_call_action(raw?.action)
const confidence = __missing_tool_call_float(raw?.confidence, 0.0)
const tool_name = __missing_tool_call_tool_name(raw?.tool_name ?? raw?.tool, candidate_tools)
const action = if original_action == "tool_call_intended" && confidence >= threshold
&& tool_name
!= "" {
"tool_call_intended"
} else if original_action == "no_tool_call_intended" && confidence >= threshold {
"no_tool_call_intended"
} else {
"ambiguous"
}
const evidence = __missing_tool_call_string(raw?.evidence ?? raw?.reason ?? raw?.reasoning)
return raw
+ {
action: action,
original_action: original_action,
tool_name: tool_name,
confidence: confidence,
confidence_threshold: threshold,
evidence: if evidence == "" {
"no evidence provided"
} else {
evidence
},
}
}
fn __missing_tool_call_fail_open(error: any, threshold: float) -> MissingToolCallVerdict {
return {
action: "ambiguous",
original_action: "error",
tool_name: "",
confidence: 0.0,
confidence_threshold: threshold,
evidence: "missing tool call classifier failed: " + to_string(error),
error: to_string(error),
}
}
/**
* missing_tool_call_classifier returns the post-turn classifier used by
* agent_loop text-mode recovery. It decides whether a turn with no parsed tool
* calls was probably meant to call one, without language-specific substring
* lists.
*
* The agent loop constructs this classifier by default for real provider-backed
* text-mode loops and passes through the active loop provider/model unless this
* config overrides them. Pass `missing_tool_call_recovery: false` to disable it
* or `missing_tool_call_recovery: { ... }` to configure classifier-specific
* settings. Mock/custom-caller tests can pass `classifier` inside that config
* for deterministic behavior. The live path evaluates intent and target in
* one request, using `evaluation` when configured and the active structured
* model otherwise. Refusal fails open to `ambiguous`; cancellation propagates.
*
* @effects: [llm.call]
* @errors: ["invalid classifier configuration", "cancelled"]
* @api_stability: experimental
*/
pub fn missing_tool_call_classifier(
harness: Harness,
opts: MissingToolCallRecoveryOptions? = nil,
) -> MissingToolCallClassifier {
const cfg = opts ?? {}
if type_of(cfg) != "dict" {
throw __missing_tool_call_config_error(
"missing_tool_call.invalid_options",
"opts must be a dict or nil; got " + type_of(cfg),
{field: "opts", expected: "dict", actual_type: type_of(cfg)},
)
}
const enabled = cfg.enabled ?? true
if type_of(enabled) != "bool" {
throw __missing_tool_call_config_error(
"missing_tool_call.invalid_enabled",
"enabled must be a bool",
{field: "enabled", expected: "bool", actual_type: type_of(enabled)},
)
}
const model = __missing_tool_call_string(cfg.model)
const threshold = __missing_tool_call_threshold(cfg.confidence_threshold)
const classifier = cfg.classifier
if classifier != nil && type_of(classifier) != "closure" {
throw __missing_tool_call_config_error(
"missing_tool_call.invalid_classifier",
"classifier must be a closure or nil; got " + type_of(classifier),
{field: "classifier", expected: "closure|nil", actual_type: type_of(classifier)},
)
}
return { payload ->
if !enabled {
return nil
}
const candidate_tools = __missing_tool_call_candidate_tools(payload)
if len(candidate_tools) == 0 {
return nil
}
const classifier_payload = payload
+ {
candidate_tools: candidate_tools,
tool_names: __missing_tool_call_tool_names(candidate_tools),
confidence_threshold: threshold,
}
if classifier != nil {
const custom = try {
classifier(classifier_payload)
}
if is_err(custom) {
const err = unwrap_err(custom)
if error_category(err) == "cancelled" {
throw err
}
return __missing_tool_call_fail_open(err, threshold) + {classifier_kind: "custom"}
}
return __missing_tool_call_normalize(unwrap(custom), candidate_tools, threshold)
+ {classifier_kind: "custom"}
}
const resolved = try {
__missing_tool_call_policy(harness.llm, cfg)
}
if is_err(resolved) {
return __missing_tool_call_fail_open(unwrap_err(resolved), threshold)
+ {classifier_kind: "llm", model: model}
}
const policy: EvaluationPolicy = unwrap(resolved)
const state: {candidate_tools: list<MissingToolCallTool>, text: string} = {
candidate_tools: candidate_tools,
text: __missing_tool_call_string(payload?.text ?? payload?.visible_text),
}
let criteria: dict<string, string> = {}
for candidate in candidate_tools {
criteria[candidate.name] = candidate.description ?? ""
}
let outcome: EvaluationOutcome? = nil
try {
outcome = harness.llm.evaluate_request(
"agent.missing_tool_call.v1",
state,
{
intended: boolean(
harness.fs.render_prompt("std/llm/prompts/missing_tool_call_intent.harn.prompt", {}),
),
tool: choice(
harness.fs.render_prompt("std/llm/prompts/missing_tool_call_target.harn.prompt", {}),
criteria,
),
},
policy,
)
} catch (err) {
if error_category(err) == "cancelled" {
throw err
}
return __missing_tool_call_fail_open(err, threshold)
+ {classifier_kind: "llm", model: policy.model}
}
if outcome == nil {
return __missing_tool_call_fail_open("evaluation returned no outcome", threshold)
+ {classifier_kind: "llm", model: policy.model}
}
if outcome.kind == "cancelled" {
const failure: AgentLoopCancelledFailure = {
category: "cancelled",
message: "missing tool call evaluation cancelled",
}
throw failure
}
const metadata = {classifier_kind: "llm", model: policy.model, decision: outcome}
if outcome.kind != "answered" {
return __missing_tool_call_fail_open(outcome, threshold) + metadata
}
const intended = outcome.value.intended
const target = outcome.value.tool
if intended.kind != "boolean" || target.kind != "choice" {
return __missing_tool_call_fail_open("invalid answer kinds", threshold) + metadata
}
return __missing_tool_call_normalize(
{
action: if intended.verdict {
"tool_call_intended"
} else {
"no_tool_call_intended"
},
tool_name: if intended.verdict {
target.choice
} else {
""
},
confidence: if intended.verdict {
min(intended.confidence, target.confidence)
} else {
intended.confidence
},
evidence: intended.evidence,
},
candidate_tools,
threshold,
)
+ metadata
}
}