feat(hive-claude): add InfiniteSession (name+store+compaction policy)
This commit is contained in:
parent
bf93a81e1d
commit
80d819e444
8 changed files with 501 additions and 283 deletions
|
|
@ -2,14 +2,15 @@
|
|||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Which claude session a run should attach to. Kept separate from [`Config`]
|
||||
/// so a single config can drive resume + create + `/compact` of the same
|
||||
/// logical session across turns.
|
||||
/// How a run attaches to a claude session — the low-level session flag. Kept
|
||||
/// separate from [`Config`] so one config can drive resume + create +
|
||||
/// `/compact` of the same logical session. For a self-managing durable
|
||||
/// session, prefer [`crate::InfiniteSession`] over hand-picking an `Attach`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Session {
|
||||
pub enum Attach {
|
||||
/// `--resume <id-or-title>` — resume an existing session by UUID or by the
|
||||
/// display title set via [`Session::Create`]. Yields
|
||||
/// [`crate::Outcome::SessionNotFound`] if nothing matches.
|
||||
/// display title set via [`Attach::Create`]. Yields
|
||||
/// [`crate::Error::SessionNotFound`] if nothing matches.
|
||||
Resume(String),
|
||||
/// `--name <title>` — start a new session carrying the given display title
|
||||
/// (persisted as a `custom-title` event, which `--resume <title>` later
|
||||
|
|
@ -23,7 +24,7 @@ pub enum Session {
|
|||
}
|
||||
|
||||
/// Everything needed to build one headless `claude --print` invocation, minus
|
||||
/// the [`Session`] attachment (passed separately to [`crate::run`]).
|
||||
/// the [`Attach`] target (passed separately to [`crate::Claude::run`]).
|
||||
///
|
||||
/// Fields map one-to-one to CLI flags; `None`/empty means "don't pass the
|
||||
/// flag". `--print --verbose --output-format stream-json` are always set by
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
//! The subprocess driver: spawn claude, pump + classify its streams, and
|
||||
//! assemble an [`Outcome`].
|
||||
//! assemble the result.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::process::Stdio;
|
||||
|
|
@ -8,7 +8,7 @@ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
|||
use tokio::process::{ChildStderr, ChildStdout, Command};
|
||||
|
||||
use crate::classify::Sentinels;
|
||||
use crate::{Config, Error, Result, Session, Sink};
|
||||
use crate::{Attach, Config, Error, Result, Sink};
|
||||
|
||||
/// Default program name spawned when [`Config::program`] is unset.
|
||||
const DEFAULT_PROGRAM: &str = "claude";
|
||||
|
|
@ -16,9 +16,9 @@ const DEFAULT_PROGRAM: &str = "claude";
|
|||
/// How many trailing stderr lines to keep for [`Error::Exit`].
|
||||
const STDERR_TAIL_LINES: usize = 20;
|
||||
|
||||
/// The driver entry point. A namespace for the run functions — there is
|
||||
/// nothing to construct; call the associated functions directly
|
||||
/// (`Claude::run(…)`, `Claude::run_resume_or_create(…)`).
|
||||
/// The low-level driver entry point. A namespace for the run function — there
|
||||
/// is nothing to construct; call `Claude::run(…)` directly. For a durable,
|
||||
/// self-compacting session, use [`crate::InfiniteSession`] instead.
|
||||
pub struct Claude;
|
||||
|
||||
impl Claude {
|
||||
|
|
@ -40,12 +40,12 @@ impl Claude {
|
|||
/// - [`Error::Exit`] on a non-zero exit that raised no sentinel.
|
||||
pub async fn run(
|
||||
config: &Config,
|
||||
session: &Session,
|
||||
attach: &Attach,
|
||||
prompt: &str,
|
||||
sink: &impl Sink,
|
||||
) -> Result<()> {
|
||||
let program = config.program.as_deref().unwrap_or(DEFAULT_PROGRAM);
|
||||
let mut cmd = build_command(program, config, session);
|
||||
let mut cmd = build_command(program, config, attach);
|
||||
|
||||
let mut child = cmd
|
||||
.stdin(Stdio::piped())
|
||||
|
|
@ -88,42 +88,12 @@ impl Claude {
|
|||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resume a titled session, creating it on first use. Runs
|
||||
/// `--resume <title>`; on [`Error::SessionNotFound`] it re-runs the *same
|
||||
/// prompt* once with `--name <title>` to mint the session.
|
||||
///
|
||||
/// Returns `Ok(true)` when a fresh session was created (the resume missed),
|
||||
/// `Ok(false)` when an existing session was resumed. This is the common
|
||||
/// "one durable session per agent, self-healing on first boot / after the
|
||||
/// file is archived away" pattern, kept generic here.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Propagates any [`Error`] from the underlying [`Claude::run`] calls
|
||||
/// (other than the `SessionNotFound` on the first attempt, which is handled
|
||||
/// by creating).
|
||||
pub async fn run_resume_or_create(
|
||||
config: &Config,
|
||||
title: &str,
|
||||
prompt: &str,
|
||||
sink: &impl Sink,
|
||||
) -> Result<bool> {
|
||||
match Self::run(config, &Session::Resume(title.to_string()), prompt, sink).await {
|
||||
Err(Error::SessionNotFound) => {
|
||||
Self::run(config, &Session::Create(title.to_string()), prompt, sink).await?;
|
||||
Ok(true)
|
||||
}
|
||||
Ok(()) => Ok(false),
|
||||
Err(other) => Err(other),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Assemble the argv. `--print --verbose --output-format stream-json` are
|
||||
/// mandatory (the driver parses that shape); everything else is gated on the
|
||||
/// [`Config`] / [`Session`].
|
||||
fn build_command(program: &str, config: &Config, session: &Session) -> Command {
|
||||
/// [`Config`] / [`Attach`].
|
||||
fn build_command(program: &str, config: &Config, attach: &Attach) -> Command {
|
||||
let mut cmd = Command::new(program);
|
||||
if let Some(cwd) = &config.cwd {
|
||||
cmd.current_dir(cwd);
|
||||
|
|
@ -138,17 +108,17 @@ fn build_command(program: &str, config: &Config, session: &Session) -> Command {
|
|||
if let Some(effort) = &config.effort {
|
||||
cmd.arg("--effort").arg(effort);
|
||||
}
|
||||
match session {
|
||||
Session::Resume(id) => {
|
||||
match attach {
|
||||
Attach::Resume(id) => {
|
||||
cmd.arg("--resume").arg(id);
|
||||
}
|
||||
Session::Create(title) => {
|
||||
Attach::Create(title) => {
|
||||
cmd.arg("--name").arg(title);
|
||||
}
|
||||
Session::Continue => {
|
||||
Attach::Continue => {
|
||||
cmd.arg("--continue");
|
||||
}
|
||||
Session::OneOff => {}
|
||||
Attach::OneOff => {}
|
||||
}
|
||||
if let Some(path) = &config.system_prompt_file {
|
||||
cmd.arg("--system-prompt-file").arg(path);
|
||||
|
|
|
|||
|
|
@ -7,9 +7,16 @@
|
|||
//! prompt-too-long, …) and hard failures (spawn, non-zero exit) — is a variant
|
||||
//! of the single [`Error`] enum, so callers branch with one `match`. The crate
|
||||
//! also locates and archives on-disk sessions by title ([`SessionStore`]). It
|
||||
//! knows only about the Claude Code CLI — no application types, policy,
|
||||
//! watermarks, or logging. Callers wire streaming output through a [`Sink`] and
|
||||
//! layer their own compaction / retry / reset policy on top.
|
||||
//! knows only about the Claude Code CLI — no application types, hard-coded
|
||||
//! watermarks, or logging. Callers wire streaming output through a [`Sink`].
|
||||
//!
|
||||
//! Two layers:
|
||||
//!
|
||||
//! - [`Claude::run`] — the low-level driver: one turn, one [`Attach`] target.
|
||||
//! - [`InfiniteSession`] — a durable session (name + [`SessionStore`] +
|
||||
//! [`CompactionPolicy`]) that keeps itself alive across the context window by
|
||||
//! compacting reactively (on overflow) and proactively (per policy — e.g.
|
||||
//! [`PercentPolicy`]). This is the one you usually want.
|
||||
//!
|
||||
//! # `thiserror` here, `anyhow` in the apps
|
||||
//!
|
||||
|
|
@ -32,13 +39,13 @@
|
|||
//!
|
||||
//! ```no_run
|
||||
//! # async fn ex() {
|
||||
//! use hive_claude::{Claude, Config, Error, NoopSink, Session};
|
||||
//! use hive_claude::{Attach, Claude, Config, Error, NoopSink};
|
||||
//!
|
||||
//! let config = Config {
|
||||
//! model: "haiku".into(),
|
||||
//! ..Default::default()
|
||||
//! };
|
||||
//! match Claude::run(&config, &Session::Resume("my-session".into()), "hello", &NoopSink).await {
|
||||
//! match Claude::run(&config, &Attach::Resume("my-session".into()), "hello", &NoopSink).await {
|
||||
//! Ok(()) => {}
|
||||
//! Err(Error::PromptTooLong) => { /* caller compacts + retries */ }
|
||||
//! Err(Error::RateLimited) => { /* caller parks + retries */ }
|
||||
|
|
@ -51,11 +58,17 @@ mod classify;
|
|||
mod config;
|
||||
mod driver;
|
||||
mod error;
|
||||
mod policy;
|
||||
mod session;
|
||||
mod sink;
|
||||
mod store;
|
||||
mod usage;
|
||||
|
||||
pub use config::{Config, Session};
|
||||
pub use config::{Attach, Config};
|
||||
pub use driver::Claude;
|
||||
pub use error::{Error, Result};
|
||||
pub use policy::{CompactionPolicy, NeverCompact, PercentPolicy};
|
||||
pub use session::{InfiniteSession, Progress};
|
||||
pub use sink::{NoopSink, Sink};
|
||||
pub use store::SessionStore;
|
||||
pub use usage::Usage;
|
||||
|
|
|
|||
130
hive-claude/src/policy.rs
Normal file
130
hive-claude/src/policy.rs
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
//! When a durable session should compact.
|
||||
|
||||
use crate::Usage;
|
||||
|
||||
/// Decides, after a completed turn, whether an [`crate::InfiniteSession`]
|
||||
/// should proactively compact — and what to say in the optional checkpoint
|
||||
/// turn that runs first. Injected by the caller so the driver stays free of
|
||||
/// any app-specific policy.
|
||||
pub trait CompactionPolicy {
|
||||
/// Given the last turn's context [`Usage`], compact now (before the window
|
||||
/// fills)?
|
||||
fn should_compact(&self, usage: Usage) -> bool;
|
||||
|
||||
/// Prompt for a pre-compaction checkpoint turn (a chance for the agent to
|
||||
/// flush durable state before detail collapses into a summary), or `None`
|
||||
/// to compact without one. Default: `None`.
|
||||
fn checkpoint_prompt(&self) -> Option<&str> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Compact once the context reaches `percent` of the model window.
|
||||
///
|
||||
/// The window is the one the model reported this turn ([`Usage::context_window`]),
|
||||
/// falling back to [`PercentPolicy::default_window`] when the turn reported
|
||||
/// none (e.g. a degenerate turn with no `result` usage). `percent == 0`
|
||||
/// disables proactive compaction entirely.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct PercentPolicy {
|
||||
/// Watermark as a percent of the context window (e.g. `75`). `0` disables.
|
||||
pub percent: u8,
|
||||
/// Window to assume when the turn didn't report one. `None` → never
|
||||
/// compact until a window is observed.
|
||||
pub default_window: Option<u64>,
|
||||
/// Prompt for the pre-compaction checkpoint turn; `None` skips it.
|
||||
pub checkpoint_prompt: Option<String>,
|
||||
}
|
||||
|
||||
impl CompactionPolicy for PercentPolicy {
|
||||
fn should_compact(&self, usage: Usage) -> bool {
|
||||
if self.percent == 0 {
|
||||
return false;
|
||||
}
|
||||
let Some(window) = usage.context_window.or(self.default_window).filter(|&w| w > 0) else {
|
||||
return false;
|
||||
};
|
||||
usage.context_tokens.saturating_mul(100) >= u64::from(self.percent) * window
|
||||
}
|
||||
|
||||
fn checkpoint_prompt(&self) -> Option<&str> {
|
||||
self.checkpoint_prompt.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
/// A policy that never compacts. Turns run until the session overflows and the
|
||||
/// reactive path in [`crate::InfiniteSession::run`] takes over.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct NeverCompact;
|
||||
|
||||
impl CompactionPolicy for NeverCompact {
|
||||
fn should_compact(&self, _usage: Usage) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{CompactionPolicy, NeverCompact, PercentPolicy};
|
||||
use crate::Usage;
|
||||
|
||||
fn policy(percent: u8, default_window: Option<u64>) -> PercentPolicy {
|
||||
PercentPolicy {
|
||||
percent,
|
||||
default_window,
|
||||
checkpoint_prompt: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fires_at_or_above_watermark() {
|
||||
let p = policy(75, None);
|
||||
// 75% of a 200k window = 150k.
|
||||
assert!(!p.should_compact(Usage {
|
||||
context_tokens: 149_999,
|
||||
context_window: Some(200_000),
|
||||
}));
|
||||
assert!(p.should_compact(Usage {
|
||||
context_tokens: 150_000,
|
||||
context_window: Some(200_000),
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_percent_disables() {
|
||||
assert!(!policy(0, Some(200_000)).should_compact(Usage {
|
||||
context_tokens: 199_999,
|
||||
context_window: Some(200_000),
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_default_window_when_unreported() {
|
||||
let p = policy(50, Some(100_000));
|
||||
assert!(p.should_compact(Usage {
|
||||
context_tokens: 50_000,
|
||||
context_window: None,
|
||||
}));
|
||||
// Reported window takes precedence over the default.
|
||||
assert!(!p.should_compact(Usage {
|
||||
context_tokens: 50_000,
|
||||
context_window: Some(200_000),
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_window_anywhere_never_fires() {
|
||||
assert!(!policy(75, None).should_compact(Usage {
|
||||
context_tokens: u64::MAX,
|
||||
context_window: None,
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn never_compact_is_never() {
|
||||
assert!(!NeverCompact.should_compact(Usage {
|
||||
context_tokens: u64::MAX,
|
||||
context_window: Some(1),
|
||||
}));
|
||||
}
|
||||
}
|
||||
175
hive-claude/src/session.rs
Normal file
175
hive-claude/src/session.rs
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
//! A durable, self-compacting ("infinite") claude session.
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{Attach, Claude, CompactionPolicy, Config, Error, Result, SessionStore, Sink, Usage, usage};
|
||||
|
||||
/// A named claude session that outlives the model's context window by
|
||||
/// compacting itself. Bundles the three things a durable session needs:
|
||||
///
|
||||
/// - a **name** (the constant session title it resumes / creates under),
|
||||
/// - a **store** ([`SessionStore`], to find the backing file so a resume vs.
|
||||
/// create is decided without a wasted spawn), and
|
||||
/// - a **policy** ([`CompactionPolicy`], deciding *when* to compact).
|
||||
///
|
||||
/// [`InfiniteSession::run`] keeps the session alive across turns:
|
||||
///
|
||||
/// - **resume-or-create** — resumes the titled session, creating it on first
|
||||
/// use (or after its file was archived away);
|
||||
/// - **reactive** — if a turn overflows ([`Error::PromptTooLong`]), it compacts
|
||||
/// and retries the same prompt once;
|
||||
/// - **proactive** — after a clean turn it consults the policy and, if due,
|
||||
/// runs an optional checkpoint turn then compacts.
|
||||
///
|
||||
/// Resetting/archiving the session is intentionally *not* part of this type —
|
||||
/// that stays with the caller.
|
||||
pub struct InfiniteSession<P: CompactionPolicy> {
|
||||
name: String,
|
||||
store: SessionStore,
|
||||
policy: P,
|
||||
}
|
||||
|
||||
/// What [`InfiniteSession::run`] did, beyond streaming the turn to the sink.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct Progress {
|
||||
/// The turn resumed no existing session — a fresh one was created.
|
||||
pub created: bool,
|
||||
/// A compaction ran (reactively on overflow, or proactively per policy).
|
||||
pub compacted: bool,
|
||||
}
|
||||
|
||||
impl<P: CompactionPolicy> InfiniteSession<P> {
|
||||
/// Build a durable session for `name`, backed by `store`, governed by
|
||||
/// `policy`.
|
||||
pub fn new(name: impl Into<String>, store: SessionStore, policy: P) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
store,
|
||||
policy,
|
||||
}
|
||||
}
|
||||
|
||||
/// Run one turn, keeping the session infinite (see the type docs).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Propagates any non-`SessionNotFound` [`Error`] from the underlying
|
||||
/// runs. A reactive retry that *still* overflows surfaces as
|
||||
/// [`Error::PromptTooLong`]; rate-limit / auth / hard failures propagate
|
||||
/// unchanged for the caller to handle.
|
||||
pub async fn run(&self, config: &Config, prompt: &str, sink: &impl Sink) -> Result<Progress> {
|
||||
let meter = UsageSink::new(sink);
|
||||
let created = match self.attempt(config, prompt, &meter).await {
|
||||
Ok(created) => created,
|
||||
Err(Error::PromptTooLong) => {
|
||||
// The session is already past the window — no turn can run on
|
||||
// it and the detail is gone (no checkpoint possible). Compact,
|
||||
// then retry the same prompt once.
|
||||
self.compact(config, sink).await?;
|
||||
let created = self.attempt(config, prompt, sink).await?;
|
||||
return Ok(Progress {
|
||||
created,
|
||||
compacted: true,
|
||||
});
|
||||
}
|
||||
Err(other) => return Err(other),
|
||||
};
|
||||
|
||||
// Proactive: the turn completed on a healthy session. If the policy
|
||||
// says it's due, checkpoint (best-effort) then compact.
|
||||
if self.policy.should_compact(meter.snapshot()) {
|
||||
if let Some(checkpoint) = self.policy.checkpoint_prompt() {
|
||||
let _ = self.attempt(config, checkpoint, sink).await;
|
||||
}
|
||||
let _ = self.compact(config, sink).await;
|
||||
return Ok(Progress {
|
||||
created,
|
||||
compacted: true,
|
||||
});
|
||||
}
|
||||
Ok(Progress {
|
||||
created,
|
||||
compacted: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Force a `/compact` on the session (e.g. operator-driven). Resume-only:
|
||||
/// if the session doesn't exist there is nothing to compact, so a
|
||||
/// [`Error::SessionNotFound`] is swallowed as a no-op `Ok`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Propagates any error other than `SessionNotFound` from the compact run.
|
||||
pub async fn compact(&self, config: &Config, sink: &impl Sink) -> Result<()> {
|
||||
match Claude::run(config, &Attach::Resume(self.name.clone()), "/compact", sink).await {
|
||||
Err(Error::SessionNotFound) => Ok(()),
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// One resume-or-create turn. Uses the store to pick resume vs. create up
|
||||
/// front (avoiding a wasted resume-miss spawn), and still self-heals if the
|
||||
/// backing file vanished between the check and the run. Returns whether a
|
||||
/// fresh session was created.
|
||||
async fn attempt(&self, config: &Config, prompt: &str, sink: &impl Sink) -> Result<bool> {
|
||||
let exists = self.store.find_by_title(&self.name).is_some();
|
||||
let attach = if exists {
|
||||
Attach::Resume(self.name.clone())
|
||||
} else {
|
||||
Attach::Create(self.name.clone())
|
||||
};
|
||||
match Claude::run(config, &attach, prompt, sink).await {
|
||||
Ok(()) => Ok(!exists),
|
||||
// We thought it existed but the resume missed (raced an archive) —
|
||||
// self-heal by creating.
|
||||
Err(Error::SessionNotFound) if exists => {
|
||||
Claude::run(config, &Attach::Create(self.name.clone()), prompt, sink).await?;
|
||||
Ok(true)
|
||||
}
|
||||
Err(other) => Err(other),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A [`Sink`] that forwards to an inner sink while accumulating the minimal
|
||||
/// [`Usage`] the policy needs from the stream. Cheap; the driver calls it
|
||||
/// synchronously from one reader task, so the `Mutex` only satisfies `&self`.
|
||||
struct UsageSink<'a, S: Sink> {
|
||||
inner: &'a S,
|
||||
usage: Mutex<Usage>,
|
||||
}
|
||||
|
||||
impl<'a, S: Sink> UsageSink<'a, S> {
|
||||
fn new(inner: &'a S) -> Self {
|
||||
Self {
|
||||
inner,
|
||||
usage: Mutex::new(Usage::default()),
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot(&self) -> Usage {
|
||||
*self.usage.lock().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: Sink> Sink for UsageSink<'_, S> {
|
||||
fn on_event(&self, event: &Value) {
|
||||
if let Some(tokens) = usage::context_tokens(event) {
|
||||
self.usage.lock().unwrap().context_tokens = tokens;
|
||||
}
|
||||
if let Some(window) = usage::context_window(event) {
|
||||
self.usage.lock().unwrap().context_window = Some(window);
|
||||
}
|
||||
self.inner.on_event(event);
|
||||
}
|
||||
|
||||
fn on_stdout_line(&self, line: &str) {
|
||||
self.inner.on_stdout_line(line);
|
||||
}
|
||||
|
||||
fn on_stderr_line(&self, line: &str) {
|
||||
self.inner.on_stderr_line(line);
|
||||
}
|
||||
}
|
||||
47
hive-claude/src/usage.rs
Normal file
47
hive-claude/src/usage.rs
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
//! Minimal context-usage signal parsed from the stream, used to drive
|
||||
//! [`crate::CompactionPolicy`]. This is deliberately small — just what a
|
||||
//! compaction decision needs. Consumers that want full per-turn accounting
|
||||
//! parse the raw events in their own [`crate::Sink`].
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
/// The context footprint of the most recent inference in a turn.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct Usage {
|
||||
/// Tokens in the last inference's context (input + cache reads + cache
|
||||
/// writes) — what counts against the model's window. `0` until the first
|
||||
/// `assistant` event is seen.
|
||||
pub context_tokens: u64,
|
||||
/// The model-reported active context window (`modelUsage.*.contextWindow`
|
||||
/// on the terminal `result` event), if the turn reported one.
|
||||
pub context_window: Option<u64>,
|
||||
}
|
||||
|
||||
/// Per-inference context size from an `assistant` event's `message.usage`.
|
||||
/// Tracking the *last* one over a turn gives the live conversation size (the
|
||||
/// cumulative `result` usage double-counts tool-call prompts and overshoots).
|
||||
pub(crate) fn context_tokens(event: &Value) -> Option<u64> {
|
||||
if event.get("type").and_then(Value::as_str) != Some("assistant") {
|
||||
return None;
|
||||
}
|
||||
let usage = event.get("message")?.get("usage")?;
|
||||
let field = |k: &str| usage.get(k).and_then(Value::as_u64).unwrap_or(0);
|
||||
Some(field("input_tokens") + field("cache_read_input_tokens") + field("cache_creation_input_tokens"))
|
||||
}
|
||||
|
||||
/// The per-inference active window from a `result` event's `modelUsage` map
|
||||
/// (first non-zero `contextWindow` across model keys). This is the limit the
|
||||
/// model actually enforces, which can be far below the prompt-cache capacity.
|
||||
pub(crate) fn context_window(event: &Value) -> Option<u64> {
|
||||
if event.get("type").and_then(Value::as_str) != Some("result") {
|
||||
return None;
|
||||
}
|
||||
for (_model, stats) in event.get("modelUsage")?.as_object()? {
|
||||
if let Some(w) = stats.get("contextWindow").and_then(Value::as_u64)
|
||||
&& w > 0
|
||||
{
|
||||
return Some(w);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
Loading…
Reference in a new issue