/**
* std/lifecycle/on_budget — named callback strategies for budget exhaustion.
*
* The runtime fires the `OnBudgetThreshold` lifecycle event whenever an
* agent loop / pipeline crosses a configured budget threshold (cents,
* tokens, thinking-tokens, etc.). Users register a handler closure to
* decide what happens next. This module ships four canonical
* strategies so the common cases stay one-liners:
*
* * `OnBudget.terminate` — hard stop. Emits a `budget_exceeded` audit
* entry and throws a structured `budget_exceeded` exception so the
* enclosing agent loop / pipeline unwinds immediately.
* * `OnBudget.graceful_exit` — schedules a clean finish. Emits a
* `budget_graceful_exit` audit entry and returns a structured exit
* envelope ({status, reason, budget_state, message}) so the
* pipeline's `on_finish` chain can drain in-flight work and return
* the envelope as the pipeline's value instead of throwing.
* * `OnBudget.warn_and_continue` — soft-warn. Emits a
* `budget_warn_and_continue` audit entry, injects a 1-turn
* `budget_warning` system_reminder so the agent's next turn sees
* the overrun, and returns `budget_state` unchanged so combinator
* chains see a passthrough.
* * `OnBudget.replan` — forks the current session at the last known
* good iteration, injects a generic replan reminder, and resumes
* the fork when the budget snapshot carries a task.
*
* All four follow the `(harness, budget_state) -> result` shape, which
* is identical to the `(harness, return_value) -> return_value` contract
* the rest of the lifecycle layer uses. That means every callback in
* this module composes freely with `std/lifecycle/combinators`:
*
* import { OnBudget } from "std/lifecycle/on_budget"
* import { compose, with_telemetry } from "std/lifecycle/combinators"
*
* register_persona_hook(
* "*",
* "OnBudgetThreshold",
* compose([OnBudget.warn_and_continue, with_telemetry(my_logger)]),
* )
*
* `budget_state` is whatever the dispatcher delivers — typically a
* dict shaped roughly `{kind, limit, limit_value, consumed_cost_usd,
* consumed_tokens, threshold_pct, message?, ...}`. The callbacks treat
* it as opaque and copy the whole snapshot into their audit + reminder
* payloads so downstream consumers see the original shape.
*
* Tracking: harn#1914 (P-11), harn#1853 (epic).
*/
fn __on_budget_snapshot(budget_state) {
if budget_state == nil {
return {}
}
if type_of(budget_state) == "dict" {
return budget_state
}
return {value: budget_state}
}
fn __on_budget_default_message(state, fallback) -> string {
if type_of(state) == "dict" && state?.message != nil {
return to_string(state.message)
}
return fallback
}
fn __on_budget_emit_audit(harness, kind, strategy, state) {
let snapshot = __on_budget_snapshot(state)
let payload = {
strategy: strategy,
budget_state: snapshot,
message: __on_budget_default_message(snapshot, "budget threshold crossed"),
}
return harness.emit_audit(kind, payload)
}
fn __on_budget_terminate_error(state) {
let snapshot = __on_budget_snapshot(state)
return {
category: "budget_exceeded",
kind: "terminal",
reason: "on_budget_terminate",
strategy: "terminate",
budget_state: snapshot,
message: __on_budget_default_message(snapshot, "budget exceeded; OnBudget.terminate aborting"),
}
}
/**
* Hard-stop budget strategy. Emits `budget_exceeded` to the lifecycle
* audit log (capturing the full `budget_state` snapshot) and then
* throws a structured `budget_exceeded` exception so the enclosing
* agent loop / pipeline unwinds immediately. The exception payload
* carries `{category: "budget_exceeded", kind: "terminal", reason:
* "on_budget_terminate", strategy: "terminate", budget_state, message}`
* so error_boundary policies and host-side handlers can route on it
* the same way they route the runtime's own preflight
* budget-exceeded throws.
*
* Use this when crossing the budget is a fatal contract violation.
* Never returns; never reaches downstream combinators.
*
* @effects: [host]
* @allocation: heap
* @errors: ["budget_exceeded"]
* @api_stability: experimental
* @example: register_persona_hook("*", "OnBudgetThreshold", OnBudget.terminate)
*/
pub fn terminate(harness, budget_state) {
__on_budget_emit_audit(harness, "budget_exceeded", "terminate", budget_state)
throw __on_budget_terminate_error(budget_state)
}
/**
* Graceful-exit budget strategy. Emits `budget_graceful_exit` to the
* lifecycle audit log and returns a structured exit envelope shaped
* `{status: "budget_exhausted", strategy: "graceful_exit", reason:
* "on_budget_graceful_exit", budget_state, message}`. Unlike
* `terminate`, this does *not* throw — the envelope becomes the
* callback's return value, which means downstream combinators (and the
* pipeline's own `on_finish` chain) can drain in-flight work and
* return the envelope as the pipeline's value. Callers who want a
* hard abort should chain `OnBudget.terminate` after this preset.
*
* The envelope keys are deterministic so host-side renderers can pin
* on `status == "budget_exhausted"` without parsing free-form text.
*
* @effects: [host]
* @allocation: heap
* @errors: []
* @api_stability: experimental
* @example: register_persona_hook("*", "OnBudgetThreshold", OnBudget.graceful_exit)
*/
pub fn graceful_exit(harness, budget_state) {
__on_budget_emit_audit(harness, "budget_graceful_exit", "graceful_exit", budget_state)
let snapshot = __on_budget_snapshot(budget_state)
return {
status: "budget_exhausted",
strategy: "graceful_exit",
reason: "on_budget_graceful_exit",
budget_state: snapshot,
message: __on_budget_default_message(
snapshot,
"budget exceeded; OnBudget.graceful_exit scheduling clean shutdown",
),
}
}
/**
* Soft-warn budget strategy. Emits a `budget_warn_and_continue` audit
* entry, injects a 1-turn `budget_warning` system_reminder so the
* agent's next turn observes the overrun, and returns the original
* `budget_state` unchanged. The passthrough return value is the
* essential property that lets combinator chains like
* `compose([OnBudget.warn_and_continue, custom_logger])` thread the
* dispatcher's snapshot through every entry in the chain without
* re-shaping it.
*
* The injected reminder uses dedupe_key `on_budget.warning` so
* repeated triggers within a single turn collapse to one reminder
* (the dedupe contract on `inject_reminder`). When no active agent
* session exists the reminder still records a
* `tool_hooks.reminder_injected` audit entry, so headless pipelines
* and conformance fixtures can observe the side effect deterministically.
*
* @effects: [host]
* @allocation: heap
* @errors: []
* @api_stability: experimental
* @example: register_persona_hook("*", "OnBudgetThreshold", OnBudget.warn_and_continue)
*/
pub fn warn_and_continue(harness, budget_state) {
__on_budget_emit_audit(harness, "budget_warn_and_continue", "warn_and_continue", budget_state)
let snapshot = __on_budget_snapshot(budget_state)
let body = __on_budget_default_message(
snapshot,
"Your budget is exhausted (OnBudget.warn_and_continue). Consider wrapping up or asking for a budget extension.",
)
tool_hooks_inject_reminder(
{tags: ["budget_warning", "on_budget"], body: body, dedupe_key: "on_budget.warning", ttl_turns: 1},
)
return budget_state
}
fn __on_budget_int(value, fallback) {
let parsed = to_int(value ?? fallback)
if parsed == nil || parsed < 0 {
return fallback
}
return parsed
}
fn __on_budget_session_id(snapshot) {
let nested = snapshot?.session?.id
let direct = snapshot?.session_id
let current = agent_session_current_id()
let session_id = direct ?? nested ?? current ?? ""
return to_string(session_id)
}
fn __on_budget_replan_keep_first(src, snapshot, iteration) {
var keep_first = __on_budget_int(snapshot?.last_good_iteration, iteration)
let source = agent_session_snapshot(src) ?? {}
let max_events = len(source.messages ?? [])
if keep_first > max_events {
keep_first = max_events
}
return keep_first
}
fn __on_budget_replan_reminder(kind, iteration) {
return "Previous loop hit `" + kind + "` at iteration " + to_string(iteration)
+ ". Restart from checkpoint with a different approach."
}
/**
* Replan budget strategy. Forks the active source session at
* `last_good_iteration`, injects a generic reminder describing the
* exhausted budget kind, and resumes the fork when `budget_state`
* carries `{task|message, system?, options?}`. Without a task it returns
* the fork envelope after injection, which lets orchestration code
* decide when to schedule the retry.
*
* @effects: [host]
* @allocation: heap
* @errors: ["budget_replan_missing_session"]
* @api_stability: experimental
* @example: register_persona_hook("*", "OnBudgetThreshold", OnBudget.replan)
*/
pub fn replan(harness, budget_state) {
__on_budget_emit_audit(harness, "budget_replan", "replan", budget_state)
let snapshot = __on_budget_snapshot(budget_state)
let src = __on_budget_session_id(snapshot)
if src == "" {
throw {
category: "budget_exceeded",
kind: "terminal",
reason: "budget_replan_missing_session",
strategy: "replan",
budget_state: snapshot,
message: "OnBudget.replan requires a source session",
}
}
let iteration = __on_budget_int(snapshot?.iteration ?? snapshot?.n, 0)
let keep_first = __on_budget_replan_keep_first(src, snapshot, iteration)
let dst = snapshot?.dst_session_id
let forked = if dst != nil {
agent_session_fork_at(src, keep_first, to_string(dst))
} else {
agent_session_fork_at(src, keep_first)
}
let budget_kind = to_string(snapshot?.budget_kind ?? snapshot?.kind ?? "budget")
let reminder_body = __on_budget_replan_reminder(budget_kind, iteration)
agent_session_inject(
forked,
transcript_reminder_event(
{
body: reminder_body,
tags: ["on_budget", "replan"],
dedupe_key: "on_budget.replan:" + forked,
ttl_turns: 1,
},
),
)
let task = snapshot?.task ?? snapshot?.message
let base_options = snapshot?.options ?? {}
let result = if task != nil {
agent_loop(task, snapshot?.system, base_options + {session_id: forked})
} else {
nil
}
return {
status: "replanned",
strategy: "replan",
session_id: forked,
source_session_id: src,
iteration: iteration,
last_good_iteration: keep_first,
reminder: reminder_body,
result: result,
}
}
/**
* `OnBudget()` returns the named-strategy namespace as a dict so
* callers can use dotted access (e.g. `OnBudget.terminate`) after a
* single import. This mirrors the `QueueStrategy()` / `Backpressure()`
* factories in `std/lifecycle/pool` and keeps the `OnBudget.xxx`
* spelling consistent with the rest of the lifecycle namespace
* pattern.
*
* @effects: []
* @allocation: heap
* @errors: []
* @api_stability: experimental
* @example: let OnBudget = OnBudget()
*/
pub fn OnBudget() {
return {
terminate: terminate,
graceful_exit: graceful_exit,
warn_and_continue: warn_and_continue,
replan: replan,
}
}