Skip to content

feat: add native manual compaction controls - #610

Merged
stamp-build[bot] merged 4 commits into
mainfrom
agent/otter/manual-compaction
Nov 2, 2026
Merged

stamp-build[bot] merged 4 commits into
mainfrom
agent/otter/manual-compaction

Conversation

@stamp-build

@stamp-build stamp-build Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Otter from open-agent-runtime opened this pull request through Stamp
View in Stamp →


Hosts restoring changed Claude/Codex prompt options need native compaction before the next input. Add optional Runtime.manualCompaction and Session.compact() for Claude, Codex and Pi SDK/RPC, with typed completed/rejected/failed/aborted/unconfirmed results tied to native evidence. This is task #154's first PR; the remaining candidates stay undeclared until their separate control and correlation regressions.

  • All four entries require idle sessions. Claude checks a same-process builtin command catalog with no non-builtin compact entry, then matches command, boundary and result identities. Codex associates the native contextCompaction item and its turn, and refuses observed active goals or queued inputs. Pi uses exclusive manual start/end facts. Automatic, child, stale and unrelated completions cannot settle the control.
  • An already aborted signal returns rejected / aborted without sending native work. timeoutMs: 0 still dispatches. After dispatch, timeout or signal stops waiting only: the native operation keeps its slot, late records remain observable, and queue entries wait for native settlement. Claude drains one queued input per turn and reserves it before its native receipt, so a later prompt cannot overtake it. Pi RPC compact has no ordinary 30-second transport deadline.
  • abort() uses the native cancellation control once its target exists. Claude's queued cancellation is aborted; after native execution starts, Claude reports compact_result=failed, so compact returns failed / native_failed independently of the ordinary turn's OAR-aborted outcome. Codex/Pi retain their native aborted result. An interrupt for native unstarted input is written before arrivals held during cancellation observation are drained. Disposal or process exit returns unconfirmed without inventing a compaction terminal or writing a late rejection.
  • Returned reasons use the same credential redactor as records. AgentStatus.compaction exposes only requestId and started?; the exported StatusState checkpoint retains native turn/span correlation. Incremental consumers must persist the entire checkpoint with its cursor, including TaskState.status and SessionView.statusState. The exported statusValue extracts only the display value and cannot replace a resumable checkpoint. Pi's idle state retains the preceding turn outcome.
  • The native resume regression changes system/append options, completes compaction, and inspects the next provider request for the new markers. Repeating Claude 2.1.316 initialize three times preserves settings, permission mode, system/append prompt and tool catalog, with one SessionStart hook and one MCP initialization. This is version-specific Linux evidence; the separate source-read/dispatch window remains non-atomic. Codex local compaction is tested; ChatGPT-login remote compaction remains source-only.

Public record change: turn_ended.inputId? and ordered inputIds? separately copy result.user_message_uuid and user_message_uuids through the common stream-json projection. They appear in public events and Frame.body.events, including ordinary non-compacting turns; raw frames are unchanged. Claude's two queued native messages report the latter singular UUID and both plural UUIDs. The earlier prompt-plus-steer fixture names the original prompt, so OAR infers no first/last rule. Qwen 0.26.5 and Qoder 1.1.81 report neither field, and their result uuid is not substituted. Hosts should settle every entry in inputIds when present. OAR's own interrupt fallback retains its separate carries and mergedInputIds provenance. Eleven existing snapshots add only 30 identity fields verified against their raw results; both additions are documented in the spec and release-note draft.

Validation: the revised implementation passes the full check (3,049 tests and 18 mock behaviors), build, clean packed-consumer type exports, all 14 behavior backends, and full native suites: Claude 2.1.316 (80), Codex 0.166.0 (62), Pi SDK 1.4.0 (97), Pi RPC 1.4.0 (95). The manual-compaction file contributes 33 native controls. Review regressions cover queued cancellation without process kill, separate queue turns, late provenance replies, conflicting command catalogs, goal-turn misattribution, pre-aborted signals, reason redaction, synthetic-terminal absence and checkpoint replay. Pi project fixtures clear and restore inherited PI_PACKAGE_DIR; their 20 cases also pass with this managed host's conflicting value present. After rebasing onto current main, the full check, build and clean packed-consumer validation pass again, as do all three Pi behavior backends and the full Pi SDK 1.5.0 (97) and Pi RPC 1.5.0 (95) vendor suites. The new capability-inventory test uses the Durable 1.4.0 model-catalog alias introduced by the security update. The final cancellation-order revision passes all 14 behavior backends and full Claude 2.1.316 (80), Qwen 0.26.5 (18) and Qoder 1.1.81 (9) vendor suites. Six independent mutations are caught, including the public Codex abort-before-item gate and the Claude result-frame reference. Packed-consumer validation checks both exports of the checkpoint type and its display helper. Windows/macOS validation awaits CI on the revised head.

Evidence, reproductions and release-note draft. No release/version bump or workflow change.

@stamp-build
stamp-build Bot force-pushed the agent/otter/manual-compaction branch from 3d5f0a0 to 2183dd3 Compare November 2, 2026 21:48
@stamp-build
stamp-build Bot merged commit 4bf1e23 into main Nov 2, 2026
36 checks passed
@stamp-build
stamp-build Bot deleted the agent/otter/manual-compaction branch November 2, 2026 23:01
@stamp-build stamp-build Bot mentioned this pull request Nov 2, 2026
stamp-build Bot added a commit that referenced this pull request Nov 2, 2026
<!-- stamp:review:v1 -->
<sup><img src="https://api.raft.build/api/avatars/pixel/bXVn.svg" width="16" height="16" align="absmiddle" alt=""> <b>Cindy</b> from open-agent-runtime opened this pull request through Stamp</sup><br><sup><a href="https://stamp.build/review?receipt=prc_757a5037d345493462e53a75c6fc292d"><picture><source media="(prefers-color-scheme: dark)" srcset="https://stamp.build/assets/brand/stamp-glyph-dark.d907ffcadb44.svg"><img src="https://stamp.build/assets/brand/stamp-glyph-light.58a2c10ed108.svg" width="13" height="12" align="absmiddle" alt=""></picture> View in Stamp →</a></sup>

---
<!-- /stamp:review:v1 -->

Minor release: a host can now ask a runtime to compact its context and wait until the runtime confirms it. This completes the 0.85.0 resume change: a prompt supplied on resume that only takes effect at the next native compaction (Claude's `systemPrompt` and `appendSystemPrompt`, Codex's `appendSystemPrompt`) can now be applied right away in the same session by resuming with the new prompt and then calling `compact()`. Codex's replacement `systemPrompt` already applies immediately. Stream-json turn ends also carry the native input identities.

## New: manual compaction (#610)
- Optional `Runtime.manualCompaction` says before opening whether a runtime supports it: `{ kind: "supported", activeTurn }` or `{ kind: "unsupported", reason }`. An absent declaration means untested, not unsupported. Supported now: Claude, Codex and Pi (in-process and subprocess). Declared unsupported with reasons: Kimi, Qwen, Antigravity, CodeBuddy. Not yet declared: Grok, Qoder, Cursor, OpenCode, Pi Durable and the community Morph runtime.
- Optional `Session.compact({ timeoutMs?, signal? })` returns a `CompactionOutcome` (new exported types `ManualCompaction`, `CompactionOptions`, `CompactionOutcome`):
  - `completed`: the runtime confirmed this compaction (Claude's `compact_boundary`, Codex's compaction item, Pi's manual compaction end); `frame` points at that record.
  - `rejected`: the compaction was not admitted, with a typed `code` and `reason`. Usually nothing reached the runtime, but Codex and Pi can also refuse after the native call (`runtime_refused`, `runtime_exited`), and Claude's command check talks to Claude first. Codes include `busy` (a turn, queued or not yet started input, another compaction, or an active Codex goal), `aborted` (the caller's `signal` was already aborted, so nothing was sent), `unsupported` (for example a project or user command shadows Claude's `/compact`), and the usual unreachable and error codes.
  - `failed`: the runtime reported failure, or the command did not actually compact (Claude reported failure, or its commands changed in between).
  - `aborted`: the runtime itself reported the compaction cancelled.
  - `unconfirmed`: OAR stopped observing without a confirmed result: `timeout`, `signal`, `disposed`, `runtime_exited`, `session_released` or `observation_failed`.
- `compact()` throws `RangeError` when `timeoutMs` is negative or not finite.
- **All current implementations require an idle session.** Codex and Pi would abort a running turn to compact; OAR never does that as a side effect.
- **Waiting limits only stop waiting.** After the request is admitted, `timeoutMs` or `signal` return `unconfirmed`; the native compaction continues and the session stays `compacting` until the runtime settles, and later records still arrive. During that time new prompts, steer and a second `compact()` are refused as `busy`; input queued during the compaction is held by Claude and Pi until it ends, while Codex refuses it as `busy`. `timeoutMs: 0` sends the request without waiting.
- `abort()` during compaction uses the runtime's own cancel once there is something to cancel; before that it is refused as `busy`. A queued Claude `/compact` cancelled before it starts ends `aborted`. A Claude compaction already running and then interrupted is reported by Claude as a failure, so `compact()` returns `failed` with that native record, independent of the turn's own aborted ending. Codex refuses `abort()` as `busy` until its compaction item names the turn.
- Claude: before every `compact()` call, OAR sends Claude an `initialize` request in the same session and sends `/compact` only if Claude reports it as its built-in command. On Claude 2.1.316 (Linux) repeated `initialize` requests did not re-run the SessionStart hook or the HTTP MCP server and left the permission mode, settings, system prompt and tool list unchanged; this is not a claim about every version or every hook implementation.
- Codex evidence covers local compaction; compaction under a ChatGPT login was checked in source only. Pi subprocess compaction is exempt from the usual 30 second RPC request deadline.
- Record stream: new request kind `compact`, new `ControlAction` value `"compact"` (check exhaustive switches over `ControlAction`), and new `RejectionCode` `aborted`.

## Status checkpoint for incremental consumers
- `StatusState` and `statusValue` are exported from the package root and `@botiverse/oar/observe`; `reduceStatus` and `initialStatus` now take and return `StatusState`. Hosts that fold status incrementally must persist the complete `StatusState` together with their cursor and pass it back unchanged. The displayed `AgentStatus` (`statusValue`, `Session.status()`) alone cannot continue a fold through a manual compaction.
- The same applies to the observe helpers: `TaskState.status` is now a `StatusState`, and `SessionView` gains `statusState?`. Persist these whole as well.
- While a manual compaction is in progress, `AgentStatus` is `running` with phase `compacting` and `compaction: { requestId, started? }`.

## Turn input identities
- `turn_ended.inputId?` and `turn_ended.inputIds?` copy the stream-json result's `user_message_uuid` and ordered `user_message_uuids` when the runtime reports them, including on ordinary turns. Neither is derived from the other. When Claude merges several queued inputs into one turn, its `inputId` names the latter input and there is no first or last rule to rely on, so use `inputIds` to settle all of them. OAR's own interrupt fallback is separate and still uses `mergedInputIds` and `carries`. Claude reports these fields; Qwen 0.26.5 and Qoder 1.1.81 currently do not.
- Stored OAR records do not change. Only re-projecting raw Claude native output with this version adds these two fields.

## Docs and experiments
- Qwen compaction attribution (#609): in Qwen's stream-json output, the manual compaction status goes through the same assistant text frames as model output, so OAR cannot tell them apart; automatic compression emits no frame at all; and a user `compress.toml` can replace `/compress`. OAR emits no Qwen compaction events and does not guess from text.
- DeepSeek Harness plugin bridge probe (#607): an external plugin shipped outside DSH can drive DSH's native controls and events without ACP, on DSH 0.2.1-rc.5 and 0.2.2-alpha.2. Experiment only; no DSH runtime yet.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Cindy <open-agent-runtime+cindy@agents.stamp.build>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants