supercode-harness 0.5.45

The optional native Volter Harness agent and tool harness
Documentation
//! BP-7 (catalog §4a "Turn/step bracketing records", "Interrupt/abort with
//! state preserved", "Auto-retry on transient provider errors"): the
//! persisted per-round-trip marker log — cc's `turn_duration`/`api_retry`
//! system records and cx's `turn_context`/`turn_aborted`/`responses_retry`
//! rows, in one typed shape.
//!
//! **Where it lands.** `<session>.events.jsonl`, the sidecar-family member
//! [`crate::store::SessionStore`] has always reserved and swept
//! (archive/delete) but never had a writer for. So this is not a new store:
//! it is the log that slot was cut for, filled in — beside the transcript,
//! the reduction log, the usage log and the git-metadata record, exactly
//! like every other family member (§1.13's "typed session data, never a
//! lossy display-only channel").
//!
//! **Relationship to the usage log.** [`crate::usage_log::UsageRecord`] is
//! the ACCOUNTING projection: one row per round-trip, aggregatable by a
//! cost dashboard. This is the BRACKETING projection: what the request was
//! built over (`Context`), what it cost (`Usage`), how the round-trip ended
//! (`Finish`), and the two things that happen *between* round-trips
//! (`Retry`, `Aborted`). They are written from the same points in
//! `Agent::run_loop` and never disagree; keeping them separate keeps a
//! usage-log reader from having to skip four record kinds it does not care
//! about.

use serde::{Deserialize, Serialize};

/// Why a model round-trip (or a whole `send` loop) ended.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FinishReason {
    /// The assistant asked for tool calls; the loop continues.
    ToolCalls,
    /// The assistant produced a final answer and the loop returned.
    EndTurn,
    /// `core.max_iterations` was exhausted.
    MaxIterations,
    /// `core.max_total_output_tokens` was reached.
    OutputTokenBudget,
    /// `core.max_budget_usd` was reached.
    SpendBudget,
    /// `core.max_steps` was reached.
    StepBudget,
}

impl FinishReason {
    /// The wire spelling, for a reader that renders these without serde.
    pub fn label(self) -> &'static str {
        match self {
            FinishReason::ToolCalls => "tool_calls",
            FinishReason::EndTurn => "end_turn",
            FinishReason::MaxIterations => "max_iterations",
            FinishReason::OutputTokenBudget => "output_token_budget",
            FinishReason::SpendBudget => "spend_budget",
            FinishReason::StepBudget => "step_budget",
        }
    }
}

/// One bracketing marker. The `marker` tag is the record's kind.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "marker", rename_all = "snake_case")]
pub enum TurnMarker {
    /// Opens a round-trip: the shape of the context the request was built
    /// over, captured BEFORE the request is issued (so it survives a
    /// request that never returns).
    Context {
        /// Messages in the request.
        messages: usize,
        /// Tool schemas advertised on the request.
        tools: usize,
        /// Estimated prompt tokens (`supercode_runtime`' own estimator — the
        /// same one the context guard uses, so the two never disagree).
        estimated_tokens: u64,
    },
    /// The round-trip's provider-reported token accounting, plus its dollar
    /// cost when the model is priceable ([`crate::pricing`]).
    Usage {
        /// Input tokens.
        prompt_tokens: u64,
        /// Output tokens.
        completion_tokens: u64,
        /// Provider-reported total.
        total_tokens: u64,
        /// Prompt tokens served from the provider's cache, if reported.
        #[serde(default, skip_serializing_if = "Option::is_none")]
        cached_tokens: Option<u64>,
        /// Dollar cost, `None` when this build cannot price the model.
        #[serde(default, skip_serializing_if = "Option::is_none")]
        cost_usd: Option<f64>,
    },
    /// Closes a round-trip (or the loop).
    Finish {
        /// Why it ended.
        reason: FinishReason,
    },
    /// A transient provider failure was retried with backoff. Written from
    /// the notices the transport's own retry loop records, so a retried
    /// request is visible in the log instead of being invisible the way the
    /// ledger's `auto-retry-on-transient-provider-errors` row described.
    Retry {
        /// 0-based attempt index that FAILED (attempt 0 is the first try).
        attempt: u32,
        /// Backoff slept before the next attempt, milliseconds.
        delay_ms: u64,
        /// One-line reason (HTTP status or transport error).
        reason: String,
    },
    /// The turn was interrupted (Ctrl-C / a cancelled `send` future). The
    /// partial work already appended to the transcript stands; this marker
    /// is what makes the interruption a FACT on reload rather than
    /// something a reader has to infer from a dangling tool call.
    Aborted {
        /// Where the interruption came from (`"ctrl_c"`, `"cancelled"`, …).
        source: String,
        /// Messages in the agent's history at the moment of the abort.
        messages: usize,
    },
    /// Reasoning effort changed mid-session (`/effort`), the extended-
    /// thinking analog of the `model_change` log.
    Effort {
        /// Effort before the change (`None` = thinking off).
        #[serde(default, skip_serializing_if = "Option::is_none")]
        from: Option<String>,
        /// Effort after the change (`None` = thinking off).
        #[serde(default, skip_serializing_if = "Option::is_none")]
        to: Option<String>,
    },
    /// The session's persistent objective was set, changed, or cleared
    /// (`/goal`). The goal itself lives in `<session>.goal.json`; this is
    /// the audit trail of when it moved.
    Goal {
        /// The objective after the change; empty means cleared.
        objective: String,
    },
}

/// One marker with the per-round-trip context every marker shares.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TurnRecord {
    /// 0-based index of the model round-trip this record brackets — the
    /// same counter [`crate::usage_log::UsageRecord::turn`] uses, so the
    /// two logs join on it.
    pub turn: usize,
    /// The model in effect when the marker was written.
    pub model: String,
    /// Unix-ms wall-clock time.
    pub timestamp_ms: i64,
    /// The marker itself.
    #[serde(flatten)]
    pub marker: TurnMarker,
}

impl TurnRecord {
    /// Build a record for `marker`.
    pub fn new(turn: usize, model: &str, timestamp_ms: i64, marker: TurnMarker) -> TurnRecord {
        TurnRecord {
            turn,
            model: model.to_string(),
            timestamp_ms,
            marker,
        }
    }
}

/// Serialize `records` as JSONL — the same shape every other append-log in
/// this crate uses.
pub fn to_jsonl(records: &[TurnRecord]) -> crate::Result<String> {
    let mut out = String::new();
    for r in records {
        out.push_str(&serde_json::to_string(r).map_err(crate::Error::Decode)?);
        out.push('\n');
    }
    Ok(out)
}

/// Parse a JSONL marker log back into records — the exact inverse of
/// [`to_jsonl`]. Blank lines are skipped; a malformed line is a hard error
/// (this is an audit log, so a corrupt record should be visible, never
/// silently dropped — same posture as [`crate::usage_log::from_jsonl`]).
pub fn from_jsonl(text: &str) -> crate::Result<Vec<TurnRecord>> {
    let mut out = Vec::new();
    for line in text.lines() {
        let line = line.trim();
        if line.is_empty() {
            continue;
        }
        out.push(serde_json::from_str(line).map_err(crate::Error::Decode)?);
    }
    Ok(out)
}