harn-stdlib 0.10.28

Embedded Harn standard library source catalog
Documentation
import {
  agent_monologue_actuation_config,
  agent_monologue_actuation_initial_state,
  agent_monologue_actuation_observe,
} from "std/agent/monologue_actuation"
import "std/agent/stall_observation"
import {
  AgentStallConfig,
  AgentStallObservation,
  AgentStallState,
  AgentStallWarning,
} from "std/agent/stall_types"
import {
  agent_emit_event,
  agent_session_inject_feedback,
  agent_session_totals,
} from "std/agent/state"
import { verification_gate_input } from "std/verification"

// Unified detector subsystem (#rearch R2). The detector conditions above
// (repeated-observation / ping-pong loops, no-progress monologue, same-error /
// stuck-diagnostic, context-window, no-net-progress) are the LOOP, NO-PROGRESS
// and STUCK-TOOL detectors of burin-code's hand-rolled runtime, already native
// here and riding the `agent_loop_stall_warning` event. The FOURTH burin
// detector — token-runaway (output growing past a task's typical size without
// landing progress, burin `lib/runtime/token-runaway-guard.harn`) — is not
// budget-native, so it is added below as a `post_turn_callback` overlay that
// emits the SAME `agent_loop_stall_warning` event and speaks the SAME
// proceed/warn/abort vocabulary. `DetectorSpec` is the ONE typed surface that
// lowers a unified spec onto native `stall_diagnostics` config PLUS the overlay.
//

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

// burin token-runaway-guard.harn parity constants.
pub const TOKEN_RUNAWAY_DEFAULT_SIGMA = 2.0

pub const TOKEN_RUNAWAY_THIN_HISTORY_MARGIN = 0.6

pub const TOKEN_RUNAWAY_HARD_OVERSHOOT_MULTIPLE = 3.0

/**
 * A unified detector spec. Each sub-key is a data row that either lowers onto
 * the native `stall_diagnostics` config (loop / no_progress / stuck) or drives
 * the token-runaway overlay. All optional; absent detectors keep their native
 * defaults.
 *   - loop:          { ping_pong_cycles?, repeat? }        -> repeated-action loop
 *   - no_progress:   { messages? }                          -> no-progress monologue
 *   - stuck:         { same_diagnostic?, same_error? }      -> stuck-tool / repair
 *   - token_runaway: TokenRunawayConfig                     -> overlay (below)
 */
pub type DetectorSpec = {
  enabled?: bool,
  hard_stop_after_trips?: int,
  loop?: {ping_pong_cycles?: int, repeat?: int},
  no_progress?: {messages?: int},
  stuck?: {same_diagnostic?: int, same_error?: int},
  token_runaway?: {
    fallback?: float,
    hard_multiple?: float,
    median?: float,
    sigma?: float,
    stddev?: float,
  },
}

/**
 * token_runaway_resolve_cap computes the per-task input-token ceiling exactly as
 * burin's `token_runaway_resolve_cap`: `median + sigma*stddev` when a stddev is
 * known, else a thin-history relative margin over the median; never below the
 * absolute `fallback` floor; `fallback` when there is no median history.
 *
 * @effects: []
 * @errors: []
 * @api_stability: experimental
 */
pub fn token_runaway_resolve_cap(
  median: float,
  stddev: float,
  sigma: float,
  fallback: float,
) -> float {
  if median <= 0.0 {
    return fallback
  }
  const relative = if stddev > 0.0 {
    median + sigma * stddev
  } else {
    median * (1.0 + TOKEN_RUNAWAY_THIN_HISTORY_MARGIN)
  }
  if relative < fallback {
    return fallback
  }
  return relative
}

/**
 * token_runaway_decision is the PURE token-runaway core, mirroring burin's
 * `token_runaway_decision`. `config` carries `{median, stddev, sigma?,
 * fallback?, hard_multiple?}`; `obs` carries `{tokens, making_progress}`.
 *   - no resolvable cap                         -> proceed "no_cap"
 *   - tokens >= cap * hard_multiple             -> abort   "token_runaway_hard"
 *   - tokens <= cap                             -> proceed "within_cap"
 *   - over cap but making progress              -> proceed "over_cap_but_progressing"
 *   - over cap, no progress evidence            -> abort   "token_runaway_no_progress"
 * `making_progress` is the veto up to the hard multiple and defaults false
 * (conservative: over-cap with no progress evidence still bites).
 *
 * @effects: []
 * @errors: []
 * @api_stability: experimental
 */
pub fn token_runaway_decision(config: dict, obs: dict) -> dict {
  const sigma = to_float(config?.sigma ?? TOKEN_RUNAWAY_DEFAULT_SIGMA)
  const fallback = to_float(config?.fallback ?? 0.0)
  const cap = token_runaway_resolve_cap(
    to_float(config?.median ?? 0.0),
    to_float(config?.stddev ?? 0.0),
    sigma,
    fallback,
  )
  if cap <= 0.0 {
    return {action: "proceed", cap: cap, reason: "no_cap"}
  }
  const tokens = to_float(obs?.tokens ?? 0)
  const hard_multiple = to_float(config?.hard_multiple ?? TOKEN_RUNAWAY_HARD_OVERSHOOT_MULTIPLE)
  if tokens >= cap * hard_multiple {
    return {action: "abort", cap: cap, reason: "token_runaway_hard"}
  }
  if tokens <= cap {
    return {action: "proceed", cap: cap, reason: "within_cap"}
  }
  if obs?.making_progress ?? false {
    return {action: "proceed", cap: cap, reason: "over_cap_but_progressing"}
  }
  return {action: "abort", cap: cap, reason: "token_runaway_no_progress"}
}

pub fn __token_runaway_is_verification_result(result) -> bool {
  // A verification/check result carries a diagnostics or errors LIST (empty =
  // clean). A plain tool success (edit/read/write/non-verify run) carries
  // neither, so it is NOT verification evidence — the gate helper would
  // otherwise default a previous-less result to "cleared" and wrongly credit an
  // edit as a passing verification. This mirrors std/verification's own
  // gate-bearing precondition.
  return type_of(result?.diagnostics) == "list"
    || type_of(result?.errors) == "list"
    || result?.feedsGates == true
    || result?.classification?.feedsGates == true
}

/**
 * token_runaway_made_progress classifies whether THIS turn advanced
 * verification — the progress signal burin's token-runaway guard vetoes on. It
 * is deliberately NOT "any tool succeeded": token-runaway exists precisely for
 * the `edit_applied_but_wrong` thrash where edit/read storms keep succeeding
 * while verification never passes, so "any successful tool" would let the soft
 * cap silently never fire. Progress = a result that is actually a verification
 * (carries a diagnostics/errors list, per
 * `__token_runaway_is_verification_result`) AND is PASSING this turn (not a
 * gate-bearing failure, via std/verification's classifier — the same signal the
 * native stall detector uses). A successful edit/read is advisory (no
 * diagnostics list) and does NOT count; a still-failing verify is a gate-bearing
 * failure and does NOT count.
 *
 * @effects: []
 * @errors: []
 * @api_stability: experimental
 */
pub fn token_runaway_made_progress(payload) -> bool {
  const results = if type_of(payload?.tool_results) == "list" {
    payload.tool_results
  } else {
    payload?.dispatch?.results ?? []
  }
  for result in results {
    if !__token_runaway_is_verification_result(result) {
      continue
    }
    // A verification result present this turn that is NOT a gate-bearing
    // failure = verification passed / advanced = real progress.
    const gate = __agent_stall_gate_input(nil, result)
    if !(gate?.gate_bearing_failure ?? false) {
      return true
    }
  }
  return false
}

/**
 * token_runaway_post_turn(config) returns a `post_turn_callback` closure. It
 * reads the session's cumulative INPUT tokens (`agent_session_totals().input_tokens`
 * — the re-sent ballooning context burin's guard keys on, NOT the input+output
 * `tokens_used` sum), classifies progress as a passing verification this turn
 * (`token_runaway_made_progress`, NOT "any tool succeeded"), and on an abort
 * emits the SAME `agent_loop_stall_warning` event native trips use (pattern
 * "token_runaway", hard_stop:true) before returning a stop verdict. This is the
 * non-native detector implemented over the existing seam, one vocabulary.
 *
 * @effects: [agent]
 * @errors: []
 * @api_stability: experimental
 */
pub fn token_runaway_post_turn(config: dict) {
  return { payload ->
    const session_id = to_string(payload?.session_id ?? payload?.session?.id ?? "")
    const totals = agent_session_totals(session_id)
    const obs = {
      making_progress: token_runaway_made_progress(payload),
      tokens: to_float(totals?.input_tokens ?? 0),
    }
    const decision = token_runaway_decision(config, obs)
    if decision.action != "abort" {
      nil
    } else {
      const warning = {
        arguments_digest: "",
        consecutive_trips: 1,
        count: to_int(obs.tokens),
        hard_stop: true,
        iteration: to_int(payload?.iteration ?? 0),
        pattern: "token_runaway",
        reason: decision.reason,
        repeat_count: 0,
        signature: "token_runaway:" + decision.reason,
        signature_digest: "",
        threshold: to_int(decision.cap),
        tool_name: "",
      }
      agent_emit_event(session_id, "agent_loop_stall_warning", warning)
      {
        message: "Token-runaway detector: output has grown well past this task's typical size ("
          + to_string(
          to_int(obs.tokens),
        )
          + " input tokens vs a ~"
          + to_string(to_int(decision.cap))
          + " ceiling) without landing verified progress. Stop and report the blocker instead of generating more.",
        stop: true,
        stop_reason: "token_runaway:" + decision.reason,
      }
    }
  }
}

/**
 * detector_spec_to_stall lowers a unified DetectorSpec onto the native
 * `stall_diagnostics` config keys `__agent_stall_config` parses. Only the
 * detectors the native subsystem covers are lowered here (loop / no_progress /
 * stuck); token_runaway is handled by the overlay. Absent sub-keys are left to
 * the native defaults.
 *
 * @effects: []
 * @errors: []
 * @api_stability: experimental
 */
pub fn detector_spec_to_stall(spec: DetectorSpec) -> dict {
  let config = {enabled: spec.enabled ?? true}
  const loop = spec.loop
  if loop != nil {
    if loop.repeat != nil {
      config = config + {repeat_same_observation: loop.repeat}
    }
    if loop.ping_pong_cycles != nil {
      config = config + {ping_pong_cycles: loop.ping_pong_cycles}
    }
  }
  const no_progress = spec.no_progress
  if no_progress?.messages != nil {
    config = config + {no_progress_messages: no_progress.messages}
  }
  const stuck = spec.stuck
  if stuck != nil {
    if stuck.same_error != nil {
      config = config + {repeat_same_error: stuck.same_error}
    }
    if stuck.same_diagnostic != nil {
      config = config + {repair_aware: true, stuck_same_diagnostic_after: stuck.same_diagnostic}
    }
  }
  if spec.hard_stop_after_trips != nil {
    config = config + {hard_stop_after_trips: spec.hard_stop_after_trips}
  }
  return config
}

/**
 * detector_spec_token_runaway returns the token-runaway config from a spec (the
 * one non-native detector), or nil.
 *
 * @effects: []
 * @errors: []
 * @api_stability: experimental
 */
pub fn detector_spec_token_runaway(spec: DetectorSpec) {
  return spec.token_runaway
}

/**
 * unified_detectors_post_turn builds the `post_turn_callback` overlay for the
 * non-native detectors in a spec (currently token-runaway), or nil when none
 * apply. The native detectors run via `stall_diagnostics` — see
 * detector_spec_to_stall.
 *
 * @effects: []
 * @errors: []
 * @api_stability: experimental
 */
pub fn unified_detectors_post_turn(spec: DetectorSpec) {
  const token_runaway = detector_spec_token_runaway(spec)
  if token_runaway == nil {
    return nil
  }
  return token_runaway_post_turn(token_runaway)
}

/**
 * detector_action_of maps a native stall warning onto the shared
 * proceed/warn/abort governance vocabulary: a hard-stop trip is "abort", any
 * other warning is "warn", and nil (no trip) is "proceed". Lets a host handle
 * governor decisions and detector trips through one switch.
 *
 * @effects: []
 * @errors: []
 * @api_stability: experimental
 */
pub fn detector_action_of(warning) -> string {
  if warning == nil {
    return "proceed"
  }
  if warning?.hard_stop ?? false {
    return "abort"
  }
  return "warn"
}