harn-stdlib 0.10.28

Embedded Harn standard library source catalog
Documentation
import "std/agent/loop_call_resolution"
import "std/agent/loop_support"

/**
 * Route agent-loop budget timing through the harness clock for deterministic replay and tests.
 *
 * @effects: [agent]
 * @errors: []
 */
pub fn __agent_loop_clock_now_ms() {
  return harness.clock.now_ms()
}

pub fn __agent_loop_clock_sleep_ms(delay_ms) {
  harness.clock.sleep_ms(delay_ms)
}

pub const __AGENT_LOOP_CHECKPOINT_ITERATION_START = "iteration_start"

pub const __AGENT_LOOP_CHECKPOINT_POST_TOOL_DISPATCH = "post_tool_dispatch"

pub const __AGENT_LOOP_HOST_INJECTION_TURN_BOUNDARY = "turn_boundary"

pub const __AGENT_LOOP_HOST_INJECTION_AFTER_NEXT_TOOL_CALL = "after_next_tool_call"

// -------------------------------------------------------------------------------------------------

// Length-truncation auto-continue.
//
// When a value model hits the output-token cap mid-emit, the provider returns a
// length-truncation stop_reason (`length` for OpenAI/OpenRouter/Ollama,
// `max_tokens` for Anthropic) and the partial output often holds a TRUNCATED,
// unparseable tool call. Treating that as a malformed/missing call burns the
// turn on parse-guidance even though the model was mid-correct-action — a
// silent-corruption class that hurts even capable models.
//
// Instead we detect the specific condition deterministically (no model
// cooperation, no abuse surface) and re-issue the completion with a RAISED cap
// so the model can finish the call. The retry is invisible to the rest of the
// loop body: it does not consume an iteration or run stall/parse accounting.
// Bounded to a small number of continuations; if still truncated after the cap
// we return the last result and the existing parse-guidance path takes over.
//
// The tool-call gate (`__host_agent_truncated_tool_call`) fires ONLY on a real
// length truncation with zero usable calls AND a partial-call signal. A second
// gate covers hidden-reasoning-only truncation: strict providers can return no
// visible text/tool calls, `stop_reason: length`, and non-empty reasoning. That
// is still a budget exhaustion, so retry with a larger output cap instead of
// handing an empty turn to the normal loop body.

// -------------------------------------------------------------------------------------------------

pub fn __autocontinue_max_continuations(llm_opts) {
  const configured = llm_opts?.truncation_auto_continue_max
  if type_of(configured) == "int" && configured >= 0 {
    return configured
  }
  return 2
}

/**
 * Compute the raised output-token cap for an auto-continue retry. An unset cap
 * (`<= 0`) means the provider's own default truncated us, so jump to an
 * explicit base. A set cap doubles. Both are clamped to a ceiling so a
 * misconfigured model can't request an unbounded body.
 *
 * @effects: []
 * @errors: []
 */
pub fn __autocontinue_raised_cap(current, llm_opts) {
  const base = if type_of(llm_opts?.truncation_auto_continue_base) == "int"
    && llm_opts
    .truncation_auto_continue_base
    > 0 {
    llm_opts.truncation_auto_continue_base
  } else {
    16384
  }
  const ceiling = if type_of(llm_opts?.truncation_auto_continue_ceiling) == "int"
    && llm_opts
    .truncation_auto_continue_ceiling
    > 0 {
    llm_opts.truncation_auto_continue_ceiling
  } else {
    32768
  }
  const raised = if type_of(current) == "int" && current > 0 {
    current * 2
  } else {
    base
  }
  if raised > ceiling {
    return ceiling
  }
  return raised
}

pub fn __autocontinue_is_length_stop(stop_reason) {
  const normalized = lowercase(trim(to_string(stop_reason ?? "")))
  return normalized == "length" || normalized == "max_tokens"
}

pub fn __autocontinue_hidden_reasoning_truncated(llm_result, tool_call_count) {
  if !__autocontinue_is_length_stop(llm_result?.stop_reason) {
    return false
  }
  if tool_call_count > 0 {
    return false
  }
  if trim(to_string(llm_result?.text ?? "")) != "" {
    return false
  }
  const thinking = trim(to_string(llm_result?.thinking ?? ""))
  const summary = trim(to_string(llm_result?.thinking_summary ?? ""))
  if thinking != "" || summary != "" {
    return true
  }
  return (to_int(llm_result?.output_tokens ?? 0) ?? 0) > 0
}

/**
 * Detect whether `call` is a length-truncated turn that resolved no usable
 * tool call but looks like it was mid-call — the one condition where
 * re-issuing with a raised cap is the right move.
 *
 * @effects: [host]
 * @errors: []
 */
pub fn __autocontinue_should_continue(call, turn_opts) {
  if !(call?.ok ?? false) {
    return false
  }
  const llm_result = call?.value
  if type_of(llm_result) != "dict" {
    return false
  }
  const raw_text = llm_result?.raw_text ?? llm_result?.text ?? ""
  const parsed = agent_parse_tool_calls(raw_text, turn_opts?.tools, turn_opts?.tool_format)
  let tool_calls = __resolve_tool_calls(llm_result, parsed)
  const has_parse_errors = len(parsed?.tool_parse_errors ?? []) > 0
  if __host_agent_truncated_tool_call(
    llm_result?.stop_reason,
    raw_text,
    len(tool_calls),
    has_parse_errors,
  ) {
    return true
  }
  return __autocontinue_hidden_reasoning_truncated(llm_result, len(tool_calls))
}

/**
 * Call the LLM, and on a length-truncated turn with an incomplete tool call,
 * AUTO-CONTINUE with a bounded raised output cap, then return the same
 * `{ok, value, ...}` shape callers already parse and account for. Falls back
 * to the truncated result at the cap so existing parse guidance still fires.
 *
 * @effects: []
 * @errors: []
 */
pub fn __invoke_llm_with_autocontinue(
  message,
  turn_system,
  llm_opts,
  turn_opts,
  session_id,
  iteration_index,
) {
  const retry_llm_opts = llm_clear_one_shot_prefill(llm_opts)
  agent_session_flush(session_id)
  let call = agent_invoke_llm(message, turn_system, llm_opts)
  const max_continuations = __autocontinue_max_continuations(llm_opts)
  let attempts = 0
  let cap = llm_opts?.max_tokens ?? 0
  while attempts < max_continuations && __autocontinue_should_continue(call, turn_opts) {
    const raised = __autocontinue_raised_cap(cap, llm_opts)
    // No headroom left to raise — re-issuing would just truncate again at the
    // same cap, so stop and let parse-guidance take the turn.
    if type_of(cap) == "int" && cap > 0 && raised <= cap {
      break
    }
    attempts = attempts + 1
    agent_emit_event(
      session_id,
      "llm_auto_continue",
      {
        iteration: iteration_index + 1,
        attempt: attempts,
        max_continuations: max_continuations,
        previous_max_tokens: cap,
        raised_max_tokens: raised,
        stop_reason: call?.value?.stop_reason ?? "",
      },
    )
    cap = raised
    const retry_opts = retry_llm_opts
      + {
      max_tokens: raised,
      _truncation_auto_continue_attempt: attempts,
    }
    call = agent_invoke_llm(message, turn_system, retry_opts)
  }
  return call
}

// -------------------------------------------------------------------------------------------------

/**
 * Context-overflow recovery.
 *
 * A provider can reject a turn with a `context_overflow` error when the
 * assembled prompt exceeds the model's real context window — typically on a
 * large repo where tool observations accreted past the budget, OR when the
 * model's window is mis/under-cataloged so auto-compaction never fired. The
 * agent must NOT die on this: it is a recoverable, self-inflicted condition.
 *
 * Recovery is a bounded loop: emergency-compact the live transcript
 * (deterministic observation masking — never an LLM call, which would itself
 * overflow), then re-issue the SAME turn. Each attempt compacts more
 * aggressively (shorter preserved tail). We stop when either the retry
 * succeeds, or emergency compaction can no longer shrink the transcript
 * (`archived == 0`, i.e. an irreducible system-prompt + single oversized
 * message), at which point the overflow is genuinely terminal.
 *
 * @effects: [agent]
 * @errors: []
 */
pub fn __agent_loop_is_context_overflow(error) {
  if type_of(error) != "dict" {
    return false
  }
  const reason = to_string(error?.reason ?? "")
  if reason == "context_overflow" {
    return true
  }
  // Defensive fallback: some routes surface the condition only in the message
  // text (uncataloged provider, no structured `reason`). Match the canonical
  // classifier tag the Rust LLM layer stamps onto the error string.
  const message = to_string(error?.message ?? "")
  return contains(message, "[context_overflow]")
}

pub fn __agent_loop_context_overflow_max_recoveries(llm_opts) {
  const configured = llm_opts?.context_overflow_recover_max
  if type_of(configured) == "int" && configured >= 0 {
    return configured
  }
  return 3
}

/**
 * On a `context_overflow` provider error, emergency-compact + retry (bounded).
 * Returns a fresh `{ok, ...}` call result: `ok:true` when a retry succeeded,
 * or the last `ok:false` result (so the caller's terminal path runs unchanged)
 * when recovery is exhausted or the transcript is irreducible.
 *
 * @effects: [agent]
 * @errors: []
 */
pub fn __agent_loop_recover_context_overflow(
  call,
  message,
  turn_system,
  llm_opts,
  turn_opts,
  session,
  iteration_index,
) {
  const max_recoveries = __agent_loop_context_overflow_max_recoveries(llm_opts)
  let current = call
  let attempt = 0
  while attempt < max_recoveries && !current.ok
    && __agent_loop_is_context_overflow(current?.error) {
    attempt = attempt + 1
    const archived = agent_emergency_compact(session, llm_opts, attempt)
    agent_emit_event(
      session.session_id,
      "context_overflow_recovery",
      {
        iteration: iteration_index + 1,
        attempt: attempt,
        max_recoveries: max_recoveries,
        archived_messages: archived,
        provider_error: current?.error ?? {},
      },
    )
    // Nothing left to shed: the prompt is irreducible (system prompt + a single
    // oversized message). Re-issuing would overflow identically, so surface the
    // original terminal error rather than spin.
    if archived <= 0 {
      break
    }
    // Rebuild the turn prompt against the now-compacted transcript and re-issue.
    const turn_prompt = __agent_loop_build_turn_prompt(session, llm_opts, iteration_index)
    const retry_opts = llm_clear_one_shot_prefill(llm_opts)
      + {
      messages: turn_prompt.messages,
      _system_fragments: turn_prompt.fragments,
      _context_overflow_recovery_attempt: attempt,
    }
    current = __invoke_llm_with_autocontinue(
      message,
      turn_prompt.system,
      retry_opts,
      turn_opts,
      session.session_id,
      iteration_index,
    )
  }
  return current
}

// -------------------------------------------------------------------------------------------------