mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 01:50:13 +01:00
## 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 <noreply@anthropic.com>
140 lines
6.1 KiB
TypeScript
140 lines
6.1 KiB
TypeScript
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<Tone, string> = {
|
|
normal: "bg-muted-foreground/60",
|
|
warn: "bg-(--status-task-todo)",
|
|
reached: "bg-destructive",
|
|
};
|
|
|
|
const textClass: Record<Tone, string> = {
|
|
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 (
|
|
<span className={cn("relative inline-block h-1.5 overflow-hidden rounded-full bg-muted", className)} aria-hidden>
|
|
<span className={cn("absolute inset-y-0 left-0 rounded-full", fillClass[tone])} style={{ width: `${clamped}%` }} />
|
|
</span>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className="space-y-2">
|
|
{summary.current.map((window) => {
|
|
const pct = window.usedPercent ?? 0;
|
|
const tone = window.id === summary.window?.id && creditTone ? creditTone : windowTone(pct, window.id === reachedId);
|
|
return (
|
|
<div key={window.id} className="space-y-1">
|
|
<div className="flex items-baseline justify-between gap-3 text-xs">
|
|
<span className="truncate text-muted-foreground">{window.label}</span>
|
|
<span className={cn("shrink-0 tabular-nums", textClass[tone])}>
|
|
{Math.round(pct)}%
|
|
{window.resetsAt && (
|
|
<span className="text-muted-foreground"> · resets {formatRateLimitClock(window.resetsAt, now)}</span>
|
|
)}
|
|
</span>
|
|
</div>
|
|
<Bar pct={pct} tone={tone} className="w-full" />
|
|
</div>
|
|
);
|
|
})}
|
|
{summary.usingCredits && (
|
|
<div className="text-xs text-(--status-task-icon-todo)">Limit reached; work continues on account credits.</div>
|
|
)}
|
|
{credits && (credits.unlimited || credits.balance !== null || credits.hasCredits === false) && (
|
|
<div className="text-xs text-muted-foreground">
|
|
{credits.unlimited ? "Unlimited credits" : credits.hasCredits === false ? "No credits remaining" : `${credits.balance} credits`}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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 (
|
|
<Popover defaultOpen={defaultOpen}>
|
|
<PopoverTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className={cn(
|
|
"group inline-flex min-w-0 items-center gap-1.5 rounded-sm text-xs tabular-nums hover:text-foreground",
|
|
textClass[tone],
|
|
className,
|
|
)}
|
|
aria-label={`Provider usage limit: ${label}`}
|
|
>
|
|
{summary.limited
|
|
? <Clock className="h-3.5 w-3.5 shrink-0" aria-hidden />
|
|
: <Bar pct={window!.usedPercent ?? 0} tone={tone} className="w-6 shrink-0" />}
|
|
<span className="truncate">{label}</span>
|
|
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100" aria-hidden />
|
|
</button>
|
|
</PopoverTrigger>
|
|
<PopoverContent align="start" className="w-72 space-y-3 p-3">
|
|
<div className="text-xs font-medium">{snapshot.provider === "openai" ? "Codex" : "Claude"} usage limits</div>
|
|
<AgentRateLimitPanel snapshot={snapshot} summary={summary} now={now} />
|
|
<div className="flex items-center justify-between gap-3 text-(length:--text-micro) text-muted-foreground">
|
|
<span>Reported {formatRateLimitClock(snapshot.observedAt, now)}</span>
|
|
{detailsHref && <Link to={detailsHref} className="hover:text-foreground hover:underline">View agent</Link>}
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
);
|
|
}
|