From dd889e6a0b55e4e9f33a49cc0953e9710ea3add7 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Thu, 5 Nov 2026 14:26:51 -0600 Subject: [PATCH] feat: show the assignee's provider usage limit in task properties (#19270) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agents run on the native Runner through the Codex app-server and the Claude ACP harness > - Both harnesses report the provider account's rate-limit windows: Codex sends `account/rateLimits/updated`, and Claude sends its SDK `rate_limit_event` as `usage_update._meta["_claude/rateLimit"]` > - Paperclip dropped both signals, so an operator could not see that an agent was near or past its provider limit > - This pull request normalizes both signals into one account-scoped snapshot, keeps the latest snapshot per agent, and shows it as one "Usage limit" line in the task properties pane > - The benefit is that an operator sees "5h 78% · resets 15:40" (amber above 80%, "Limited until …" when the provider refuses work, "using credits" when a used-up window falls back to credits) next to the assignee, and can click it for every window ## Linked Issues or Issue Description Refs #19246 Refs #19259 **Is your feature request related to a problem?** Codex and Claude report provider rate-limit windows during a run. Paperclip dropped them: the Codex notification fell through the Rust normalizer with no branch, and ACPX dropped Claude's `_meta`. Operators could not see how close an agent was to its provider limit. **Describe the solution you'd like** Store the latest reported windows per agent and show one compact line in the task properties pane, with a popover that lists every window. **Describe alternatives you've considered** A full gauge in the agent list and the run header. A design review chose one property line with a details popover instead. ## What Changed - Runner (Rust, the runnerd path): new `provider_rate_limits.rs`. Codex `account/rateLimits/updated` becomes a `provider.notice.recorded` event (category `provider.rate_limits`, scope `account`). `CodexProviderState` merges sparse updates and emits a notice only when the windows change. ACPX `usage_update` with a sanitized `rateLimit` field also emits the notice. - Runner (sidecar): `acpx-runtime-sidecar.ts` forwards only `status`, `rateLimitType`, `utilization` and `resetsAt` from `_meta["_claude/rateLimit"]`. This needs the ACPX `_meta` passthrough from #19259. Until that merges, Claude emits nothing. - Runner (TypeScript in-process drivers): the same mapping in `codex-session-notifications.ts` and `codex-acpx-driver.ts` through `drivers/provider-rate-limits.ts`. - Shared: `provider-rate-limits.ts` holds the snapshot type, the parser, the merge, and `summarizeProviderRateLimits` (fullest current window, expiry by reset time, limited versus using credits). - Server: `agent-provider-rate-limits.ts` merges each notice into `agent_runtime_state.state_json.providerRateLimits`, scoped by company and agent. A session reset keeps the snapshot. No migration. - UI: `AgentRateLimitPropertyValue` adds a "Usage limit" row under the assignee in `IssueProperties`. Transcripts treat the notice as run-log only. A run status event refreshes the assignee's runtime state. - Storybook: `Tasks and conversations/Task creation and lifecycle/Usage limit property`. - Docs: `doc/run-log-events.md` describes the notice and its storage. ## Verification - `cd packages/paperclip-runner/runner && cargo test -p paperclip-runner-core --lib rate_limit` and `--test acpx_provider_events`: sparse Codex merge, dedupe, reached window, checkpoint round trip, Claude utilization and rejection, notice shape. - `cd packages/paperclip-runner && npx vitest run src/drivers/provider-rate-limits.test.ts src/drivers/codex` - `cd packages/shared && npx vitest run src/provider-rate-limits.test.ts`: summary, expiry, limited, using credits, parse and merge. - `cd server && npx vitest run src/__tests__/agent-provider-rate-limits.test.ts`: create and merge, out-of-order snapshot, company isolation, unrelated notices, session reset keeps the snapshot. - `cd ui && npx vitest run src/components/transcript src/components/issue-properties src/context/LiveUpdatesProvider` - `pnpm -r typecheck` and `pnpm check:token-gates` pass. - Live test on a local worktree instance: a native Codex agent on a ChatGPT subscription login ran a real task. The runner recorded one `provider.rate_limits` notice. The server stored it. The task properties pane showed "7d 100% · using credits" (this account's weekly window was used up and had credits, and the run succeeded). The transcript showed no extra row. - Not verified live: Claude. The local Claude CLI was not signed in, and the Claude path also needs #19259. The Rust and sidecar paths have tests. - Not run locally: the full `pnpm test:run` and `pnpm build`. CI runs them. ## Risks - Low risk. The snapshot is display data. It does not hold or start work. The existing provider-limit wait (#19246) still reads typed run failures. - Codex can send a rate-limit update often. The runner emits a notice only when the windows change, so run-log growth stays small. - With API-key auth, the harnesses report no windows, so no row appears. This is the intended result. - `CodexProviderState` gains two optional serde fields with defaults, so older checkpoints still load. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, through Claude Code with tool use (shell, file edits, browser) and extended reasoning. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- doc/run-log-events.md | 13 + .../runner/crates/runner-core/src/lib.rs | 1 + .../runner-core/src/provider_backend.rs | 67 +++ .../crates/runner-core/src/provider_events.rs | 22 +- .../runner-core/src/provider_rate_limits.rs | 483 ++++++++++++++++++ .../runner-core/tests/acpx_provider_events.rs | 57 +++ .../src/cli/acpx-runtime-sidecar.ts | 23 + .../src/drivers/acpx/codex-acpx-driver.ts | 16 + .../codex/codex-session-notifications.ts | 15 + .../src/drivers/codex/codex-session-state.ts | 4 + .../src/drivers/provider-rate-limits.test.ts | 130 +++++ .../src/drivers/provider-rate-limits.ts | 251 +++++++++ packages/shared/src/constants.ts | 1 + packages/shared/src/index.ts | 1 + .../shared/src/provider-rate-limits.test.ts | 89 ++++ packages/shared/src/provider-rate-limits.ts | 209 ++++++++ .../agent-provider-rate-limits.test.ts | 143 ++++++ .../__tests__/live-events-run-read.test.ts | 5 + server/src/realtime/live-events-ws.ts | 1 + .../services/agent-provider-rate-limits.ts | 71 +++ server/src/services/heartbeat/run-state.ts | 6 +- .../native-runtime/native-session-executor.ts | 13 + ui/src/components/AgentRateLimitGauge.tsx | 139 +++++ .../issue-properties/IssueProperties.tsx | 24 +- .../transcript/run-log-only-events.ts | 4 + ui/src/context/LiveUpdatesProvider.tsx | 11 + .../task-usage-limit-property.stories.tsx | 117 +++++ 27 files changed, 1911 insertions(+), 5 deletions(-) create mode 100644 packages/paperclip-runner/runner/crates/runner-core/src/provider_rate_limits.rs create mode 100644 packages/paperclip-runner/src/drivers/provider-rate-limits.test.ts create mode 100644 packages/paperclip-runner/src/drivers/provider-rate-limits.ts create mode 100644 packages/shared/src/provider-rate-limits.test.ts create mode 100644 packages/shared/src/provider-rate-limits.ts create mode 100644 server/src/__tests__/agent-provider-rate-limits.test.ts create mode 100644 server/src/services/agent-provider-rate-limits.ts create mode 100644 ui/src/components/AgentRateLimitGauge.tsx create mode 100644 ui/storybook/stories/tasks/task-usage-limit-property.stories.tsx diff --git a/doc/run-log-events.md b/doc/run-log-events.md index 6a6fe1c9a00..4b06ff715ee 100644 --- a/doc/run-log-events.md +++ b/doc/run-log-events.md @@ -591,6 +591,19 @@ changes, and unrelated thread information do not create a provider notice in chat. Chat also omits the matching notice stored by older runners. Real provider warnings and errors remain visible. +Provider rate-limit windows use `provider.notice.recorded` with category +`provider.rate_limits` and scope `account`. Codex sends them as +`account/rateLimits/updated`. Claude sends them as `usage_update` metadata +(`_claude/rateLimit`) when it uses a subscription sign-in. The runner merges +sparse Codex updates and records a notice only when the windows change. The +notice has one `rateLimits` detail: a JSON snapshot with schema +`paperclip.provider.rate_limits.v1` (windows with used percent and reset time, +credits, and the reached window). Chat does not show the notice. The server +keeps the latest snapshot in the agent's runtime state under +`providerRateLimits`, and the task properties pane shows it as the assignee's +**Usage limit**. The snapshot is display data. It does not hold or start work; +the provider-limit wait reads typed run failures. + Harness status notices use `provider.notice.recorded` with a status category. Codex `error` notifications read `error.message`. A notification with `willRetry: true` becomes a recoverable `provider_retry` notice. A final error diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/lib.rs b/packages/paperclip-runner/runner/crates/runner-core/src/lib.rs index 37bfa79e712..8c9eb7c1be7 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/lib.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/lib.rs @@ -35,6 +35,7 @@ pub mod provider_backend; pub mod provider_bridge; pub mod provider_events; pub mod provider_failure; +pub mod provider_rate_limits; pub mod qualified_launch; pub mod question_response; pub mod replay; diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/src/provider_backend.rs index 17e8f5bd760..9de9869c27a 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/provider_backend.rs @@ -625,6 +625,30 @@ fn normalize_provider_notification( method: &str, params: &Value, ) -> Result, DurableRunnerError> { + if state.config.provider == "codex" && method == "account/rateLimits/updated" { + // Account-scoped and sparse: merge into the last snapshot and report + // only a changed one, so repeated identical updates stay out of the log. + let Some(next) = crate::provider_rate_limits::codex_rate_limit_snapshot( + params, + state.codex_rate_limits.as_ref(), + &crate::provider_rate_limits::now_iso(), + ) else { + return Ok(Vec::new()); + }; + let changed = crate::provider_rate_limits::rate_limit_snapshot_changed( + state.codex_rate_limits.as_ref(), + &next, + ); + state.codex_rate_limits = Some(next.clone()); + if !changed { + return Ok(Vec::new()); + } + state.rate_limit_notice_seq = state.rate_limit_notice_seq.saturating_add(1); + return Ok(vec![crate::provider_rate_limits::rate_limit_notice( + &next, + &format!("codex-rate-limits-{}", state.rate_limit_notice_seq), + )]); + } if state.config.provider == "codex" && ["thread/tokenUsage/updated", "paperclip/resumeUsageSnapshot"].contains(&method) { @@ -1185,6 +1209,11 @@ struct CodexProviderState { cursor_cloud_terminal_receipt: Option, #[serde(default)] codex_usage: Option, + /// Latest Codex account rate-limit snapshot, merged across sparse updates. + #[serde(default, skip_serializing_if = "Option::is_none")] + codex_rate_limits: Option, + #[serde(default)] + rate_limit_notice_seq: u64, #[serde(default)] previous_accounting_origin: Option, #[serde(default)] @@ -1299,6 +1328,8 @@ impl CodexProviderState { provider_termination_receipt: None, cursor_cloud_terminal_receipt: None, codex_usage, + codex_rate_limits: None, + rate_limit_notice_seq: 0, previous_accounting_origin: None, completion_contract, tool_bridge, @@ -5919,6 +5950,40 @@ mod tests { state } + #[test] + fn codex_rate_limit_updates_emit_only_changed_account_notices() { + let mut state = opencode_result_state(); + state.config.provider = "codex".to_owned(); + let update = json!({"rateLimits": {"limitId": "codex", + "primary": {"usedPercent": 78, "windowDurationMins": 300, "resetsAt": 1_793_893_200_i64}}}); + let first = + normalize_provider_notification(&mut state, "account/rateLimits/updated", &update) + .unwrap(); + assert_eq!(first.len(), 1); + assert_eq!(first[0].event_type, "provider.notice.recorded"); + assert_eq!(first[0].payload["category"], "provider.rate_limits"); + assert_eq!(first[0].payload["noticeId"], "codex-rate-limits-1"); + assert!( + normalize_provider_notification(&mut state, "account/rateLimits/updated", &update) + .unwrap() + .is_empty() + ); + let sparse = json!({"rateLimits": {"limitId": "codex", + "secondary": {"usedPercent": 41, "windowDurationMins": 10080, "resetsAt": null}}}); + let second = + normalize_provider_notification(&mut state, "account/rateLimits/updated", &sparse) + .unwrap(); + let snapshot: Value = + serde_json::from_str(second[0].payload["details"][0]["value"].as_str().unwrap()) + .unwrap(); + assert_eq!(snapshot["windows"].as_array().unwrap().len(), 2); + assert_eq!(snapshot["windows"][0]["usedPercent"], 78.0); + // Restored checkpoints keep the merged snapshot. + let restored: CodexProviderState = + serde_json::from_value(serde_json::to_value(&state).unwrap()).unwrap(); + assert_eq!(restored.codex_rate_limits, state.codex_rate_limits); + } + fn valid_opencode_result() -> Value { json!({ "schema": "paperclip.run_result.v1", @@ -6921,6 +6986,8 @@ mod tests { provider_termination_receipt: None, cursor_cloud_terminal_receipt: None, codex_usage: Some(CodexRunUsage::new(false)), + codex_rate_limits: None, + rate_limit_notice_seq: 0, previous_accounting_origin: None, completion_contract: None, tool_bridge: ProviderToolBridge::default(), diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/provider_events.rs b/packages/paperclip-runner/runner/crates/runner-core/src/provider_events.rs index 0a169a25f85..a6778513a8b 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/provider_events.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/provider_events.rs @@ -1613,7 +1613,7 @@ fn normalize_acpx_status( "requests": 1, "providerCostUsd": if turn_cost { provider_cost } else { 0.0 }, }); - return vec![NormalizedProviderEvent { + let usage = NormalizedProviderEvent { event_type: "usage.reported".to_owned(), priority: EventPriority::P0, payload: json!({ @@ -1628,7 +1628,25 @@ fn normalize_acpx_status( "runDeltaAvailable": run_delta_available, "runDelta": run_delta, }), - }]; + }; + // Claude forwards its SDK rate_limit_event on usage_update; the + // sidecar passes only the sanitized `rateLimit` fields. + let rate_limits = payload.get("rateLimit").and_then(|info| { + crate::provider_rate_limits::claude_rate_limit_snapshot( + info, + &crate::provider_rate_limits::now_iso(), + ) + }); + return match rate_limits { + Some(snapshot) => vec![ + usage, + crate::provider_rate_limits::rate_limit_notice( + &snapshot, + &stable_id(&format!("{item_id}:rate-limits"), "acpx-rate-limits"), + ), + ], + None => vec![usage], + }; } if tag == "current_mode_update" { let status = string(payload.get("text")); diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/provider_rate_limits.rs b/packages/paperclip-runner/runner/crates/runner-core/src/provider_rate_limits.rs new file mode 100644 index 00000000000..52f2cdc224e --- /dev/null +++ b/packages/paperclip-runner/runner/crates/runner-core/src/provider_rate_limits.rs @@ -0,0 +1,483 @@ +//! Provider rate-limit windows normalized into one account-scoped snapshot and +//! carried as a `provider.notice.recorded` event (category +//! `provider.rate_limits`). The control plane keeps the latest snapshot per +//! agent; transcripts treat the notice as run-log only. Mirrors +//! `src/drivers/provider-rate-limits.ts` and `@paperclipai/shared` +//! `provider-rate-limits.ts` (schema `paperclip.provider.rate_limits.v1`). + +use serde_json::{json, Map, Value}; + +use crate::durable::EventPriority; +use crate::provider_events::NormalizedProviderEvent; + +pub const PROVIDER_RATE_LIMITS_CATEGORY: &str = "provider.rate_limits"; +pub const PROVIDER_RATE_LIMITS_SCHEMA: &str = "paperclip.provider.rate_limits.v1"; +const MAX_WINDOWS: usize = 8; +const MAX_DETAIL_CHARS: usize = 4_000; + +fn bounded(value: Option<&Value>, max: usize) -> Option { + value + .and_then(Value::as_str) + .filter(|text| !text.is_empty()) + .map(|text| text.chars().take(max).collect()) +} + +fn finite(value: Option<&Value>) -> Option { + value + .and_then(Value::as_f64) + .filter(|value| value.is_finite()) +} + +fn percent(value: Option) -> Value { + match value { + Some(value) => json!((value.clamp(0.0, 1000.0) * 10.0).round() / 10.0), + None => Value::Null, + } +} + +fn epoch_seconds_iso(value: Option<&Value>) -> Value { + use aws_smithy_types::{date_time::Format, DateTime}; + let Some(seconds) = finite(value).filter(|seconds| *seconds > 0.0) else { + return Value::Null; + }; + DateTime::from_secs(seconds as i64) + .fmt(Format::DateTime) + .map(Value::String) + .unwrap_or(Value::Null) +} + +fn iso_epoch(value: &str) -> Option { + use aws_smithy_types::{date_time::Format, DateTime}; + DateTime::from_str(value, Format::DateTime) + .ok() + .map(|date| date.secs()) +} + +fn iso_epoch_value(value: &Value) -> Option { + value.as_str().and_then(iso_epoch) +} + +pub fn now_iso() -> String { + use aws_smithy_types::{date_time::Format, DateTime}; + DateTime::from(std::time::SystemTime::now()) + .fmt(Format::DateTime) + .unwrap_or_default() +} + +fn window_labels(minutes: Option) -> (String, String) { + match minutes { + None => ("Usage window".to_owned(), "Usage".to_owned()), + Some(minutes) if minutes % 10_080 == 0 => { + let weeks = minutes / 10_080; + if weeks == 1 { + ("Weekly".to_owned(), "7d".to_owned()) + } else { + (format!("{weeks}-week window"), format!("{}d", weeks * 7)) + } + } + Some(minutes) if minutes % 1_440 == 0 => { + let days = minutes / 1_440; + let label = if days == 1 { + "Daily".to_owned() + } else { + format!("{days}-day window") + }; + (label, format!("{days}d")) + } + Some(minutes) if minutes % 60 == 0 => { + let hours = minutes / 60; + (format!("{hours}-hour window"), format!("{hours}h")) + } + Some(minutes) => (format!("{minutes}-minute window"), format!("{minutes}m")), + } +} + +fn codex_window( + limit_id: Option<&str>, + limit_name: Option<&str>, + slot: &str, + raw: &Value, +) -> Option { + let window = raw.as_object()?; + let minutes = finite(window.get("windowDurationMins")) + .filter(|minutes| *minutes > 0.0) + .map(|minutes| minutes.round() as i64); + let (label, short) = window_labels(minutes); + let scoped = limit_id.filter(|id| *id != "codex"); + let scope = scoped.map(|id| limit_name.unwrap_or(id)); + Some(json!({ + "id": format!("{}.{slot}", scoped.unwrap_or("codex")), + "label": scope.map_or(label.clone(), |scope| format!("{label} ({scope})")), + "shortLabel": scope.map_or(short.clone(), |scope| format!("{short} {scope}")), + "windowMinutes": minutes, + "usedPercent": percent(finite(window.get("usedPercent"))), + "resetsAt": epoch_seconds_iso(window.get("resetsAt")), + "status": Value::Null, + })) +} + +fn used(window: &Value) -> f64 { + window + .get("usedPercent") + .and_then(Value::as_f64) + .unwrap_or(-1.0) +} + +/// Merge a sparse update: windows by id; null fields never clear a known value. +fn merge(previous: Option<&Value>, mut next: Value) -> Value { + let Some(previous) = previous.filter(|previous| previous["provider"] == next["provider"]) + else { + return next; + }; + let mut windows: Vec = previous["windows"].as_array().cloned().unwrap_or_default(); + for window in next["windows"].as_array().cloned().unwrap_or_default() { + if let Some(prior) = windows.iter_mut().find(|prior| prior["id"] == window["id"]) { + let mut merged = window.clone(); + for key in ["usedPercent", "resetsAt", "windowMinutes", "status"] { + if merged[key].is_null() { + merged[key] = prior[key].clone(); + } + } + *prior = merged; + } else { + windows.push(window); + } + } + windows.truncate(MAX_WINDOWS); + next["windows"] = Value::Array(windows); + if next["credits"].is_null() { + next["credits"] = previous["credits"].clone(); + } + next +} + +/// Normalize Codex `account/rateLimits/updated`, merged into the previous snapshot. +pub fn codex_rate_limit_snapshot( + params: &Value, + previous: Option<&Value>, + observed_at: &str, +) -> Option { + let snapshot = params.get("rateLimits")?.as_object()?; + let limit_id = bounded(snapshot.get("limitId"), 160); + let limit_name = bounded(snapshot.get("limitName"), 160); + let windows: Vec = ["primary", "secondary"] + .iter() + .filter_map(|slot| { + codex_window( + limit_id.as_deref(), + limit_name.as_deref(), + slot, + snapshot.get(*slot)?, + ) + }) + .collect(); + let credits = snapshot + .get("credits") + .and_then(Value::as_object) + .map(|credits| { + json!({ + "hasCredits": credits.get("hasCredits").and_then(Value::as_bool), + "unlimited": credits.get("unlimited").and_then(Value::as_bool), + "balance": bounded(credits.get("balance"), 40), + }) + }); + let reason = bounded(snapshot.get("rateLimitReachedType"), 160); + let mut merged = merge( + previous, + json!({ + "schema": PROVIDER_RATE_LIMITS_SCHEMA, + "provider": "openai", + "windows": windows, + "credits": credits, + "limitReached": Value::Null, + "observedAt": observed_at, + }), + ); + let windows = merged["windows"].as_array().cloned().unwrap_or_default(); + if windows.is_empty() && merged["credits"].is_null() && reason.is_none() { + return None; + } + // A retained window whose reset has passed no longer applies. + let at = iso_epoch(observed_at); + let current: Vec<&Value> = windows + .iter() + .filter(|window| match (iso_epoch_value(&window["resetsAt"]), at) { + (Some(reset), Some(at)) => reset > at, + _ => true, + }) + .collect(); + let fullest = current + .iter() + .max_by(|a, b| used(a).total_cmp(&used(b))) + .map(|window| (*window).clone()); + let exhausted = current + .iter() + .find(|window| used(window) >= 100.0) + .map(|window| (*window).clone()); + let reached = if reason.is_some() { + fullest + } else { + exhausted.clone() + }; + if reason.is_some() || exhausted.is_some() { + merged["limitReached"] = json!({ + "windowId": reached.as_ref().map(|window| window["id"].clone()), + "reason": reason.unwrap_or_else(|| "rate_limit_reached".to_owned()), + "resetsAt": reached.as_ref().map_or(Value::Null, |window| window["resetsAt"].clone()), + }); + } + Some(merged) +} + +/// Normalize Claude's sanitized `_claude/rateLimit` (SDK rate_limit_info). +/// Claude reports one window per event; the control plane merges by window. +pub fn claude_rate_limit_snapshot(info: &Value, observed_at: &str) -> Option { + let info = info.as_object()?; + let kind = bounded(info.get("rateLimitType"), 64).unwrap_or_else(|| "five_hour".to_owned()); + let (label, short, minutes): (String, String, Option) = match kind.as_str() { + "five_hour" => ("5-hour window".into(), "5h".into(), Some(300)), + "seven_day" => ("Weekly".into(), "7d".into(), Some(10_080)), + "seven_day_opus" => ("Weekly (Opus)".into(), "7d Opus".into(), Some(10_080)), + "seven_day_sonnet" => ("Weekly (Sonnet)".into(), "7d Sonnet".into(), Some(10_080)), + "seven_day_overage_included" => ( + "Weekly (with extra usage)".into(), + "7d".into(), + Some(10_080), + ), + "overage" => ("Extra usage".into(), "Extra".into(), None), + other => (other.replace('_', " "), other.to_owned(), None), + }; + let status = match info.get("status").and_then(Value::as_str) { + Some("allowed") => Some("allowed"), + Some("allowed_warning") => Some("warning"), + Some("rejected") => Some("rejected"), + _ => None, + }; + let utilization = finite(info.get("utilization")); + let used_percent = match (status, utilization) { + (Some("rejected"), None) => json!(100.0), + (_, value) => percent(value.map(|value| value * 100.0)), + }; + let id = format!("claude.{kind}"); + let resets_at = epoch_seconds_iso(info.get("resetsAt")); + let limit_reached = if status == Some("rejected") { + json!({"windowId": id, "reason": "rate_limit_reached", "resetsAt": resets_at}) + } else { + Value::Null + }; + Some(json!({ + "schema": PROVIDER_RATE_LIMITS_SCHEMA, + "provider": "anthropic", + "windows": [{ + "id": id, "label": label, "shortLabel": short, "windowMinutes": minutes, + "usedPercent": used_percent, "resetsAt": resets_at, "status": status, + }], + "credits": Value::Null, + "limitReached": limit_reached, + "observedAt": observed_at, + })) +} + +/// True when the content differs, ignoring the observation time. +pub fn rate_limit_snapshot_changed(previous: Option<&Value>, next: &Value) -> bool { + let strip = |value: &Value| { + let mut value = value.clone(); + if let Some(object) = value.as_object_mut() { + object.remove("observedAt"); + } + value + }; + previous.is_none_or(|previous| strip(previous) != strip(next)) +} + +fn summary(snapshot: &Value) -> String { + let harness = if snapshot["provider"] == "openai" { + "Codex" + } else { + "Claude" + }; + let windows = snapshot["windows"].as_array().cloned().unwrap_or_default(); + if let Some(reached) = snapshot["limitReached"].as_object() { + let label = windows + .iter() + .find(|window| Some(&window["id"]) == reached.get("windowId")) + .and_then(|window| window["label"].as_str()) + .map(|label| format!(" ({label})")) + .unwrap_or_default(); + let reset = reached + .get("resetsAt") + .and_then(Value::as_str) + .map(|at| format!("; resets {at}")) + .unwrap_or_default(); + return format!("{harness} rate limit reached{label}{reset}"); + } + let parts: Vec = windows + .iter() + .filter_map(|window| { + Some(format!( + "{} {}%", + window["label"].as_str()?, + window["usedPercent"].as_f64()? + )) + }) + .collect(); + if parts.is_empty() { + format!("{harness} rate limits updated") + } else { + format!("{harness} rate limits: {}", parts.join(", ")) + } +} + +/// The notice event carrying `snapshot` as one JSON detail (kept whole under the cap). +pub fn rate_limit_notice(snapshot: &Value, notice_id: &str) -> NormalizedProviderEvent { + let mut bounded_snapshot = snapshot.clone(); + let mut serialized = bounded_snapshot.to_string(); + while serialized.chars().count() > MAX_DETAIL_CHARS { + let Some(windows) = bounded_snapshot["windows"] + .as_array_mut() + .filter(|w| !w.is_empty()) + else { + break; + }; + windows.pop(); + serialized = bounded_snapshot.to_string(); + } + let mut payload = Map::new(); + payload.insert("schema".into(), json!("paperclip.provider.notice.v1")); + payload.insert("noticeId".into(), json!(notice_id)); + payload.insert( + "severity".into(), + json!(if snapshot["limitReached"].is_null() { + "info" + } else { + "warning" + }), + ); + payload.insert("category".into(), json!(PROVIDER_RATE_LIMITS_CATEGORY)); + payload.insert("scope".into(), json!("account")); + payload.insert("recoverable".into(), json!(true)); + payload.insert("userActionable".into(), json!(false)); + payload.insert( + "summary".into(), + json!(summary(snapshot).chars().take(4_000).collect::()), + ); + payload.insert( + "details".into(), + json!([{"name": "rateLimits", "value": serialized}]), + ); + NormalizedProviderEvent { + event_type: "provider.notice.recorded".to_owned(), + priority: EventPriority::P1, + payload: Value::Object(payload), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const T0: &str = "2026-11-05T15:00:00Z"; + const RESETS: i64 = 1_793_893_200; // 2026-11-05T15:40:00Z + + #[test] + fn codex_snapshot_merges_sparse_updates_and_marks_reached() { + let first = codex_rate_limit_snapshot( + &json!({"rateLimits": {"limitId": "codex", + "primary": {"usedPercent": 78, "windowDurationMins": 300, "resetsAt": RESETS}, + "secondary": {"usedPercent": 41, "windowDurationMins": 10080, "resetsAt": RESETS + 86_400}, + "credits": {"hasCredits": true, "unlimited": false, "balance": "12.40"}}}), + None, + T0, + ) + .unwrap(); + assert_eq!(first["windows"][0]["label"], "5-hour window"); + assert_eq!(first["windows"][0]["resetsAt"], "2026-11-05T15:40:00Z"); + assert_eq!(first["windows"][1]["shortLabel"], "7d"); + assert!(first["limitReached"].is_null()); + + let second = codex_rate_limit_snapshot( + &json!({"rateLimits": {"limitId": "codex", + "primary": {"usedPercent": 100, "windowDurationMins": null, "resetsAt": null}, + "rateLimitReachedType": "rate_limit_reached"}}), + Some(&first), + T0, + ) + .unwrap(); + assert_eq!(second["windows"][0]["usedPercent"], 100.0); + assert_eq!(second["windows"][0]["windowMinutes"], 300); + assert_eq!(second["windows"][1]["usedPercent"], 41.0); + assert_eq!(second["credits"]["balance"], "12.40"); + assert_eq!(second["limitReached"]["windowId"], "codex.primary"); + assert_eq!(second["limitReached"]["resetsAt"], "2026-11-05T15:40:00Z"); + assert!(rate_limit_snapshot_changed(Some(&first), &second)); + let mut same = second.clone(); + same["observedAt"] = json!("2026-11-05T16:00:00Z"); + assert!(!rate_limit_snapshot_changed(Some(&second), &same)); + } + + #[test] + fn codex_reached_window_skips_retained_expired_windows() { + let first = codex_rate_limit_snapshot( + &json!({"rateLimits": {"limitId": "codex", + "primary": {"usedPercent": 100, "windowDurationMins": 300, "resetsAt": RESETS}}}), + None, + T0, + ) + .unwrap(); + let later = codex_rate_limit_snapshot( + &json!({"rateLimits": {"limitId": "codex", + "secondary": {"usedPercent": 100, "windowDurationMins": 10080, "resetsAt": RESETS + 86_400}, + "rateLimitReachedType": "rate_limit_reached"}}), + Some(&first), + "2026-11-05T16:00:00Z", + ) + .unwrap(); + assert_eq!(later["limitReached"]["windowId"], "codex.secondary"); + } + + #[test] + fn codex_empty_update_is_ignored() { + assert!( + codex_rate_limit_snapshot(&json!({"rateLimits": {"primary": null}}), None, T0) + .is_none() + ); + assert!(codex_rate_limit_snapshot(&json!({}), None, T0).is_none()); + } + + #[test] + fn claude_snapshot_maps_utilization_and_rejection() { + let warn = claude_rate_limit_snapshot( + &json!({"status": "allowed_warning", "rateLimitType": "five_hour", "utilization": 0.86, "resetsAt": RESETS}), + T0, + ) + .unwrap(); + assert_eq!(warn["windows"][0]["usedPercent"], 86.0); + assert_eq!(warn["windows"][0]["status"], "warning"); + assert!(warn["limitReached"].is_null()); + let rejected = claude_rate_limit_snapshot( + &json!({"status": "rejected", "rateLimitType": "seven_day"}), + T0, + ) + .unwrap(); + assert_eq!(rejected["windows"][0]["usedPercent"], 100.0); + assert_eq!(rejected["limitReached"]["windowId"], "claude.seven_day"); + } + + #[test] + fn notice_carries_snapshot_as_json_detail() { + let snapshot = + claude_rate_limit_snapshot(&json!({"status": "allowed", "utilization": 0.5}), T0) + .unwrap(); + let event = rate_limit_notice(&snapshot, "turn-1:rate-limits"); + assert_eq!(event.event_type, "provider.notice.recorded"); + assert_eq!(event.payload["category"], PROVIDER_RATE_LIMITS_CATEGORY); + assert_eq!(event.payload["scope"], "account"); + assert_eq!( + event.payload["summary"], + "Claude rate limits: 5-hour window 50%" + ); + let detail: Value = + serde_json::from_str(event.payload["details"][0]["value"].as_str().unwrap()).unwrap(); + assert_eq!(detail, snapshot); + } +} diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_events.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_events.rs index d997bdc1399..be0143ab46c 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_events.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_events.rs @@ -737,6 +737,63 @@ fn leaves_operational_semantic_and_terminal_events_to_the_adapter() { assert!(normalize(AcpxRuntimeEventKind::Done, json!({"type":"done"})).is_empty()); } +#[test] +fn claude_rate_limit_on_usage_update_becomes_an_account_notice() { + let mut scope = AcpxEventScope::new("run-1").unwrap(); + scope.bind_turn("turn-1").unwrap(); + let decoded = decode_acpx_event( + &scope, + &AcpxSidecarEvent { + sequence: 1, + event_type: GeneratedAcpxSidecarEventType::RuntimeEvent, + run_id: Some("run-1".to_owned()), + turn_id: Some("turn-1".to_owned()), + payload: json!({ + "type":"status", "tag":"usage_update", + "rateLimit":{ + "status":"allowed_warning", "rateLimitType":"five_hour", + "utilization":0.86, "resetsAt":1_793_893_200 + } + }), + }, + ) + .unwrap(); + let AcpxEventPayload::Runtime { + kind, + tool_operation, + payload, + .. + } = decoded + else { + panic!("runtime usage must decode as a runtime payload"); + }; + let events = + normalize_acpx_runtime_event(kind, &payload, tool_operation, "event-7", "turn-1", 3); + assert_eq!(events.len(), 2); + assert_eq!(events[0].event_type, "usage.reported"); + let notice = &events[1]; + assert_eq!(notice.event_type, "provider.notice.recorded"); + assert_eq!(notice.payload["category"], "provider.rate_limits"); + assert_eq!(notice.payload["scope"], "account"); + assert_eq!(notice.payload["noticeId"], "event-7:rate-limits"); + let snapshot: serde_json::Value = + serde_json::from_str(notice.payload["details"][0]["value"].as_str().unwrap()).unwrap(); + assert_eq!(snapshot["provider"], "anthropic"); + assert_eq!(snapshot["windows"][0]["id"], "claude.five_hour"); + assert_eq!(snapshot["windows"][0]["usedPercent"], 86.0); + assert_eq!(snapshot["windows"][0]["resetsAt"], "2026-11-05T15:40:00Z"); + + let plain = normalize( + AcpxRuntimeEventKind::Status, + json!({"type":"status", "tag":"usage_update"}), + ); + assert_eq!( + plain.len(), + 1, + "usage without rate-limit data stays a single event" + ); +} + #[test] fn claude_status_notices_pair_by_key_and_keep_details() { let notice = |phase: &str| { diff --git a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts index 296f8ead98e..9246ce91048 100644 --- a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts +++ b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts @@ -1166,6 +1166,7 @@ function sanitizeRuntimeEvent(event: PiProjectedMessageEvent & used: safeNonNegativeNumber(event.used), size: safeNonNegativeNumber(event.size), ...safeUsage(event.cost, event.breakdown), + ...safeRateLimit(event), }); } if (event.type === "tool_call") { @@ -1300,6 +1301,28 @@ function sanitizeRuntimeStatus(value: unknown): Record { ); } +/** + * Claude forwards its SDK `rate_limit_event` as + * `usage_update._meta["_claude/rateLimit"]`. Pass only the window fields the + * rate-limit normalizer reads; billing and overage details stay behind. + */ +function safeRateLimit(event: unknown): Record { + const raw = record(event); + if (raw.tag !== "usage_update") return {}; + const info = record(record(raw._meta)["_claude/rateLimit"]); + if (Object.keys(info).length === 0) return {}; + const status = ["allowed", "allowed_warning", "rejected"].includes(String(info.status)) ? info.status : null; + const rateLimitType = typeof info.rateLimitType === "string" && /^[a-z0-9_]{1,64}$/.test(info.rateLimitType) + ? info.rateLimitType + : null; + const utilization = typeof info.utilization === "number" && Number.isFinite(info.utilization) && info.utilization >= 0 + ? Math.min(info.utilization, 10) + : null; + const resetsAt = safeNonNegativeNumber(info.resetsAt); + if (status === null && utilization === null) return {}; + return { rateLimit: { status, rateLimitType, utilization, resetsAt } }; +} + function safeUsage(cost: unknown, breakdown: unknown): Record { const nativeCost = record(cost); const nativeBreakdown = record( diff --git a/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts b/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts index b5a08e74639..406e8cffec3 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts @@ -95,6 +95,7 @@ import { import { workspaceRootsFor } from "../../tool-file-changes.js"; import { AcpxTurnControlLedger, parseAcpxTurnControl, type AcpxTurnControlMode } from "./turn-controls.js"; +import { claudeRateLimitSnapshot, rateLimitNoticeEvent, rateLimitSnapshotChanged, type ProviderRateLimitSnapshot } from "../provider-rate-limits.js"; import { acpxUsageEstimateNotice, persistedAcpxTurnUsage, persistedCursorUsageNotice } from "./usage-accounting.js"; const MAX_BUFFERED_EVENTS = 512; @@ -783,6 +784,8 @@ class CodexAcpxSession implements HarnessSession { turnId: string; } | null = null; #usage: Record | null = null; + #rateLimits: ProviderRateLimitSnapshot | null = null; + #rateLimitNoticeSequence = 0; #assistantText = ""; /** Claude compaction banners in this session; pairs start and end notices. */ #compactionCount = 0; @@ -1774,6 +1777,7 @@ class CodexAcpxSession implements HarnessSession { cumulative: event.breakdown, cost: event.cost, }); + this.#observeRateLimits(event as { _meta?: unknown }, turnId); } this.#workspaceRoots ??= workspaceRootsFor(this.#input.workingDirectory); for (const canonical of canonicalProviderEventsFromAcpxRuntimeEvent( @@ -1789,6 +1793,18 @@ class CodexAcpxSession implements HarnessSession { } } + /** Claude reports `rate_limit_event` as `usage_update._meta["_claude/rateLimit"]`. */ + #observeRateLimits(event: { _meta?: unknown }, turnId: string): void { + const next = claudeRateLimitSnapshot(event._meta, this.#rateLimits, this.#now().toISOString()); + if (!next) return; + if (rateLimitSnapshotChanged(this.#rateLimits, next)) { + const itemId = `${turnId}:rate-limits:${++this.#rateLimitNoticeSequence}`; + const notice = rateLimitNoticeEvent(next, itemId); + this.#emit(notice.eventType, notice.payload, { turnId, itemId }); + } + this.#rateLimits = next; + } + async #handleExtensionInput( turnId: string, input: AcpxExtensionInput, diff --git a/packages/paperclip-runner/src/drivers/codex/codex-session-notifications.ts b/packages/paperclip-runner/src/drivers/codex/codex-session-notifications.ts index 0138c28f605..b16d2c72cb0 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-session-notifications.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-session-notifications.ts @@ -1,4 +1,5 @@ import { observeCodexUsage, codexRunUsage } from "./codex-usage-baseline.js"; +import { codexRateLimitSnapshot, rateLimitNoticeEvent, rateLimitSnapshotChanged } from "../provider-rate-limits.js"; import { classifyCodexNotification } from "./codex-notification-identity.js"; import { paperclipWorkspaceFileReferencesFromText } from "../../live/workspace-file-reference.js"; import { canonicalProviderEventsFromCodex, isCanonicalProviderEventType } from "../../provider-events.js"; @@ -153,6 +154,20 @@ async function mapNotificationBody(state: CodexSessionState, notification: Codex if (params.runId === state.runId) state.emit("runtime.activity.recorded", record(params.payload)); return; } + if (notification.method === "account/rateLimits/updated") { + // Account-scoped: it names no thread or turn, so it bypasses thread binding. + const next = codexRateLimitSnapshot(record(notification.params), state.rateLimits, state.now().toISOString()); + if (next && rateLimitSnapshotChanged(state.rateLimits, next)) { + const itemId = `codex-rate-limits:${++state.rateLimitNoticeSequence}`; + const notice = rateLimitNoticeEvent(next, itemId); + state.emit(notice.eventType, notice.payload, { + ...(state.activeTurnId ? { turnId: state.activeTurnId } : {}), + itemId, + }); + } + if (next) state.rateLimits = next; + return; + } if (!isSupportedCodexNotificationMethod(notification.method)) return; if (notification.method === "item/completed" && notification.params.kind === "steering_acknowledgement" && !notification.params.threadId && !notification.params.turnId && !notification.params.thread && !notification.params.turn) return; diff --git a/packages/paperclip-runner/src/drivers/codex/codex-session-state.ts b/packages/paperclip-runner/src/drivers/codex/codex-session-state.ts index c2aaa48d4bb..a9ace1defee 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-session-state.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-session-state.ts @@ -1,4 +1,5 @@ import { type CodexUsageBaseline, codexRunUsage } from "./codex-usage-baseline.js"; +import type { ProviderRateLimitSnapshot } from "../provider-rate-limits.js"; import { workspaceRootsFor } from "../../tool-file-changes.js"; import type { HarnessRuntimeRequest, @@ -118,6 +119,9 @@ export class CodexSessionState { activeTurnId: string | null; usageSnapshot: Record | null = null; codexUsageBaseline: CodexUsageBaseline | null = null; + /** Latest account rate-limit snapshot, merged across sparse updates. */ + rateLimits: ProviderRateLimitSnapshot | null = null; + rateLimitNoticeSequence = 0; result: PrpStructuredRunResult | null = null; resultFingerprint: string | null = null; readonly normalizeReplayPayload?: HarnessSessionRecoveryOptions["normalizeReplayPayload"]; diff --git a/packages/paperclip-runner/src/drivers/provider-rate-limits.test.ts b/packages/paperclip-runner/src/drivers/provider-rate-limits.test.ts new file mode 100644 index 00000000000..f0d740ddfb7 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/provider-rate-limits.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from "vitest"; +import { + PROVIDER_RATE_LIMITS_CATEGORY, + claudeRateLimitSnapshot, + codexRateLimitSnapshot, + rateLimitNoticeEvent, + rateLimitSnapshotChanged, +} from "./provider-rate-limits.js"; + +const T0 = "2026-11-05T15:00:00.000Z"; +const resetsAt = Date.parse("2026-11-05T15:40:00.000Z") / 1000; + +describe("codexRateLimitSnapshot", () => { + it("normalizes primary and secondary windows, credits and reset times", () => { + const snapshot = codexRateLimitSnapshot({ + rateLimits: { + limitId: "codex", limitName: null, normalModelSlug: null, + primary: { usedPercent: 78, windowDurationMins: 300, resetsAt }, + secondary: { usedPercent: 41, windowDurationMins: 10080, resetsAt: resetsAt + 86400 }, + credits: { hasCredits: true, unlimited: false, balance: "12.40" }, + individualLimit: null, spendControlReached: null, planType: "pro", rateLimitReachedType: null, + }, + }, null, T0); + expect(snapshot).toMatchObject({ + provider: "openai", + credits: { hasCredits: true, unlimited: false, balance: "12.40" }, + limitReached: null, + windows: [ + { id: "codex.primary", label: "5-hour window", shortLabel: "5h", usedPercent: 78, resetsAt: "2026-11-05T15:40:00.000Z" }, + { id: "codex.secondary", label: "Weekly", shortLabel: "7d", usedPercent: 41 }, + ], + }); + }); + + it("merges a sparse update without clearing known windows", () => { + const first = codexRateLimitSnapshot({ rateLimits: { + limitId: "codex", primary: { usedPercent: 40, windowDurationMins: 300, resetsAt }, + secondary: { usedPercent: 10, windowDurationMins: 10080, resetsAt: resetsAt + 86400 }, credits: null, + } }, null, T0); + const second = codexRateLimitSnapshot({ rateLimits: { + limitId: "codex", primary: { usedPercent: 55, windowDurationMins: null, resetsAt: null }, secondary: null, credits: null, + } }, first, T0); + expect(second?.windows.map((window) => [window.id, window.usedPercent, window.windowMinutes, window.resetsAt])).toEqual([ + ["codex.primary", 55, 300, "2026-11-05T15:40:00.000Z"], + ["codex.secondary", 10, 10080, "2026-11-06T15:40:00.000Z"], + ]); + }); + + it("marks the fullest window reached when Codex reports a reached type", () => { + const snapshot = codexRateLimitSnapshot({ rateLimits: { + limitId: "codex", primary: { usedPercent: 100, windowDurationMins: 300, resetsAt }, + secondary: { usedPercent: 70, windowDurationMins: 10080, resetsAt: resetsAt + 86400 }, + rateLimitReachedType: "rate_limit_reached", + } }, null, T0); + expect(snapshot?.limitReached).toEqual({ + windowId: "codex.primary", reason: "rate_limit_reached", resetsAt: "2026-11-05T15:40:00.000Z", + }); + }); + + it("ignores a retained window whose reset has passed when picking the reached one", () => { + const first = codexRateLimitSnapshot({ rateLimits: { + limitId: "codex", primary: { usedPercent: 100, windowDurationMins: 300, resetsAt }, + } }, null, T0); + const later = codexRateLimitSnapshot({ rateLimits: { + limitId: "codex", secondary: { usedPercent: 100, windowDurationMins: 10080, resetsAt: resetsAt + 86400 }, + rateLimitReachedType: "rate_limit_reached", + } }, first, "2026-11-05T16:00:00.000Z"); + expect(later?.limitReached?.windowId).toBe("codex.secondary"); + }); + + it("returns null for an empty update", () => { + expect(codexRateLimitSnapshot({ rateLimits: { limitId: "codex", primary: null, secondary: null } }, null, T0)).toBeNull(); + expect(codexRateLimitSnapshot({}, null, T0)).toBeNull(); + }); +}); + +describe("claudeRateLimitSnapshot", () => { + it("maps utilization (0..1) per rate-limit type and merges types", () => { + const five = claudeRateLimitSnapshot({ "_claude/rateLimit": { + status: "allowed_warning", rateLimitType: "five_hour", utilization: 0.86, resetsAt, + } }, null, T0); + const week = claudeRateLimitSnapshot({ "_claude/rateLimit": { + status: "allowed", rateLimitType: "seven_day", utilization: 0.63, + } }, five, T0); + expect(week?.windows).toEqual([ + expect.objectContaining({ id: "claude.five_hour", label: "5-hour window", usedPercent: 86, status: "warning" }), + expect.objectContaining({ id: "claude.seven_day", label: "Weekly", usedPercent: 63, status: "allowed" }), + ]); + expect(week?.limitReached).toBeNull(); + }); + + it("reports a rejected window as reached even without utilization", () => { + const snapshot = claudeRateLimitSnapshot({ "_claude/rateLimit": { + status: "rejected", rateLimitType: "five_hour", resetsAt, + } }, null, T0); + expect(snapshot?.windows[0]?.usedPercent).toBe(100); + expect(snapshot?.limitReached).toEqual({ + windowId: "claude.five_hour", reason: "rate_limit_reached", resetsAt: "2026-11-05T15:40:00.000Z", + }); + }); + + it("ignores usage updates without rate-limit metadata", () => { + expect(claudeRateLimitSnapshot(undefined, null, T0)).toBeNull(); + expect(claudeRateLimitSnapshot({ usage: {} }, null, T0)).toBeNull(); + }); +}); + +describe("rateLimitNoticeEvent", () => { + it("emits an account-scoped notice carrying the snapshot as one JSON detail", () => { + const snapshot = codexRateLimitSnapshot({ rateLimits: { + limitId: "codex", primary: { usedPercent: 78, windowDurationMins: 300, resetsAt }, + } }, null, T0)!; + const event = rateLimitNoticeEvent(snapshot, "codex-rate-limits:1"); + expect(event.eventType).toBe("provider.notice.recorded"); + expect(event.payload).toMatchObject({ + schema: "paperclip.provider.notice.v1", category: PROVIDER_RATE_LIMITS_CATEGORY, + scope: "account", severity: "info", summary: "Codex rate limits: 5-hour window 78%", + }); + const details = event.payload.details as Array<{ name: string; value: string }>; + expect(JSON.parse(details[0]!.value)).toEqual(snapshot); + }); + + it("only reports a change when the content differs", () => { + const a = codexRateLimitSnapshot({ rateLimits: { primary: { usedPercent: 5, windowDurationMins: 300, resetsAt } } }, null, T0)!; + const b = codexRateLimitSnapshot({ rateLimits: { primary: { usedPercent: 5, windowDurationMins: 300, resetsAt } } }, a, "2026-11-05T15:05:00.000Z")!; + expect(rateLimitSnapshotChanged(a, b)).toBe(false); + const c = codexRateLimitSnapshot({ rateLimits: { primary: { usedPercent: 6 } } }, b, T0)!; + expect(rateLimitSnapshotChanged(b, c)).toBe(true); + }); +}); diff --git a/packages/paperclip-runner/src/drivers/provider-rate-limits.ts b/packages/paperclip-runner/src/drivers/provider-rate-limits.ts new file mode 100644 index 00000000000..c3fba695f4e --- /dev/null +++ b/packages/paperclip-runner/src/drivers/provider-rate-limits.ts @@ -0,0 +1,251 @@ +import type { CanonicalProviderEvent } from "../provider-events.js"; + +/** + * Provider rate-limit windows reported by a harness, normalized into one + * account-scoped snapshot and carried on a `provider.notice.recorded` event + * (category {@link PROVIDER_RATE_LIMITS_CATEGORY}). The server keeps the latest + * snapshot per agent; the transcript treats it as run-log only. + * + * Sources: + * - Codex app-server `account/rateLimits/updated` (sparse RateLimitSnapshot: + * primary/secondary windows, credits, rateLimitReachedType). + * - Claude `usage_update._meta["_claude/rateLimit"]` (SDK rate_limit_event: + * one window per event, utilization 0..1, status). + */ +export const PROVIDER_RATE_LIMITS_CATEGORY = "provider.rate_limits"; +export const PROVIDER_RATE_LIMITS_SCHEMA = "paperclip.provider.rate_limits.v1"; +export const PROVIDER_RATE_LIMITS_DETAIL = "rateLimits"; + +export interface ProviderRateLimitWindow { + id: string; + label: string; + shortLabel: string; + windowMinutes: number | null; + usedPercent: number | null; + resetsAt: string | null; + status: "allowed" | "warning" | "rejected" | null; +} + +export interface ProviderRateLimitSnapshot { + schema: typeof PROVIDER_RATE_LIMITS_SCHEMA; + provider: "openai" | "anthropic"; + windows: ProviderRateLimitWindow[]; + credits: { hasCredits: boolean | null; unlimited: boolean | null; balance: string | null } | null; + limitReached: { windowId: string | null; reason: string; resetsAt: string | null } | null; + observedAt: string; +} + +type Obj = Record; +const obj = (value: unknown): Obj | null => + value !== null && typeof value === "object" && !Array.isArray(value) ? (value as Obj) : null; +const num = (value: unknown): number | null => + typeof value === "number" && Number.isFinite(value) ? value : null; +const str = (value: unknown, max = 160): string | null => + typeof value === "string" && value.length > 0 ? value.slice(0, max) : null; +const bool = (value: unknown): boolean | null => (typeof value === "boolean" ? value : null); + +function epochSecondsIso(value: unknown): string | null { + const seconds = num(value); + if (seconds === null || seconds <= 0) return null; + const date = new Date(seconds * 1000); + return Number.isFinite(date.getTime()) ? date.toISOString() : null; +} + +function percent(value: number | null): number | null { + if (value === null) return null; + return Math.round(Math.min(1000, Math.max(0, value)) * 10) / 10; +} + +export function windowLabels(minutes: number | null): { label: string; shortLabel: string } { + if (minutes === null) return { label: "Usage window", shortLabel: "Usage" }; + if (minutes % 10080 === 0) { + const weeks = minutes / 10080; + return weeks === 1 ? { label: "Weekly", shortLabel: "7d" } : { label: `${weeks}-week window`, shortLabel: `${weeks * 7}d` }; + } + if (minutes % 1440 === 0) { + const days = minutes / 1440; + return { label: days === 1 ? "Daily" : `${days}-day window`, shortLabel: `${days}d` }; + } + if (minutes % 60 === 0) { + const hours = minutes / 60; + return { label: `${hours}-hour window`, shortLabel: `${hours}h` }; + } + return { label: `${minutes}-minute window`, shortLabel: `${minutes}m` }; +} + +/** Merge a sparse update: windows by id, null fields never clear a known value. */ +export function mergeRateLimitSnapshot( + previous: ProviderRateLimitSnapshot | null, + next: ProviderRateLimitSnapshot, +): ProviderRateLimitSnapshot { + if (!previous || previous.provider !== next.provider) return next; + const windows = new Map(previous.windows.map((window) => [window.id, window])); + for (const window of next.windows) { + const prior = windows.get(window.id); + windows.set(window.id, prior ? { + ...window, + usedPercent: window.usedPercent ?? prior.usedPercent, + resetsAt: window.resetsAt ?? prior.resetsAt, + windowMinutes: window.windowMinutes ?? prior.windowMinutes, + status: window.status ?? prior.status, + } : window); + } + return { + ...next, + windows: [...windows.values()].slice(0, 8), + credits: next.credits ?? previous.credits, + }; +} + +function codexWindow(limitId: string | null, limitName: string | null, slot: "primary" | "secondary", raw: unknown): ProviderRateLimitWindow | null { + const window = obj(raw); + if (!window) return null; + const minutes = num(window.windowDurationMins); + const labels = windowLabels(minutes !== null && minutes > 0 ? Math.round(minutes) : null); + const scoped = limitId && limitId !== "codex"; + const scope = scoped ? (limitName ?? limitId) : null; + return { + id: `${scoped ? limitId : "codex"}.${slot}`, + label: scope ? `${labels.label} (${scope})` : labels.label, + shortLabel: scope ? `${labels.shortLabel} ${scope}` : labels.shortLabel, + windowMinutes: minutes !== null && minutes > 0 ? Math.round(minutes) : null, + usedPercent: percent(num(window.usedPercent)), + resetsAt: epochSecondsIso(window.resetsAt), + status: null, + }; +} + +/** Normalize Codex `account/rateLimits/updated` params, merged into the previous snapshot. */ +export function codexRateLimitSnapshot( + params: Obj, + previous: ProviderRateLimitSnapshot | null, + observedAt: string, +): ProviderRateLimitSnapshot | null { + const snapshot = obj(params.rateLimits); + if (!snapshot) return null; + const limitId = str(snapshot.limitId); + const limitName = str(snapshot.limitName); + const windows = (["primary", "secondary"] as const) + .map((slot) => codexWindow(limitId, limitName, slot, snapshot[slot])) + .filter((window): window is ProviderRateLimitWindow => window !== null); + const creditsRaw = obj(snapshot.credits); + const credits = creditsRaw + ? { hasCredits: bool(creditsRaw.hasCredits), unlimited: bool(creditsRaw.unlimited), balance: str(creditsRaw.balance, 40) } + : null; + const reason = str(snapshot.rateLimitReachedType); + const merged = mergeRateLimitSnapshot(previous, { + schema: PROVIDER_RATE_LIMITS_SCHEMA, + provider: "openai", + windows, + credits, + limitReached: null, + observedAt, + }); + if (merged.windows.length === 0 && !merged.credits && !reason) return null; + // The reached type names no window; attribute it to the fullest current one. + // A retained window whose reset has passed no longer applies. + const at = Date.parse(observedAt); + const current = merged.windows.filter((window) => !window.resetsAt || Date.parse(window.resetsAt) > at); + const fullest = [...current].sort((a, b) => (b.usedPercent ?? -1) - (a.usedPercent ?? -1))[0] ?? null; + const exhausted = current.find((window) => (window.usedPercent ?? 0) >= 100) ?? null; + const reachedWindow = reason ? fullest : exhausted; + merged.limitReached = reason || exhausted + ? { windowId: reachedWindow?.id ?? null, reason: reason ?? "rate_limit_reached", resetsAt: reachedWindow?.resetsAt ?? null } + : null; + return merged; +} + +const CLAUDE_WINDOWS: Record = { + five_hour: { label: "5-hour window", shortLabel: "5h", minutes: 300 }, + seven_day: { label: "Weekly", shortLabel: "7d", minutes: 10080 }, + seven_day_opus: { label: "Weekly (Opus)", shortLabel: "7d Opus", minutes: 10080 }, + seven_day_sonnet: { label: "Weekly (Sonnet)", shortLabel: "7d Sonnet", minutes: 10080 }, + seven_day_overage_included: { label: "Weekly (with extra usage)", shortLabel: "7d", minutes: 10080 }, + overage: { label: "Extra usage", shortLabel: "Extra", minutes: null }, +}; + +/** Normalize Claude's `_claude/rateLimit` (SDK rate_limit_info), merged into the previous snapshot. */ +export function claudeRateLimitSnapshot( + meta: unknown, + previous: ProviderRateLimitSnapshot | null, + observedAt: string, +): ProviderRateLimitSnapshot | null { + const info = obj(obj(meta)?.["_claude/rateLimit"]); + if (!info) return null; + const type = str(info.rateLimitType) ?? "five_hour"; + const known = CLAUDE_WINDOWS[type] ?? { label: type.replaceAll("_", " "), shortLabel: type, minutes: null }; + const rawStatus = str(info.status); + const status = rawStatus === "allowed" ? "allowed" : rawStatus === "allowed_warning" ? "warning" : rawStatus === "rejected" ? "rejected" : null; + const utilization = num(info.utilization); + const window: ProviderRateLimitWindow = { + id: `claude.${type}`, + label: known.label, + shortLabel: known.shortLabel, + windowMinutes: known.minutes, + usedPercent: status === "rejected" && utilization === null ? 100 : percent(utilization === null ? null : utilization * 100), + resetsAt: epochSecondsIso(info.resetsAt), + status, + }; + const merged = mergeRateLimitSnapshot(previous, { + schema: PROVIDER_RATE_LIMITS_SCHEMA, + provider: "anthropic", + windows: [window], + credits: null, + limitReached: null, + observedAt, + }); + const rejected = merged.windows.find((candidate) => candidate.status === "rejected") ?? null; + merged.limitReached = rejected + ? { windowId: rejected.id, reason: "rate_limit_reached", resetsAt: rejected.resetsAt } + : null; + return merged; +} + +function fingerprint(snapshot: ProviderRateLimitSnapshot): string { + const { observedAt: _observedAt, ...rest } = snapshot; + return JSON.stringify(rest); +} + +export function rateLimitSnapshotChanged( + previous: ProviderRateLimitSnapshot | null, + next: ProviderRateLimitSnapshot, +): boolean { + return !previous || fingerprint(previous) !== fingerprint(next); +} + +function summary(snapshot: ProviderRateLimitSnapshot): string { + const harness = snapshot.provider === "openai" ? "Codex" : "Claude"; + if (snapshot.limitReached) { + const window = snapshot.windows.find((candidate) => candidate.id === snapshot.limitReached?.windowId); + return `${harness} rate limit reached${window ? ` (${window.label})` : ""}${snapshot.limitReached.resetsAt ? `; resets ${snapshot.limitReached.resetsAt}` : ""}`; + } + const parts = snapshot.windows + .filter((window) => window.usedPercent !== null) + .map((window) => `${window.label} ${window.usedPercent}%`); + return parts.length ? `${harness} rate limits: ${parts.join(", ")}` : `${harness} rate limits updated`; +} + +export function rateLimitNoticeEvent(snapshot: ProviderRateLimitSnapshot, itemId: string): CanonicalProviderEvent { + // Notice detail values are capped at 4000 characters; keep the JSON whole. + const bounded = { ...snapshot, windows: [...snapshot.windows] }; + let serialized = JSON.stringify(bounded); + while (serialized.length > 4000 && bounded.windows.length > 0) { + bounded.windows.pop(); + serialized = JSON.stringify(bounded); + } + return { + eventType: "provider.notice.recorded", + itemId, + payload: { + schema: "paperclip.provider.notice.v1", + noticeId: itemId, + severity: snapshot.limitReached ? "warning" : "info", + category: PROVIDER_RATE_LIMITS_CATEGORY, + scope: "account", + recoverable: true, + userActionable: false, + summary: summary(snapshot).slice(0, 4000), + details: [{ name: PROVIDER_RATE_LIMITS_DETAIL, value: serialized }], + }, + }; +} diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 7d0b9cecae2..95131d69d51 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -954,6 +954,7 @@ export const LIVE_EVENT_TYPES = [ "heartbeat.run.activity", "agent.session.goal.changed", "agent.status", + "agent.runtime_state.updated", "activity.logged", "external_object.updated", "plugin.ui.updated", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index becaa5ec7d7..d80124a0136 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2888,3 +2888,4 @@ export { export { readProviderFailure, type ProviderFailure } from "./provider-failure.generated.js"; export type { BoardBootstrap, BoardBootstrapSession } from "./types/board-bootstrap.js"; export { readBoardBootstrap } from "./board-bootstrap.js"; +export * from "./provider-rate-limits.js"; diff --git a/packages/shared/src/provider-rate-limits.test.ts b/packages/shared/src/provider-rate-limits.test.ts new file mode 100644 index 00000000000..ee920b9c3a7 --- /dev/null +++ b/packages/shared/src/provider-rate-limits.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; +import { + mergeProviderRateLimitSnapshots, + parseProviderRateLimitSnapshot, + providerRateLimitSnapshotFromNotice, + summarizeProviderRateLimits, + type ProviderRateLimitSnapshot, +} from "./provider-rate-limits.js"; + +const NOW = Date.parse("2026-11-05T15:18:00.000Z"); +const base: ProviderRateLimitSnapshot = { + schema: "paperclip.provider.rate_limits.v1", + provider: "openai", + windows: [ + { id: "codex.primary", label: "5-hour window", shortLabel: "5h", windowMinutes: 300, usedPercent: 78, resetsAt: "2026-11-05T15:40:00.000Z", status: null }, + { id: "codex.secondary", label: "Weekly", shortLabel: "7d", windowMinutes: 10080, usedPercent: 41, resetsAt: "2026-11-09T09:00:00.000Z", status: null }, + ], + credits: null, + limitReached: null, + observedAt: "2026-11-05T15:17:00.000Z", +}; + +describe("provider rate limits", () => { + it("summarizes the fullest current window and its tone", () => { + expect(summarizeProviderRateLimits(base, NOW)).toMatchObject({ tone: "normal", limited: false, window: { id: "codex.primary" } }); + const warm = { ...base, windows: [{ ...base.windows[0]!, usedPercent: 86 }, base.windows[1]!] }; + expect(summarizeProviderRateLimits(warm, NOW)?.tone).toBe("warn"); + }); + + it("drops windows whose reset has passed", () => { + const later = Date.parse("2026-11-05T16:00:00.000Z"); + expect(summarizeProviderRateLimits(base, later)?.window?.id).toBe("codex.secondary"); + expect(summarizeProviderRateLimits(base, Date.parse("2026-11-10T00:00:00.000Z"))).toBeNull(); + }); + + it("reports the limited state until the reset", () => { + const limited = { ...base, limitReached: { windowId: "codex.primary", reason: "rate_limit_reached", resetsAt: "2026-11-05T15:40:00.000Z" } }; + expect(summarizeProviderRateLimits(limited, NOW)).toMatchObject({ tone: "reached", limited: true, limitedUntil: "2026-11-05T15:40:00.000Z" }); + expect(summarizeProviderRateLimits(limited, Date.parse("2026-11-05T15:41:00.000Z"))?.limited).toBe(false); + }); + + it("treats a used-up window with credits as working on credits, not limited", () => { + const onCredits = { + ...base, + windows: [{ ...base.windows[1]!, id: "codex.primary", usedPercent: 100 }], + credits: { hasCredits: true, unlimited: false, balance: null }, + limitReached: { windowId: "codex.primary", reason: "rate_limit_reached", resetsAt: "2026-11-09T09:00:00.000Z" }, + }; + expect(summarizeProviderRateLimits(onCredits, NOW)).toMatchObject({ tone: "warn", limited: false, usingCredits: true }); + }); + + it("reads the snapshot from a rate-limit notice and rejects other shapes", () => { + const payload = { + schema: "paperclip.provider.notice.v1", category: "provider.rate_limits", + details: [{ name: "rateLimits", value: JSON.stringify(base) }], + }; + expect(providerRateLimitSnapshotFromNotice("provider.notice.recorded", payload)).toEqual(base); + expect(providerRateLimitSnapshotFromNotice("provider.notice.recorded", { ...payload, category: "warning" })).toBeNull(); + expect(providerRateLimitSnapshotFromNotice("provider.notice.recorded", { ...payload, details: [{ name: "rateLimits", value: "{" }] })).toBeNull(); + expect(parseProviderRateLimitSnapshot({ ...base, provider: "google" })).toBeNull(); + }); + + it("keeps known fields and another window's current rejection when merging", () => { + const rejected: ProviderRateLimitSnapshot = { + ...base, + provider: "anthropic", + windows: [{ id: "claude.five_hour", label: "5-hour window", shortLabel: "5h", windowMinutes: 300, usedPercent: 100, resetsAt: "2026-11-05T15:40:00.000Z", status: "rejected" }], + limitReached: { windowId: "claude.five_hour", reason: "rate_limit_reached", resetsAt: "2026-11-05T15:40:00.000Z" }, + }; + const weekly: ProviderRateLimitSnapshot = { + ...rejected, + windows: [{ id: "claude.seven_day", label: "Weekly", shortLabel: "7d", windowMinutes: 10080, usedPercent: 40, resetsAt: null, status: "allowed" }], + limitReached: null, + observedAt: "2026-11-05T15:20:00.000Z", + }; + const merged = mergeProviderRateLimitSnapshots(rejected, weekly); + expect(merged.limitReached?.windowId).toBe("claude.five_hour"); + const sparse = { ...weekly, windows: [{ ...rejected.windows[0]!, usedPercent: null, resetsAt: null, status: "warning" as const }], observedAt: "2026-11-05T15:21:00.000Z" }; + const again = mergeProviderRateLimitSnapshots(merged, sparse); + expect(again.windows.find((w) => w.id === "claude.five_hour")).toMatchObject({ usedPercent: 100, resetsAt: "2026-11-05T15:40:00.000Z", status: "warning" }); + expect(again.limitReached).toBeNull(); + }); + + it("merges by window and ignores out-of-order snapshots", () => { + const next = { ...base, windows: [{ ...base.windows[0]!, usedPercent: 90 }], observedAt: "2026-11-05T15:20:00.000Z" }; + expect(mergeProviderRateLimitSnapshots(base, next).windows.map((w) => w.usedPercent)).toEqual([90, 41]); + expect(mergeProviderRateLimitSnapshots(next, base)).toBe(next); + }); +}); diff --git a/packages/shared/src/provider-rate-limits.ts b/packages/shared/src/provider-rate-limits.ts new file mode 100644 index 00000000000..fbb688f59dd --- /dev/null +++ b/packages/shared/src/provider-rate-limits.ts @@ -0,0 +1,209 @@ +/** + * Latest provider rate-limit windows an agent's harness reported (Codex + * `account/rateLimits/updated`, Claude `rate_limit_event`). The runner carries a + * snapshot as one JSON detail on a `provider.notice.recorded` event with + * category {@link PROVIDER_RATE_LIMITS_NOTICE_CATEGORY}; the server keeps the + * merged latest snapshot in `agent_runtime_state.state_json` under + * {@link PROVIDER_RATE_LIMITS_STATE_KEY}. + */ +export const PROVIDER_RATE_LIMITS_NOTICE_CATEGORY = "provider.rate_limits"; +export const PROVIDER_RATE_LIMITS_SCHEMA = "paperclip.provider.rate_limits.v1"; +export const PROVIDER_RATE_LIMITS_NOTICE_DETAIL = "rateLimits"; +export const PROVIDER_RATE_LIMITS_STATE_KEY = "providerRateLimits"; +export const PROVIDER_RATE_LIMIT_WARN_PERCENT = 80; + +export type ProviderRateLimitProvider = "openai" | "anthropic"; + +export interface ProviderRateLimitWindow { + id: string; + /** "5-hour window", "Weekly", "Weekly (Opus)" */ + label: string; + /** "5h", "7d" */ + shortLabel: string; + windowMinutes: number | null; + usedPercent: number | null; + resetsAt: string | null; + status: "allowed" | "warning" | "rejected" | null; +} + +export interface ProviderRateLimitSnapshot { + schema: typeof PROVIDER_RATE_LIMITS_SCHEMA; + provider: ProviderRateLimitProvider; + windows: ProviderRateLimitWindow[]; + credits: { hasCredits: boolean | null; unlimited: boolean | null; balance: string | null } | null; + limitReached: { windowId: string | null; reason: string; resetsAt: string | null } | null; + observedAt: string; +} + +type Obj = Record; +const obj = (value: unknown): Obj | null => + value !== null && typeof value === "object" && !Array.isArray(value) ? (value as Obj) : null; +const str = (value: unknown, max = 160): string | null => + typeof value === "string" && value.length > 0 ? value.slice(0, max) : null; +const num = (value: unknown): number | null => + typeof value === "number" && Number.isFinite(value) ? value : null; +const iso = (value: unknown): string | null => { + const text = str(value, 64); + return text && Number.isFinite(Date.parse(text)) ? text : null; +}; +const bool = (value: unknown): boolean | null => (typeof value === "boolean" ? value : null); + +/** Validate an untrusted snapshot (from a run event or stored state). */ +export function parseProviderRateLimitSnapshot(value: unknown): ProviderRateLimitSnapshot | null { + const raw = obj(value); + if (!raw || raw.schema !== PROVIDER_RATE_LIMITS_SCHEMA) return null; + if (raw.provider !== "openai" && raw.provider !== "anthropic") return null; + const observedAt = iso(raw.observedAt); + if (!observedAt || !Array.isArray(raw.windows)) return null; + const windows: ProviderRateLimitWindow[] = []; + for (const entry of raw.windows.slice(0, 8)) { + const window = obj(entry); + const id = str(window?.id); + const label = str(window?.label); + if (!window || !id || !label) continue; + const status = window.status === "allowed" || window.status === "warning" || window.status === "rejected" ? window.status : null; + const usedPercent = num(window.usedPercent); + const windowMinutes = num(window.windowMinutes); + windows.push({ + id, + label, + shortLabel: str(window.shortLabel, 40) ?? label, + windowMinutes: windowMinutes !== null && windowMinutes > 0 ? windowMinutes : null, + usedPercent: usedPercent === null ? null : Math.min(1000, Math.max(0, usedPercent)), + resetsAt: iso(window.resetsAt), + status, + }); + } + const credits = obj(raw.credits); + const reached = obj(raw.limitReached); + return { + schema: PROVIDER_RATE_LIMITS_SCHEMA, + provider: raw.provider, + windows, + credits: credits + ? { hasCredits: bool(credits.hasCredits), unlimited: bool(credits.unlimited), balance: str(credits.balance, 40) } + : null, + limitReached: reached + ? { windowId: str(reached.windowId), reason: str(reached.reason) ?? "rate_limit_reached", resetsAt: iso(reached.resetsAt) } + : null, + observedAt, + }; +} + +/** The snapshot carried by a rate-limit `provider.notice.recorded` payload, if any. */ +export function providerRateLimitSnapshotFromNotice( + eventType: string, + payload: Record, +): ProviderRateLimitSnapshot | null { + if (eventType !== "provider.notice.recorded") return null; + if (payload.schema !== "paperclip.provider.notice.v1" || payload.category !== PROVIDER_RATE_LIMITS_NOTICE_CATEGORY) return null; + const details = Array.isArray(payload.details) ? payload.details : []; + const detail = details.map(obj).find((entry) => entry?.name === PROVIDER_RATE_LIMITS_NOTICE_DETAIL); + if (typeof detail?.value !== "string") return null; + try { + return parseProviderRateLimitSnapshot(JSON.parse(detail.value)); + } catch { + return null; + } +} + +export function isProviderRateLimitNotice(eventType: string, payload: Record): boolean { + return eventType === "provider.notice.recorded" + && payload.schema === "paperclip.provider.notice.v1" + && payload.category === PROVIDER_RATE_LIMITS_NOTICE_CATEGORY; +} + +/** + * Merge a newer snapshot into the stored one. Runners already merge within a + * session; this keeps windows another session reported and ignores + * out-of-order deliveries. + */ +export function mergeProviderRateLimitSnapshots( + stored: ProviderRateLimitSnapshot | null, + next: ProviderRateLimitSnapshot, +): ProviderRateLimitSnapshot { + if (!stored || stored.provider !== next.provider) return next; + const at = Date.parse(next.observedAt); + if (at < Date.parse(stored.observedAt)) return stored; + // Claude reports one window per event and Codex updates are sparse: a null + // field never clears a known value. + const windows = new Map(stored.windows.map((window) => [window.id, window])); + for (const window of next.windows) { + const prior = windows.get(window.id); + windows.set(window.id, prior ? { + ...window, + usedPercent: window.usedPercent ?? prior.usedPercent, + resetsAt: window.resetsAt ?? prior.resetsAt, + windowMinutes: window.windowMinutes ?? prior.windowMinutes, + status: window.status ?? prior.status, + } : window); + } + // A report about another window does not clear a still-current rejection. + const reportedIds = new Set(next.windows.map((window) => window.id)); + const storedReached = stored.limitReached; + const keepStoredReached = storedReached !== null + && storedReached.windowId !== null + && !reportedIds.has(storedReached.windowId) + && (storedReached.resetsAt === null || Date.parse(storedReached.resetsAt) > at); + return { + ...next, + windows: [...windows.values()].slice(0, 8), + credits: next.credits ?? stored.credits, + limitReached: next.limitReached ?? (keepStoredReached ? storedReached : null), + }; +} + +export function readProviderRateLimits(stateJson: Record | null | undefined): ProviderRateLimitSnapshot | null { + return parseProviderRateLimitSnapshot(stateJson?.[PROVIDER_RATE_LIMITS_STATE_KEY]); +} + +export interface ProviderRateLimitSummary { + tone: "normal" | "warn" | "reached"; + /** The window the one-line summary describes. */ + window: ProviderRateLimitWindow | null; + /** Set while the provider is refusing work and the reset is still ahead. */ + limitedUntil: string | null; + limited: boolean; + /** + * The provider reports a window as used up but the account has credits, so + * work continues on credits (Codex) instead of waiting for the reset. + */ + usingCredits: boolean; + /** Windows whose reset has not passed yet, fullest first. */ + current: ProviderRateLimitWindow[]; +} + +/** + * Which window to show, given the clock. A window whose reset time has passed + * has rolled over, so its stored percentage no longer applies. + */ +export function summarizeProviderRateLimits( + snapshot: ProviderRateLimitSnapshot | null, + now: number, +): ProviderRateLimitSummary | null { + if (!snapshot) return null; + const live = (resetsAt: string | null) => !resetsAt || Date.parse(resetsAt) > now; + const current = snapshot.windows + .filter((window) => window.usedPercent !== null && live(window.resetsAt)) + .sort((a, b) => (b.usedPercent ?? 0) - (a.usedPercent ?? 0)); + const reached = snapshot.limitReached; + const hasCredits = snapshot.credits?.unlimited === true || snapshot.credits?.hasCredits === true; + const reachedNow = reached !== null && live(reached.resetsAt) + && (reached.resetsAt !== null || now - Date.parse(snapshot.observedAt) < 3_600_000); + if (reached && reachedNow && !hasCredits) { + const window = snapshot.windows.find((candidate) => candidate.id === reached.windowId) ?? current[0] ?? null; + return { tone: "reached", window, limitedUntil: reached.resetsAt, limited: true, usingCredits: false, current }; + } + const window = current[0] ?? null; + if (!window) return null; + const pct = window.usedPercent ?? 0; + const usingCredits = hasCredits && (reachedNow || pct >= 100); + return { + tone: usingCredits ? "warn" : pct >= 100 ? "reached" : pct > PROVIDER_RATE_LIMIT_WARN_PERCENT ? "warn" : "normal", + window, + limitedUntil: null, + limited: false, + usingCredits, + current, + }; +} diff --git a/server/src/__tests__/agent-provider-rate-limits.test.ts b/server/src/__tests__/agent-provider-rate-limits.test.ts new file mode 100644 index 00000000000..597d17db3e2 --- /dev/null +++ b/server/src/__tests__/agent-provider-rate-limits.test.ts @@ -0,0 +1,143 @@ +import { randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { agentRuntimeState, agents, companies, createDb } from "@paperclipai/db"; +import { readProviderRateLimits, type ProviderRateLimitSnapshot } from "@paperclipai/shared"; +import { recordAgentProviderRateLimits, preservedAccountRuntimeState } from "../services/agent-provider-rate-limits.js"; +import { heartbeatService } from "../services/heartbeat.js"; +import { recoveryPostgresSupport, startRecoveryPostgres } from "./helpers/recovery-postgres.js"; + +const describePostgres = recoveryPostgresSupport.supported ? describe : describe.skip; +if (!recoveryPostgresSupport.supported) console.warn(`Skipping provider rate limit tests: ${recoveryPostgresSupport.reason}`); + +function snapshot(input: Partial & { observedAt: string }): ProviderRateLimitSnapshot { + return { + schema: "paperclip.provider.rate_limits.v1", + provider: "openai", + windows: [], + credits: null, + limitReached: null, + ...input, + }; +} + +function notice(value: ProviderRateLimitSnapshot) { + return { + eventType: "provider.notice.recorded", + payload: { + schema: "paperclip.provider.notice.v1", + noticeId: "codex-rate-limits:1", + severity: "info", + category: "provider.rate_limits", + scope: "account", + recoverable: true, + userActionable: false, + summary: "Codex rate limits", + details: [{ name: "rateLimits", value: JSON.stringify(value) }], + }, + }; +} + +const fiveHour = (usedPercent: number) => ({ + id: "codex.primary", label: "5-hour window", shortLabel: "5h", windowMinutes: 300, + usedPercent, resetsAt: "2026-11-05T15:40:00.000Z", status: null, +}); +const weekly = (usedPercent: number) => ({ + id: "codex.secondary", label: "Weekly", shortLabel: "7d", windowMinutes: 10080, + usedPercent, resetsAt: "2026-11-09T09:00:00.000Z", status: null, +}); + +/** The latest provider rate-limit snapshot is kept per agent, in its own company. */ +describePostgres("agent provider rate limits", () => { + let temporary: Awaited>; + let db: ReturnType; + + beforeAll(async () => { + temporary = await startRecoveryPostgres("agent-provider-rate-limits-"); + db = createDb(temporary.connectionString); + }, 60_000); + + afterAll(async () => { + await db?.$client.end({ timeout: 0 }); + await temporary?.cleanup(); + }); + + async function seed() { + const companyId = randomUUID(), agentId = randomUUID(); + await db.insert(companies).values({ id: companyId, name: "Rate limit fixture", issuePrefix: `R${companyId.slice(0, 6)}` }); + await db.insert(agents).values({ id: agentId, companyId, name: "Codex planner", adapterType: "paperclip_runner", status: "idle", adapterConfig: {} }); + return { companyId, agentId }; + } + + async function stored(agentId: string) { + const row = await db.select().from(agentRuntimeState).where(eq(agentRuntimeState.agentId, agentId)).then((rows) => rows[0]); + return row ? readProviderRateLimits(row.stateJson) : null; + } + + it("creates runtime state and merges later snapshots by window", async () => { + const binding = await seed(); + await expect(recordAgentProviderRateLimits(db, binding, notice(snapshot({ + observedAt: "2026-11-05T15:00:00.000Z", windows: [fiveHour(70), weekly(40)], + })))).resolves.toBe(true); + await recordAgentProviderRateLimits(db, binding, notice(snapshot({ + observedAt: "2026-11-05T15:10:00.000Z", windows: [fiveHour(78)], + }))); + expect((await stored(binding.agentId))?.windows.map((window) => [window.id, window.usedPercent])).toEqual([ + ["codex.primary", 78], + ["codex.secondary", 40], + ]); + }); + + it("merges concurrent reports for one agent without losing a window", async () => { + const binding = await seed(); + await Promise.all([ + recordAgentProviderRateLimits(db, binding, notice(snapshot({ observedAt: "2026-11-05T15:00:00.000Z", windows: [fiveHour(70)] }))), + recordAgentProviderRateLimits(db, binding, notice(snapshot({ observedAt: "2026-11-05T15:00:01.000Z", windows: [weekly(40)] }))), + ]); + expect((await stored(binding.agentId))?.windows.map((window) => window.id).sort()).toEqual(["codex.primary", "codex.secondary"]); + }); + + it("reports no change when the same notice is replayed", async () => { + const binding = await seed(); + const value = notice(snapshot({ observedAt: "2026-11-05T15:00:00.000Z", windows: [fiveHour(70)] })); + await expect(recordAgentProviderRateLimits(db, binding, value)).resolves.toBe(true); + await expect(recordAgentProviderRateLimits(db, binding, value)).resolves.toBe(false); + }); + + it("ignores an older snapshot delivered late", async () => { + const binding = await seed(); + await recordAgentProviderRateLimits(db, binding, notice(snapshot({ observedAt: "2026-11-05T15:10:00.000Z", windows: [fiveHour(78)] }))); + await recordAgentProviderRateLimits(db, binding, notice(snapshot({ observedAt: "2026-11-05T15:00:00.000Z", windows: [fiveHour(60)] }))); + expect((await stored(binding.agentId))?.windows[0]?.usedPercent).toBe(78); + }); + + it("does not write for another company's agent or for unrelated notices", async () => { + const binding = await seed(); + const other = await seed(); + await expect(recordAgentProviderRateLimits(db, { companyId: other.companyId, agentId: binding.agentId }, notice(snapshot({ + observedAt: "2026-11-05T15:00:00.000Z", windows: [fiveHour(10)], + })))).resolves.toBe(false); + await expect(recordAgentProviderRateLimits(db, binding, { + eventType: "provider.notice.recorded", + payload: { schema: "paperclip.provider.notice.v1", category: "warning", details: [] }, + })).resolves.toBe(false); + expect(await stored(binding.agentId)).toBeNull(); + }); + + it("keeps the snapshot when the board resets the agent's session", async () => { + const binding = await seed(); + await recordAgentProviderRateLimits(db, binding, notice(snapshot({ observedAt: "2026-11-05T15:00:00.000Z", windows: [fiveHour(55)] }))); + await db.update(agentRuntimeState) + .set({ stateJson: { ...(await db.select().from(agentRuntimeState).where(eq(agentRuntimeState.agentId, binding.agentId)).then((rows) => rows[0]!.stateJson)), sessionScratch: true } }) + .where(eq(agentRuntimeState.agentId, binding.agentId)); + await heartbeatService(db).resetRuntimeSession(binding.agentId); + const row = await db.select().from(agentRuntimeState).where(eq(agentRuntimeState.agentId, binding.agentId)).then((rows) => rows[0]!); + expect(row.stateJson.sessionScratch).toBeUndefined(); + expect(readProviderRateLimits(row.stateJson)?.windows[0]?.usedPercent).toBe(55); + }); + + it("preserves only account keys", () => { + expect(preservedAccountRuntimeState({ providerRateLimits: { a: 1 }, other: 2 })).toEqual({ providerRateLimits: { a: 1 } }); + expect(preservedAccountRuntimeState(null)).toEqual({}); + }); +}); diff --git a/server/src/__tests__/live-events-run-read.test.ts b/server/src/__tests__/live-events-run-read.test.ts index 00bda1efaa2..2976bd96a01 100644 --- a/server/src/__tests__/live-events-run-read.test.ts +++ b/server/src/__tests__/live-events-run-read.test.ts @@ -278,6 +278,11 @@ describe("live-event run read boundaries", () => { await once(socket, "open"); socket.send(JSON.stringify({ type: "live.interests", version: 1, revision: 1, runIds: [] })); await expect.poll(() => messages.some(message => message.type === "live.subscribed" && message.revision === 1)).toBe(true); + // Runtime-state refreshes reach the company without a transcript subscription. + const refreshedAgentId = randomUUID(); + publishLiveEvent({ companyId, type: "agent.runtime_state.updated", payload: { agentId: refreshedAgentId } }); + await barrier(); + expect(messages.find(message => message.type === "agent.runtime_state.updated")?.payload).toMatchObject({ agentId: refreshedAgentId }); const baseline = queries.length; publishLiveEvent({ companyId, type: "heartbeat.run.event", payload: sourcePayload }); await barrier(); expect(messages.some(message => message.type === "heartbeat.run.event")).toBe(false); diff --git a/server/src/realtime/live-events-ws.ts b/server/src/realtime/live-events-ws.ts index 260ac29adaf..acd244f1d9c 100644 --- a/server/src/realtime/live-events-ws.ts +++ b/server/src/realtime/live-events-ws.ts @@ -355,6 +355,7 @@ export function setupLiveEventsWebSocketServer( ...(issueId ? await activityToastIdentity(db, event.companyId, payload) : {}), } }; if (event.type === "agent.status") return { ...event, payload: { agentId: payload.agentId, status: payload.status } }; + if (event.type === "agent.runtime_state.updated") return { ...event, payload: { agentId: payload.agentId } }; if (event.type === "external_object.updated") return { ...event, payload: { externalObjectId: payload.externalObjectId } }; if (event.type.startsWith("plugin.")) return event; return null; diff --git a/server/src/services/agent-provider-rate-limits.ts b/server/src/services/agent-provider-rate-limits.ts new file mode 100644 index 00000000000..63afa4cd051 --- /dev/null +++ b/server/src/services/agent-provider-rate-limits.ts @@ -0,0 +1,71 @@ +import { and, eq, sql } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { agentRuntimeState, agents } from "@paperclipai/db"; +import { + PROVIDER_RATE_LIMITS_STATE_KEY, + mergeProviderRateLimitSnapshots, + providerRateLimitSnapshotFromNotice, + readProviderRateLimits, +} from "@paperclipai/shared"; +import { publishLiveEvent } from "./live-events.js"; + +/** + * Keep the latest provider rate-limit snapshot an agent's harness reported. + * The snapshot is account data the harness observed while running this agent; + * it lives in the agent's company-scoped runtime state and never drives + * execution (the provider-limit wait reads typed run failures instead). + * + * Safe to repeat: replaying the same notice merges to the same snapshot. + * Returns true when the stored snapshot changed. + */ +export async function recordAgentProviderRateLimits( + db: Db, + binding: { companyId: string; agentId: string }, + event: { eventType: string; payload: Record }, +): Promise { + const next = providerRateLimitSnapshotFromNotice(event.eventType, event.payload); + if (!next) return false; + const changed = await db.transaction(async (tx) => { + const agent = await tx + .select({ adapterType: agents.adapterType }) + .from(agents) + .where(and(eq(agents.id, binding.agentId), eq(agents.companyId, binding.companyId))) + .then((rows) => rows[0] ?? null); + if (!agent) return false; + await tx + .insert(agentRuntimeState) + .values({ agentId: binding.agentId, companyId: binding.companyId, adapterType: agent.adapterType, stateJson: {} }) + .onConflictDoNothing({ target: agentRuntimeState.agentId }); + // Lock the row so concurrent runs merge in order instead of overwriting. + const existing = await tx + .select({ stateJson: agentRuntimeState.stateJson }) + .from(agentRuntimeState) + .where(and(eq(agentRuntimeState.agentId, binding.agentId), eq(agentRuntimeState.companyId, binding.companyId))) + .for("update") + .then((rows) => rows[0] ?? null); + if (!existing) return false; + const stored = readProviderRateLimits(existing.stateJson); + const merged = mergeProviderRateLimitSnapshots(stored, next); + if (stored && JSON.stringify(stored) === JSON.stringify(merged)) return false; + const patch = JSON.stringify({ [PROVIDER_RATE_LIMITS_STATE_KEY]: merged }); + await tx + .update(agentRuntimeState) + .set({ stateJson: sql`coalesce(${agentRuntimeState.stateJson}, '{}'::jsonb) || ${patch}::jsonb` }) + .where(and(eq(agentRuntimeState.agentId, binding.agentId), eq(agentRuntimeState.companyId, binding.companyId))); + return true; + }); + if (changed) { + publishLiveEvent({ + companyId: binding.companyId, + type: "agent.runtime_state.updated", + payload: { agentId: binding.agentId }, + }); + } + return changed; +} + +/** Runtime state keys that describe the provider account, not the session. */ +export function preservedAccountRuntimeState(stateJson: Record | null | undefined): Record { + const snapshot = stateJson?.[PROVIDER_RATE_LIMITS_STATE_KEY]; + return snapshot === undefined ? {} : { [PROVIDER_RATE_LIMITS_STATE_KEY]: snapshot }; +} diff --git a/server/src/services/heartbeat/run-state.ts b/server/src/services/heartbeat/run-state.ts index 4c8aa2b0bdb..3c48a03114f 100644 --- a/server/src/services/heartbeat/run-state.ts +++ b/server/src/services/heartbeat/run-state.ts @@ -1,4 +1,5 @@ import { notifyExternalConversationSettlement } from "../external-conversation-settlement-notifications.js"; +import { preservedAccountRuntimeState } from "../agent-provider-rate-limits.js"; import { readRuntimeAccountingTotals } from "../runtime-accounting-totals.js"; import { conversationEventCondition, conversationEventColumns } from "./conversation-events.js"; import { deriveTaskKey } from "./run-preparation.js"; @@ -1940,7 +1941,7 @@ export function createHeartbeatRunState(db: Db) { ) => { const agent = await getAgent(agentId); if (!agent) throw notFound("Agent not found"); - await ensureRuntimeState(agent); + const runtimeBefore = await ensureRuntimeState(agent); const taskKey = readNonEmptyString(opts?.taskKey); const clearedTaskSessions = await clearTaskSessions( agent.companyId, @@ -1959,7 +1960,8 @@ export function createHeartbeatRunState(db: Db) { updatedAt: new Date(), }; if (!taskKey) { - runtimePatch.stateJson = {}; + // Provider rate limits describe the account, not the session. + runtimePatch.stateJson = preservedAccountRuntimeState(runtimeBefore.stateJson); } const updated = await db diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index d0de738f785..284587f56bd 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -1,4 +1,5 @@ import { NativeSessionDiagnostics, nativeSessionDiagnostics, type NativeDiagnosticLease } from "./native-session-diagnostics.js"; +import { recordAgentProviderRateLimits } from "../agent-provider-rate-limits.js"; import { nativePostResultFailureCategory, type NativePostResultFailureCategory } from "./native-post-result-failure.js"; import { logger } from "../../middleware/logger.js"; import { canRestoreMigratedNativeSession } from "./native-workspace-migration.js"; @@ -9215,6 +9216,16 @@ async function executePaperclipNativeSessionWithinScope( throw error; } const controlPlaneInstanceId = `${effectiveRunnerInstanceId}:control`; + // Display data only; a failure never fails the run. The replay path repeats + // it so a snapshot lost between the event commit and this write recovers. + const recordRateLimits = async (event: unknown) => { + await recordAgentProviderRateLimits(input.db, input.execution.binding, event as { + eventType: string; + payload: Record; + }).catch((error) => { + logger.warn({ err: error, runId: input.execution.binding.runId }, "failed to record provider rate limits"); + }); + }; const planSynchronizations: PlanSynchronization[] = []; const upsertPlanSynchronization = ( synchronization: PlanSynchronization, @@ -9682,6 +9693,7 @@ async function executePaperclipNativeSessionWithinScope( event, event.eventType === "item.completed" || questionFallback !== null, ); + await recordRateLimits(event); await recordPlanSynchronization( event as { sourceEventId: string; @@ -9708,6 +9720,7 @@ async function executePaperclipNativeSessionWithinScope( event, event.eventType === "item.completed" || questionFallback !== null, ); + await recordRateLimits(event); const planSynchronization = await synchronizeCompletedProviderPlan({ db: input.db, execution: input.execution, diff --git a/ui/src/components/AgentRateLimitGauge.tsx b/ui/src/components/AgentRateLimitGauge.tsx new file mode 100644 index 00000000000..4bddb2de2a5 --- /dev/null +++ b/ui/src/components/AgentRateLimitGauge.tsx @@ -0,0 +1,139 @@ +import { ChevronRight, Clock } from "lucide-react"; +import { + summarizeProviderRateLimits, + type ProviderRateLimitSnapshot, + type ProviderRateLimitSummary, +} from "@paperclipai/shared"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Link } from "@/lib/router"; +import { cn } from "@/lib/utils"; + +type Tone = ProviderRateLimitSummary["tone"]; + +const fillClass: Record = { + normal: "bg-muted-foreground/60", + warn: "bg-(--status-task-todo)", + reached: "bg-destructive", +}; + +const textClass: Record = { + normal: "text-foreground", + warn: "text-(--status-task-icon-todo)", + reached: "text-destructive", +}; + +export function formatRateLimitClock(iso: string, now: number): string { + const at = new Date(iso); + const time = at.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false }); + if (new Date(now).toDateString() === at.toDateString()) return time; + if (Math.abs(at.getTime() - now) < 6 * 86_400_000) return `${at.toLocaleDateString("en-US", { weekday: "short" })} ${time}`; + return `${at.toLocaleDateString("en-US", { month: "short", day: "numeric" })} ${time}`; +} + +function Bar({ pct, tone, className }: { pct: number; tone: Tone; className?: string }) { + const clamped = Math.min(100, Math.max(0, pct)); + return ( + + + + ); +} + +function windowTone(pct: number, reached: boolean): Tone { + if (reached || pct >= 100) return "reached"; + return pct > 80 ? "warn" : "normal"; +} + +/** Every current window as a labelled bar; shown inside the property popover. */ +export function AgentRateLimitPanel({ snapshot, summary, now }: { + snapshot: ProviderRateLimitSnapshot; + summary: ProviderRateLimitSummary; + now: number; +}) { + const reachedId = summary.limited ? snapshot.limitReached?.windowId ?? null : null; + const creditTone = summary.usingCredits ? "warn" : null; + const credits = snapshot.credits; + return ( +
+ {summary.current.map((window) => { + const pct = window.usedPercent ?? 0; + const tone = window.id === summary.window?.id && creditTone ? creditTone : windowTone(pct, window.id === reachedId); + return ( +
+
+ {window.label} + + {Math.round(pct)}% + {window.resetsAt && ( + · resets {formatRateLimitClock(window.resetsAt, now)} + )} + +
+ +
+ ); + })} + {summary.usingCredits && ( +
Limit reached; work continues on account credits.
+ )} + {credits && (credits.unlimited || credits.balance !== null || credits.hasCredits === false) && ( +
+ {credits.unlimited ? "Unlimited credits" : credits.hasCredits === false ? "No credits remaining" : `${credits.balance} credits`} +
+ )} +
+ ); +} + +/** + * One-line "Usage limit" property value: "5h 78% · resets 15:40", amber above + * 80%, "Limited until 15:40" while the provider refuses work, "using credits" + * when a used-up window falls back to account credits. Click opens the + * full window breakdown. Renders nothing when no current limit data exists. + */ +export function AgentRateLimitPropertyValue({ snapshot, now = Date.now(), defaultOpen, detailsHref, className }: { + snapshot: ProviderRateLimitSnapshot | null; + now?: number; + defaultOpen?: boolean; + /** Link to a fuller usage view, e.g. the agent's page. */ + detailsHref?: string; + className?: string; +}) { + const summary = summarizeProviderRateLimits(snapshot, now); + if (!snapshot || !summary) return null; + const { window, tone } = summary; + const label = summary.limited + ? `Limited${summary.limitedUntil ? ` until ${formatRateLimitClock(summary.limitedUntil, now)}` : ""}` + : summary.usingCredits + ? `${window!.shortLabel} ${Math.round(window!.usedPercent ?? 0)}% · using credits` + : `${window!.shortLabel} ${Math.round(window!.usedPercent ?? 0)}%${window!.resetsAt ? ` · resets ${formatRateLimitClock(window!.resetsAt, now)}` : ""}`; + return ( + + + + + +
{snapshot.provider === "openai" ? "Codex" : "Claude"} usage limits
+ +
+ Reported {formatRateLimitClock(snapshot.observedAt, now)} + {detailsHref && View agent} +
+
+
+ ); +} diff --git a/ui/src/components/issue-properties/IssueProperties.tsx b/ui/src/components/issue-properties/IssueProperties.tsx index 8e8d0ec668c..ebf412ba2d8 100644 --- a/ui/src/components/issue-properties/IssueProperties.tsx +++ b/ui/src/components/issue-properties/IssueProperties.tsx @@ -14,6 +14,8 @@ import { copyTextToClipboard } from "@/lib/clipboard"; import { Link } from "@/lib/router"; import { deriveOriginatingActor, + readProviderRateLimits, + summarizeProviderRateLimits, isArtifactReviewDocumentKey, type ExecutionWorkspace, type Issue, @@ -69,7 +71,8 @@ import { SHOW_TASK_PRIORITY_UI } from "../../lib/ui-flags"; import { Identity } from "../Identity"; import { ProjectTile } from "../ProjectTile"; import { IssueReferencePill } from "../IssueReferencePill"; -import { formatDate, formatDateTime, cn, projectUrl } from "../../lib/utils"; +import { formatDate, formatDateTime, cn, projectUrl, agentUrl } from "../../lib/utils"; +import { AgentRateLimitPropertyValue } from "../AgentRateLimitGauge"; import type { IssueExternalObjectGroup } from "../../hooks/useIssueExternalObjects"; import { timeAgo } from "../../lib/timeAgo"; import { invalidateInboxIssueQueries } from "../../lib/inboxArchiveCache"; @@ -427,6 +430,16 @@ export function IssueProperties({ queryFn: () => agentsApi.list(companyId!), enabled: !!companyId, }); + const assigneeRuntimeAgentId = issue.assigneeAgentId ?? null; + const { data: assigneeRuntimeState } = useQuery({ + queryKey: queryKeys.agents.runtimeState(assigneeRuntimeAgentId ?? ""), + queryFn: () => agentsApi.runtimeState(assigneeRuntimeAgentId!, companyId ?? undefined), + // Optional detail: a 403 for board users without agent management access + // is not retried by the shared policy and simply hides the row. + enabled: !!assigneeRuntimeAgentId && !!companyId, + }); + const assigneeRateLimits = readProviderRateLimits(assigneeRuntimeState?.stateJson); + const assigneeRateLimitAgent = assigneeRuntimeAgentId ? agents?.find((agent) => agent.id === assigneeRuntimeAgentId) : null; const { data: companyMembers } = useQuery({ queryKey: queryKeys.access.companyUserDirectory(companyId!), queryFn: () => accessApi.listUserDirectory(companyId!), @@ -2403,6 +2416,15 @@ export function IssueProperties({ ) : null} + {assigneeRateLimits && summarizeProviderRateLimits(assigneeRateLimits, Date.now()) ? ( + + + + ) : null} + , ): boolean { + // Rate-limit snapshots feed the agent's usage-limit property, not the transcript. + if (isProviderRateLimitNotice(eventType, payload)) return true; if (eventType === "harness.diagnostic") { return payload.code === "codex_unrelated_information"; } diff --git a/ui/src/context/LiveUpdatesProvider.tsx b/ui/src/context/LiveUpdatesProvider.tsx index 39f6a94abc6..f14de12239e 100644 --- a/ui/src/context/LiveUpdatesProvider.tsx +++ b/ui/src/context/LiveUpdatesProvider.tsx @@ -1378,6 +1378,10 @@ function invalidateHeartbeatQueries( queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(companyId, agentId), }); + // Provider rate limits reported during the run live in runtime state. + queryClient.invalidateQueries({ + queryKey: queryKeys.agents.runtimeState(agentId), + }); } const runId = readString(payload.runId); if (runId) { @@ -1988,6 +1992,13 @@ function handleLiveEvent( return; } + if (event.type === "agent.runtime_state.updated") { + // The server saved a new provider rate-limit snapshot for this agent. + const agentId = readString(payload.agentId); + if (agentId) queryClient.invalidateQueries({ queryKey: queryKeys.agents.runtimeState(agentId) }); + return; + } + if (event.type === "agent.status") { queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(expectedCompanyId), diff --git a/ui/storybook/stories/tasks/task-usage-limit-property.stories.tsx b/ui/storybook/stories/tasks/task-usage-limit-property.stories.tsx new file mode 100644 index 00000000000..a2a3fdc1b7d --- /dev/null +++ b/ui/storybook/stories/tasks/task-usage-limit-property.stories.tsx @@ -0,0 +1,117 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { Agent, ProviderRateLimitSnapshot } from "@paperclipai/shared"; +import { AgentIdentity } from "@/components/AgentIdentity"; +import { AgentRateLimitPropertyValue } from "@/components/AgentRateLimitGauge"; +import { IssueStatusBadge } from "@/components/StatusBadge"; +import { PropertyRow, PropertySection } from "@/components/issue-properties/primitives"; + +// Local-time ISO strings (no zone) keep the rendered clocks stable across machines. +const NOW = new Date("2026-11-05T15:18:00").getTime(); +const at = (local: string) => new Date(local).toISOString(); + +const window = (id: string, label: string, shortLabel: string, windowMinutes: number, usedPercent: number, resetsAt: string) => ({ + id, label, shortLabel, windowMinutes, usedPercent, resetsAt: at(resetsAt), status: null, +}); + +const codex: ProviderRateLimitSnapshot = { + schema: "paperclip.provider.rate_limits.v1", + provider: "openai", + windows: [ + window("codex.primary", "5-hour window", "5h", 300, 78, "2026-11-05T15:40:00"), + window("codex.secondary", "Weekly", "7d", 10080, 41, "2026-11-09T09:00:00"), + ], + credits: null, + limitReached: null, + observedAt: at("2026-11-05T15:17:00"), +}; + +const claude: ProviderRateLimitSnapshot = { + schema: "paperclip.provider.rate_limits.v1", + provider: "anthropic", + windows: [ + window("claude.five_hour", "5-hour window", "5h", 300, 86, "2026-11-05T17:05:00"), + window("claude.seven_day", "Weekly", "7d", 10080, 63, "2026-11-10T08:00:00"), + window("claude.seven_day_opus", "Weekly (Opus)", "7d Opus", 10080, 52, "2026-11-10T08:00:00"), + ], + credits: null, + limitReached: null, + observedAt: at("2026-11-05T15:16:00"), +}; + +const limited: ProviderRateLimitSnapshot = { + ...codex, + windows: [ + window("codex.primary", "5-hour window", "5h", 300, 100, "2026-11-05T15:40:00"), + window("codex.secondary", "Weekly", "7d", 10080, 71, "2026-11-09T09:00:00"), + ], + credits: { hasCredits: false, unlimited: false, balance: null }, + limitReached: { windowId: "codex.primary", reason: "rate_limit_reached", resetsAt: at("2026-11-05T15:40:00") }, + observedAt: at("2026-11-05T15:02:00"), +}; + +const agent = (id: string, name: string) => ({ id, name, companyId: "company-story" }) as unknown as Agent; + +function Pane({ caption, assignee, model, snapshot, open }: { + caption: string; + assignee: Agent; + model: string; + snapshot: ProviderRateLimitSnapshot | null; + open?: boolean; +}) { + return ( +
+
{caption}
+
+ + + + {model} + {snapshot && ( + + + + )} + Billing revamp + +
+
+ ); +} + +function UsageLimitProperty() { + return ( +
+ + + + +
+ ); +} + +const meta = { + id: "tasks-usage-limit-property", + title: "Tasks and conversations/Task creation and lifecycle/Usage limit property", + component: UsageLimitProperty, + parameters: { + layout: "fullscreen", + docs: { + description: { + component: + "The task properties pane shows the assignee's latest provider usage limit as one line: the fullest current window and its reset, amber above 80%, 'Limited until' while the provider refuses work. Clicking it lists every window. Agents on API-key auth report no limits and show no row.", + }, + }, + paperclip: { + capabilities: ["tasks/properties"], + status: "current", + implementation: "production", + boundary: "fixtures", + notes: "Renders the shipped AgentRateLimitPropertyValue inside the shared property primitives with a fixed clock. Snapshots mirror Codex account/rateLimits/updated and Claude rate_limit_event windows; the runtime-state query is not exercised.", + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Overview: Story = {};