mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 22:56:58 +01:00
feat(task): show the agent's context window in task properties (#19273)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agents run through a harness (Codex app-server, or Claude through ACPX), and the runner turns harness events into Paperclip events > - Both harnesses report how full the model's context window is. Codex sends `modelContextWindow` with its token usage. Claude sends `used` and `size` in ACP `usage_update` > - Paperclip dropped both values. The task view could not show how close an agent is to compaction, and a compaction left no visible mark in the task chat > - This pull request carries the context-window occupancy through the runner, stores it with the existing usage events, and shows it as a "Context" row in the task Properties panel > - It also shows a "Context compacted · 230k → 38k tokens" divider in the task chat when the agent compacts its context > - The benefit is that operators can see when a long task is near its context limit and when the agent lost detail to a compaction ## Linked Issues or Issue Description Refs #19252 (same pattern: a canonical harness event turned into task-chat UI). Refs #19267 (status notices; it turns Claude's compaction banner into a notice, and this PR reads that notice too). **What existing behavior does this improve?** The task view shows no context-window information, and a compaction is invisible in the task chat. **Subsystem affected** Runner usage normalization (Rust and TypeScript transport), the PRP usage schema, a read-only issue API, the task Properties panel and the task-chat transcript. **Current behavior** Codex `modelContextWindow` and ACP `used`/`size` are dropped in the runner. `context.compacted` always has null sizes, Claude compactions emit no `context.compacted` event at all, and the live runner turn hides every marker except "interrupted". **Proposed behavior** `usage.reported` carries an optional `context {usedTokens, windowTokens}`. The task Properties panel shows a Context row ("10% of 258k") with a hover breakdown (tokens used, input and cached input, output, cost, compactions). The transcript shows a compaction divider, with sizes from the usage before and after the compaction. **Reason and benefit** Operators can see when a long task is close to its context limit, and they can see where the agent's earlier context was summarized. **Breaking changes** None. The new schema field is optional, and the new endpoint is read-only. ## What Changed - PRP `usage.schema.json`: add an optional `context {usedTokens, windowTokens}` object. Regenerate the schema bundle and the protocol manifest. - Rust `provider_events.rs`: add `codex_context_usage` (last request `totalTokens` and `modelContextWindow`) and `acpx_context_usage` (ACP `used`/`size`). ACPX `usage_update` now sets `context`. - Rust `provider_backend.rs`: Codex `thread/tokenUsage/updated` sets `context` on live (non-historical) usage. - Rust `provider_events.rs`: emit `context.compacted` for the completed `context_compaction` provider notice that #19267 produces from Claude's compaction banner. Only this path marks a Claude compaction, so the server counts each compaction once. - `runnerd-codex-transport.ts`: `rehydrateRunnerdUsageNotification` keeps a valid `context` on `tokenUsage`, so it reaches the stored `item.completed` usage item. - Server: new `GET /api/issues/:id/context-usage` (issue read access is required) and `issueContextUsage` service. It reads only the assignee's current session (the session of its newest usage report), and returns that session's latest context reading, non-zero totals, cost, and the count of compactions across all of the session's runs. Added to OpenAPI. Shared type `IssueContextUsage`. - DB: migration 0432 adds two partial indexes on `heartbeat_run_events`, built concurrently. One indexes usage items by run and time. The other indexes `context.compacted` events by company and session. `EXPLAIN` shows that both lookups use them. - UI: new `ContextUsageMeter` (bar, percentage, hover card) and `IssueContextUsageRow` in the Execution section of Properties. The row refreshes every 8 s while a run is active, and once more when the run ends. - UI transcript: `native-run-events.ts` maps `context.compacted` to a `context` activity entry with pre/post sizes from neighbouring usage items. `transcript-adapter.ts` turns it into a new `context_compacted` marker variant. The live runner turn keeps that marker as its own timeline row. - Storybook: `task-context-window.stories.tsx` (Properties panel in a task, all states, hover breakdowns, divider). ## Verification - `cargo test -p paperclip-runner-core --lib provider_events` (new tests: ACPX context, Codex context, banner compaction, notice compaction) - `pnpm --filter @paperclipai/paperclip-runner exec vitest run src/live/runnerd-codex-transport.test.ts` - `cd ui && npx vitest run src/components/ContextUsageMeter.test.tsx src/components/transcript src/components/task-chat src/components/issue-properties` - `cd server && npx vitest run src/__tests__/issue-context-usage.test.ts` (embedded Postgres) - `node scripts/typecheck-low-memory.mjs`, `pnpm check:token-gates`, `pnpm --filter @paperclipai/paperclip-runner run check:protocol-types` - Live check on a local worktree instance through the runnerd path: - A Codex `gpt-5.5` task showed "10% of 258k" (27k of 258k, input 77k with 49k cached, output 1.1k). - A Claude Sonnet 4.6 task showed "17% of 200k" (34k of 200k). - Storybook: Tasks and conversations / … / Task chat / Context window. - Not verified live: a real compaction. The divider is covered by unit tests through the same projector path the task chat uses. ## Risks - The schema field is optional, and older runners omit it. The UI renders nothing until a harness reports a window. - Migration 0432 builds two partial indexes concurrently on `heartbeat_run_events`, which is a large table. The indexes cover only usage items and compaction events, so they stay small. They add a little write overhead to those events. - Claude in-progress usage rows still record zero tokens with `requests: 1`. The final turn usage is correct. This PR does not change accounting. - A "Compact now" action is out of scope. It needs a compaction-only run that does not produce a run result. It will come in a follow-up. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, used through Claude Code with tool use (shell, file edits, in-app browser for Storybook and the live instance). ## 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>
This commit is contained in:
1 parent
b61a815d3d
commit
bf10bc695a
35 files changed
+1760
-11
No files matched your search
@@ -591,6 +591,7 @@ const MIGRATIONS_WITH_TOP_LEVEL_STATEMENTS = new Set([
|
||||
"0413_tired_kronos.sql",
|
||||
"0414_closed_edwin_jarvis.sql",
|
||||
"0419_slippery_impossible_man.sql",
|
||||
"0432_strong_mole_man.sql",
|
||||
|
||||
|
||||
"0384_funny_adam_warlock.sql",
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS "heartbeat_run_events_usage_item_run_idx" ON "heartbeat_run_events" USING btree ("run_id","created_at") WHERE "heartbeat_run_events"."event_type" = 'item.completed' and ("heartbeat_run_events"."payload" -> 'prpEvent' -> 'payload' ->> 'kind') = 'usage';--> statement-breakpoint
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS "heartbeat_run_events_compaction_session_idx" ON "heartbeat_run_events" USING btree ("company_id",("payload" -> 'prpEvent' ->> 'normalizedSessionId')) WHERE "heartbeat_run_events"."event_type" = 'context.compacted';
|
||||
+343
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "dd860bff-55de-4849-8787-58b9de729258",
|
||||
"prevId": "a1fcb4c6-40e7-47a6-b3f2-c43cd9bf332b",
|
||||
"id": "a87278a8-15b3-4187-94a9-f4bb79d8640d",
|
||||
"prevId": "9fcf29d1-a9a6-4ead-8d43-7e087a2b2b24",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
@@ -713,6 +713,12 @@
|
||||
"notNull": true,
|
||||
"default": "'{}'::jsonb"
|
||||
},
|
||||
"previous_usage_json": {
|
||||
"name": "previous_usage_json",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"reason": {
|
||||
"name": "reason",
|
||||
"type": "text",
|
||||
@@ -7171,6 +7177,147 @@
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.budget_evaluation_work": {
|
||||
"name": "budget_evaluation_work",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"company_id": {
|
||||
"name": "company_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"agent_id": {
|
||||
"name": "agent_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"project_id": {
|
||||
"name": "project_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"budget_evaluation_work_company_pending_idx": {
|
||||
"name": "budget_evaluation_work_company_pending_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "company_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "created_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"budget_evaluation_work_agent_pending_idx": {
|
||||
"name": "budget_evaluation_work_agent_pending_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "company_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "agent_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "created_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"budget_evaluation_work_project_pending_idx": {
|
||||
"name": "budget_evaluation_work_project_pending_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "company_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "project_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "created_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"budget_evaluation_work_company_id_companies_id_fk": {
|
||||
"name": "budget_evaluation_work_company_id_companies_id_fk",
|
||||
"tableFrom": "budget_evaluation_work",
|
||||
"tableTo": "companies",
|
||||
"columnsFrom": [
|
||||
"company_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.budget_incidents": {
|
||||
"name": "budget_incidents",
|
||||
"schema": "",
|
||||
@@ -7518,6 +7665,13 @@
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"evaluation_version": {
|
||||
"name": "evaluation_version",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"created_by_user_id": {
|
||||
"name": "created_by_user_id",
|
||||
"type": "text",
|
||||
@@ -21885,6 +22039,12 @@
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"reported_usage_json": {
|
||||
"name": "reported_usage_json",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"pricing_provenance": {
|
||||
"name": "pricing_provenance",
|
||||
"type": "jsonb",
|
||||
@@ -28957,6 +29117,12 @@
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"admitted_project_ids": {
|
||||
"name": "admitted_project_ids",
|
||||
"type": "uuid[]",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"settlement_id": {
|
||||
"name": "settlement_id",
|
||||
"type": "uuid",
|
||||
@@ -29076,6 +29242,10 @@
|
||||
"execution_admissions_settlement_check": {
|
||||
"name": "execution_admissions_settlement_check",
|
||||
"value": "(\"execution_admissions\".\"settlement_id\" is null) = (\"execution_admissions\".\"settled_at\" is null)"
|
||||
},
|
||||
"execution_admissions_project_set_check": {
|
||||
"name": "execution_admissions_project_set_check",
|
||||
"value": "case when \"execution_admissions\".\"admitted_project_ids\" is null\n then \"execution_admissions\".\"settled_at\" is not null else array_position(\"execution_admissions\".\"admitted_project_ids\", null) is null and cardinality(\"execution_admissions\".\"admitted_project_ids\") <= 100 end"
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
@@ -34016,6 +34186,50 @@
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"heartbeat_run_events_usage_item_run_idx": {
|
||||
"name": "heartbeat_run_events_usage_item_run_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "run_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "created_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"where": "\"heartbeat_run_events\".\"event_type\" = 'item.completed' and (\"heartbeat_run_events\".\"payload\" -> 'prpEvent' -> 'payload' ->> 'kind') = 'usage'",
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"heartbeat_run_events_compaction_session_idx": {
|
||||
"name": "heartbeat_run_events_compaction_session_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "company_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "(\"payload\" -> 'prpEvent' ->> 'normalizedSessionId')",
|
||||
"asc": true,
|
||||
"isExpression": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"where": "\"heartbeat_run_events\".\"event_type\" = 'context.compacted'",
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
@@ -44016,6 +44230,133 @@
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.native_harness_backups": {
|
||||
"name": "native_harness_backups",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"company_id": {
|
||||
"name": "company_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"issue_id": {
|
||||
"name": "issue_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"run_id": {
|
||||
"name": "run_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"scope_digest": {
|
||||
"name": "scope_digest",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"manifest_sha256": {
|
||||
"name": "manifest_sha256",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'pending'"
|
||||
},
|
||||
"reference": {
|
||||
"name": "reference",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"native_harness_backups_current_idx": {
|
||||
"name": "native_harness_backups_current_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "company_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "scope_digest",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"where": "\"native_harness_backups\".\"status\" = 'current'",
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"native_harness_backups_scope_idx": {
|
||||
"name": "native_harness_backups_scope_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "company_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "scope_digest",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "manifest_sha256",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.native_run_finalizations": {
|
||||
"name": "native_run_finalizations",
|
||||
"schema": "",
|
||||
+7
@@ -3011,6 +3011,13 @@
|
||||
"when": 1793798006496,
|
||||
"tag": "0431_magenta_vanisher",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 432,
|
||||
"version": "7",
|
||||
"when": 1793916406980,
|
||||
"tag": "0432_strong_mole_man",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -46,5 +46,13 @@ export const heartbeatRunEvents = pgTable(
|
||||
.where(sql`${table.sourceInstanceId} is not null and ${table.sourceSeq} is not null`),
|
||||
companyRunIdx: index("heartbeat_run_events_company_run_idx").on(table.companyId, table.runId),
|
||||
companyCreatedIdx: index("heartbeat_run_events_company_created_idx").on(table.companyId, table.createdAt),
|
||||
// Context-window readings: the few usage items per run, newest first.
|
||||
usageItemRunIdx: index("heartbeat_run_events_usage_item_run_idx")
|
||||
.on(table.runId, table.createdAt)
|
||||
.where(sql`${table.eventType} = 'item.completed' and (${table.payload} -> 'prpEvent' -> 'payload' ->> 'kind') = 'usage'`),
|
||||
// Compactions counted per agent session across runs.
|
||||
compactionSessionIdx: index("heartbeat_run_events_compaction_session_idx")
|
||||
.on(table.companyId, sql`(${table.payload} -> 'prpEvent' ->> 'normalizedSessionId')`)
|
||||
.where(sql`${table.eventType} = 'context.compacted'`),
|
||||
}),
|
||||
);
|
||||
@@ -145,7 +145,7 @@
|
||||
{
|
||||
"path": "schemas/usage.schema.json",
|
||||
"id": "https://paperclip.dev/schemas/prp/v1/usage.schema.json",
|
||||
"sha256": "be9609768b224459546b800236045b375448f2cb2cb47ccb337e149e0b016361"
|
||||
"sha256": "ed06a0b50c311e7d8ac6c6fe53f0178cc37a38b65831531efb53cb35c992319f"
|
||||
},
|
||||
{
|
||||
"path": "schemas/workspace-diff.schema.json",
|
||||
|
||||
@@ -78,7 +78,17 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"runDelta": { "$ref": "#/$defs/measurement" }
|
||||
"runDelta": { "$ref": "#/$defs/measurement" },
|
||||
"context": {
|
||||
"description": "Live context-window occupancy for the provider session after this usage, when the provider reports it.",
|
||||
"type": "object",
|
||||
"required": ["usedTokens", "windowTokens"],
|
||||
"properties": {
|
||||
"usedTokens": { "type": "integer", "minimum": 0 },
|
||||
"windowTokens": { "type": "integer", "minimum": 1 }
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"$defs": {
|
||||
|
||||
@@ -31,7 +31,7 @@ use crate::provider_bridge::{
|
||||
TOOL_SET_SCHEMA,
|
||||
};
|
||||
use crate::provider_events::{
|
||||
normalize_codex_notification, normalize_codex_notification_in,
|
||||
codex_context_usage, normalize_codex_notification, normalize_codex_notification_in,
|
||||
normalized_codex_terminal_event_type, with_terminal_outcome, NormalizedProviderEvent,
|
||||
};
|
||||
use crate::stable_identity::{is_stable_id, DURABLE_STABLE_ID_CHARS, SHORT_STABLE_ID_CHARS};
|
||||
@@ -723,6 +723,11 @@ fn normalize_provider_notification(
|
||||
.codex_usage
|
||||
.get_or_insert_with(|| CodexRunUsage::new(true))
|
||||
.observe(total, historical);
|
||||
let context = if historical {
|
||||
None
|
||||
} else {
|
||||
codex_context_usage(params)
|
||||
};
|
||||
let mut normalized_params = params.clone();
|
||||
let emit_usage = !historical || (had_baseline && increases_known_usage);
|
||||
if historical && emit_usage {
|
||||
@@ -743,6 +748,9 @@ fn normalize_provider_notification(
|
||||
if event.event_type == "usage.reported" {
|
||||
event.payload["runDeltaAvailable"] = json!(complete);
|
||||
event.payload["runDeltaBasis"] = json!("run");
|
||||
if let Some(context) = &context {
|
||||
event.payload["context"] = context.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
return Ok(events);
|
||||
|
||||
@@ -832,6 +832,44 @@ fn measurement(value: &Value) -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
/// Context-window occupancy from a Codex `thread/tokenUsage/updated` payload.
|
||||
/// Codex sizes the live context by the most recent request's total tokens.
|
||||
pub(crate) fn codex_context_usage(params: &Value) -> Option<Value> {
|
||||
let usage = params.get("tokenUsage").unwrap_or(params);
|
||||
let window = usage
|
||||
.get("modelContextWindow")
|
||||
.and_then(Value::as_u64)
|
||||
.filter(|value| *value > 0)?;
|
||||
let last = usage.get("last")?;
|
||||
let used = last
|
||||
.get("totalTokens")
|
||||
.and_then(Value::as_u64)
|
||||
.or_else(|| {
|
||||
let input = last.get("inputTokens").and_then(Value::as_u64)?;
|
||||
Some(
|
||||
input.saturating_add(
|
||||
last.get("outputTokens")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0),
|
||||
),
|
||||
)
|
||||
})?;
|
||||
Some(json!({"usedTokens": used, "windowTokens": window}))
|
||||
}
|
||||
|
||||
/// Context-window occupancy from an ACP `usage_update` (`used` of `size`).
|
||||
fn acpx_context_usage(payload: &Value) -> Option<Value> {
|
||||
let window = payload
|
||||
.get("size")
|
||||
.and_then(Value::as_f64)
|
||||
.filter(|value| value.is_finite() && *value >= 1.0)?;
|
||||
let used = payload
|
||||
.get("used")
|
||||
.and_then(Value::as_f64)
|
||||
.filter(|value| value.is_finite() && *value >= 0.0)?;
|
||||
Some(json!({"usedTokens": used.round() as u64, "windowTokens": window.round() as u64}))
|
||||
}
|
||||
|
||||
/// Converts Codex app-server notifications into provider-neutral PRP events.
|
||||
/// Provider-native envelopes are consumed here and never cross the PRP boundary.
|
||||
fn provider_performance_spans(params: &Value) -> Vec<Value> {
|
||||
@@ -1508,7 +1546,10 @@ pub fn normalize_acpx_runtime_event(
|
||||
AcpxRuntimeEventKind::Plan => stable_id(turn_id, fallback_item_id),
|
||||
_ => stable_id(string(payload.get("messageId")), fallback_item_id),
|
||||
};
|
||||
match kind {
|
||||
let compaction = matches!(kind, AcpxRuntimeEventKind::ProviderNotice)
|
||||
.then(|| acpx_compaction_notice_completed(payload, turn_id))
|
||||
.flatten();
|
||||
let mut events = match kind {
|
||||
AcpxRuntimeEventKind::TextDelta => vec![NormalizedProviderEvent {
|
||||
event_type: "item.delta".to_owned(),
|
||||
priority: EventPriority::P2,
|
||||
@@ -1583,6 +1624,48 @@ pub fn normalize_acpx_runtime_event(
|
||||
true,
|
||||
)],
|
||||
AcpxRuntimeEventKind::SemanticResult | AcpxRuntimeEventKind::Done => Vec::new(),
|
||||
};
|
||||
events.extend(compaction);
|
||||
events
|
||||
}
|
||||
|
||||
/// A completed `context_compaction` notice (the sidecar's form of Claude's
|
||||
/// compaction banner) also marks a context compaction.
|
||||
fn acpx_compaction_notice_completed(
|
||||
payload: &Value,
|
||||
turn_id: &str,
|
||||
) -> Option<NormalizedProviderEvent> {
|
||||
if string(payload.get("category")) != "context_compaction" {
|
||||
return None;
|
||||
}
|
||||
let completed = payload
|
||||
.get("details")
|
||||
.and_then(Value::as_array)?
|
||||
.iter()
|
||||
.any(|detail| {
|
||||
string(detail.get("name")) == "Phase" && string(detail.get("value")) == "completed"
|
||||
});
|
||||
if !completed {
|
||||
return None;
|
||||
}
|
||||
Some(acpx_context_compacted(&stable_id(
|
||||
&format!("{turn_id}:{}", string(payload.get("noticeKey"))),
|
||||
"acpx-compaction",
|
||||
)))
|
||||
}
|
||||
|
||||
fn acpx_context_compacted(compaction_id: &str) -> NormalizedProviderEvent {
|
||||
NormalizedProviderEvent {
|
||||
event_type: "context.compacted".to_owned(),
|
||||
priority: EventPriority::P1,
|
||||
payload: json!({
|
||||
"schema": "paperclip.context.compacted.v1",
|
||||
"compactionId": compaction_id,
|
||||
"reason": "provider",
|
||||
"preTokens": Value::Null,
|
||||
"postTokens": Value::Null,
|
||||
"sameSession": true,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1733,7 +1816,7 @@ fn normalize_acpx_status(
|
||||
"requests": 1,
|
||||
"providerCostUsd": if turn_cost { provider_cost } else { 0.0 },
|
||||
});
|
||||
let usage = NormalizedProviderEvent {
|
||||
let mut usage = NormalizedProviderEvent {
|
||||
event_type: "usage.reported".to_owned(),
|
||||
priority: EventPriority::P0,
|
||||
payload: json!({
|
||||
@@ -1749,6 +1832,9 @@ fn normalize_acpx_status(
|
||||
"runDelta": run_delta,
|
||||
}),
|
||||
};
|
||||
if let Some(context) = acpx_context_usage(payload) {
|
||||
usage.payload["context"] = context;
|
||||
}
|
||||
// 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| {
|
||||
@@ -2298,6 +2384,86 @@ mod tests {
|
||||
assert_eq!(cumulative[0].payload["runDelta"]["providerCostUsd"], 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acpx_usage_carries_context_window_occupancy() {
|
||||
let usage = normalize_acpx_status(
|
||||
&json!({"tag": "usage_update", "used": 34076, "size": 200000}),
|
||||
"item",
|
||||
"turn-1",
|
||||
1,
|
||||
);
|
||||
assert_eq!(
|
||||
usage[0].payload["context"],
|
||||
json!({"usedTokens": 34076, "windowTokens": 200000})
|
||||
);
|
||||
for missing in [
|
||||
json!({"tag": "usage_update", "used": 10}),
|
||||
json!({"tag": "usage_update", "used": 10, "size": 0}),
|
||||
] {
|
||||
let usage = normalize_acpx_status(&missing, "item", "turn-1", 1);
|
||||
assert!(usage[0].payload.get("context").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_context_usage_reads_last_request_and_model_window() {
|
||||
assert_eq!(
|
||||
codex_context_usage(&json!({"tokenUsage": {
|
||||
"total": {"inputTokens": 76682, "outputTokens": 1089},
|
||||
"last": {"inputTokens": 26000, "outputTokens": 672, "totalTokens": 26672},
|
||||
"modelContextWindow": 258400,
|
||||
}})),
|
||||
Some(json!({"usedTokens": 26672, "windowTokens": 258400}))
|
||||
);
|
||||
assert_eq!(
|
||||
codex_context_usage(&json!({"tokenUsage": {
|
||||
"last": {"inputTokens": 100, "outputTokens": 20}, "modelContextWindow": 1000,
|
||||
}})),
|
||||
Some(json!({"usedTokens": 120, "windowTokens": 1000}))
|
||||
);
|
||||
assert_eq!(
|
||||
codex_context_usage(&json!({"tokenUsage": {"last": {"totalTokens": 5}}})),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compaction_banner_text_alone_is_not_a_compaction() {
|
||||
// The sidecar turns Claude's banner into a `context_compaction`
|
||||
// notice; only that notice marks the compaction, so it is counted once.
|
||||
let events = normalize_acpx_runtime_event(
|
||||
AcpxRuntimeEventKind::TextDelta,
|
||||
&json!({"text": "\n\nCompacting completed."}),
|
||||
None,
|
||||
"fallback",
|
||||
"turn-1",
|
||||
0,
|
||||
);
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(events[0].event_type, "item.delta");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_compaction_notice_emits_context_compacted() {
|
||||
let notice = |phase: &str| {
|
||||
normalize_acpx_runtime_event(
|
||||
AcpxRuntimeEventKind::ProviderNotice,
|
||||
&json!({"severity": "info", "category": "context_compaction",
|
||||
"summary": "Compacting context", "noticeKey": "compaction-1",
|
||||
"details": [{"name": "Phase", "value": phase}]}),
|
||||
None,
|
||||
"fallback",
|
||||
"turn-1",
|
||||
0,
|
||||
)
|
||||
};
|
||||
assert_eq!(notice("running").len(), 1);
|
||||
let completed = notice("completed");
|
||||
assert_eq!(completed.len(), 2);
|
||||
assert_eq!(completed[1].event_type, "context.compacted");
|
||||
assert_eq!(completed[1].payload["compactionId"], "turn-1:compaction-1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn performance_diagnostics_keep_only_bounded_content_free_measurements() {
|
||||
let mut spans = vec![json!({"name":"private prompt","startedAtMs":1,"endedAtMs":2})];
|
||||
|
||||
@@ -3108,6 +3108,20 @@ it("rehydrates normalized usage with the opened driver binding", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps reported context-window occupancy on rehydrated usage", () => {
|
||||
const base = { cumulative: { inputTokens: 10 }, runDelta: { inputTokens: 3 }, runDeltaAvailable: true };
|
||||
const params = rehydrateRunnerdUsageNotification(
|
||||
{ ...base, context: { usedTokens: 26672, windowTokens: 258400 } },
|
||||
"opened-thread-1",
|
||||
"active-turn-1",
|
||||
);
|
||||
expect(params.tokenUsage).toMatchObject({ context: { usedTokens: 26672, windowTokens: 258400 } });
|
||||
for (const context of [undefined, { usedTokens: -1, windowTokens: 10 }, { usedTokens: 1, windowTokens: 0 }]) {
|
||||
const rehydrated = rehydrateRunnerdUsageNotification({ ...base, context }, "opened-thread-1", "active-turn-1");
|
||||
expect(rehydrated.tokenUsage).not.toHaveProperty("context");
|
||||
}
|
||||
});
|
||||
|
||||
it("rehydrates durable cumulative usage for a cold thread read", () => {
|
||||
expect(
|
||||
rehydrateRunnerdThreadTokenUsage({ inputTokens: 12, outputTokens: 3 }),
|
||||
|
||||
@@ -1879,10 +1879,21 @@ export function rehydrateRunnerdUsageNotification(
|
||||
// authority beside the counters so ACPX/managed driver projections and
|
||||
// durable replay cannot certify an incomplete delta as a free receipt.
|
||||
runDeltaComplete: rawParams.runDeltaAvailable === true,
|
||||
...runnerdContextUsage(rawParams.context),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Context-window occupancy carried on PRP `usage.reported`, when the provider reported it. */
|
||||
function runnerdContextUsage(value: unknown): { context?: { usedTokens: number; windowTokens: number } } {
|
||||
const context = record(value);
|
||||
const usedTokens = context.usedTokens;
|
||||
const windowTokens = context.windowTokens;
|
||||
if (typeof usedTokens !== "number" || !Number.isSafeInteger(usedTokens) || usedTokens < 0) return {};
|
||||
if (typeof windowTokens !== "number" || !Number.isSafeInteger(windowTokens) || windowTokens < 1) return {};
|
||||
return { context: { usedTokens, windowTokens } };
|
||||
}
|
||||
|
||||
export function rehydrateRunnerdThreadTokenUsage(
|
||||
cumulative: unknown,
|
||||
): { total: Record<string, unknown> } | null {
|
||||
|
||||
@@ -3673,6 +3673,25 @@ export const usageSchema = {
|
||||
},
|
||||
"runDelta": {
|
||||
"$ref": "#/$defs/measurement"
|
||||
},
|
||||
"context": {
|
||||
"description": "Live context-window occupancy for the provider session after this usage, when the provider reports it.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"usedTokens",
|
||||
"windowTokens"
|
||||
],
|
||||
"properties": {
|
||||
"usedTokens": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"windowTokens": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
|
||||
@@ -22,6 +22,7 @@ export {
|
||||
type RunnerGoalActionRequest,
|
||||
type RunnerGoalActionAccepted,
|
||||
} from "./runner-goal.js";
|
||||
export type { IssueContextUsage } from "./issue-context-usage.js";
|
||||
export { ADAPTER_AUTH_MISSING_CHECK_CODE } from "./adapter-auth-check-code.js";
|
||||
export {
|
||||
CONNECTION_INTENT_AGENT_GUIDANCE,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Latest context-window occupancy for the agent session working on a task,
|
||||
* read from the provider usage the runner recorded for the task's runs.
|
||||
*/
|
||||
export interface IssueContextUsage {
|
||||
runId: string;
|
||||
agentId: string;
|
||||
/** Normalized runner session the measurement belongs to. */
|
||||
sessionId: string | null;
|
||||
usedTokens: number;
|
||||
windowTokens: number;
|
||||
observedAt: string;
|
||||
/** Session totals when the provider reported non-zero counters. */
|
||||
totals: {
|
||||
inputTokens: number;
|
||||
cachedInputTokens: number;
|
||||
outputTokens: number;
|
||||
} | null;
|
||||
/** Provider-reported session cost in USD, when non-zero. */
|
||||
costUsd: number | null;
|
||||
/** Compactions recorded for this session. */
|
||||
compactions: number;
|
||||
}
|
||||
Reference in new issue
Block a user