|
| 1 | +# Specification: Approval numbering, header clarity, and explicit `/compact` |
| 2 | + |
| 3 | +## Overview |
| 4 | + |
| 5 | +This spec defines the behavior for two user-facing changes in the `agent` CLI: |
| 6 | + |
| 7 | +1. Each interactive approval within a single user turn is numbered, and repeats carry a `different from #N` hint. |
| 8 | +2. The `chat` command supports `/compact` (and keeps `/summarize` as a deprecated alias) to run the existing real-model summarizer with a confirmation prompt. |
| 9 | + |
| 10 | +The change is local to the terminal policy in `apps/agent/src/terminal.rs` and the chat loop in `apps/agent/src/main.rs`. The `agent-core` and `agent-runtime` crates do not change shape. |
| 11 | + |
| 12 | +--- |
| 13 | + |
| 14 | +## 1. Requirements |
| 15 | + |
| 16 | +### R1: Numbered approval prompts |
| 17 | + |
| 18 | +**Given**: A turn in `chat` that triggers a tool with an `approval` preview. |
| 19 | +**When**: `Policy::approve(request, cancel)` is invoked. |
| 20 | +**Then**: The terminal prints the request number for that turn in the header line, and increments an internal counter that resets at the start of each new turn. |
| 21 | + |
| 22 | +**Header shape**: |
| 23 | + |
| 24 | +``` |
| 25 | +Approval for <request.action> (request #N this turn): |
| 26 | + Action: <request.action> |
| 27 | + Effect: <one-line summary derived from preview> |
| 28 | +<preview> |
| 29 | +Approve this exact change? [y/N] |
| 30 | +``` |
| 31 | + |
| 32 | +When `N > 1`, the header suffix becomes `(request #N this turn, different from #<N-1>):`. |
| 33 | + |
| 34 | +### R2: Per-turn counter lifecycle |
| 35 | + |
| 36 | +**Given**: A turn has multiple approval requests. |
| 37 | +**When**: The next turn starts (`chat` reads a new line from `Input`, or `Run`/`Resume` is invoked). |
| 38 | +**Then**: The counter resets to 0 *before* the next call to `run(...)`. |
| 39 | + |
| 40 | +The counter is held in a `Mutex<u64>` on `Policy`. The CLI calls `Policy::reset_turn()` exactly once before each new turn. `approve()` increments the counter on entry, after the `--allow` short-circuit is taken. `reset_turn()` is the only way to bring the counter back to 0. |
| 41 | + |
| 42 | +### R3: `Effect:` line derivation |
| 43 | + |
| 44 | +**Given**: An `ApprovalRequest` with a non-empty `preview`. |
| 45 | +**When**: The header is rendered. |
| 46 | +**Then**: A single `Effect:` line is included, drawn from the first non-empty line of `preview` if it starts with a known prefix (`Directory:`, `Trusted local MCP server`, `Server:`, `MCP tool`, or a unified-diff `--- ` line). Otherwise the line is omitted. |
| 47 | + |
| 48 | +This avoids parsing the full diff and stays within the existing preview content. The diff itself is still printed in full below the header. |
| 49 | + |
| 50 | +### R4: `/compact` command in `chat` |
| 51 | + |
| 52 | +**Given**: A user types `/compact` (or `/summarize`) at the `agent>` prompt. |
| 53 | +**When**: The chat loop reads the line. |
| 54 | +**Then**: The CLI prints a confirmation prompt `Compact session now? Older history will be summarized; originals stay in the database. [y/N]`. Only a `y`/`Y`/`yes` line continues. |
| 55 | + |
| 56 | +**On yes**: The CLI invokes the loop's real-model summarizer on the same session, persists the result, and emits the same `Event::Compacted` the automatic path emits. The terminal prints `Context compacted; original history retained.` |
| 57 | + |
| 58 | +**On no or empty input**: The CLI prints `Compaction skipped.` and returns to `agent>` without touching the session. |
| 59 | + |
| 60 | +`/compact` is not exposed in `Run` (single prompt, no chat). `Run` already calls the loop exactly once; the automatic threshold is the only compaction trigger there. |
| 61 | + |
| 62 | +### R5: Reuse of existing summarization |
| 63 | + |
| 64 | +**Given**: The loop is asked to compact now. |
| 65 | +**When**: It runs. |
| 66 | +**Then**: It must use the same `summary_request` already used by `StandardLoop::context()` (system prompt `Summarize repository work…`, no tools, `max_tokens=1024`, prior summary appended if present). The `Event::Compacted { through }` is recorded, `session.summary` and `session.summary_through` are updated, and the SQLite store is saved. |
| 67 | + |
| 68 | +No new request shape. No new model call surface. No new provider path. |
| 69 | + |
| 70 | +### R6: Approval state on denial |
| 71 | + |
| 72 | +**Given**: A `Tool` whose `prepare()` returned an `approval` and the user denied. |
| 73 | +**When**: The denial reaches the loop. |
| 74 | +**Then**: The loop returns `AgentError::Tool("action denied by user; do not retry without a new user request")` for that iteration. The terminal does not block subsequent prompts; the next turn starts cleanly with a reset counter. |
| 75 | + |
| 76 | +### R7: Backwards compatibility |
| 77 | + |
| 78 | +**Given**: Existing callers and tests of the agent runtime and core crates. |
| 79 | +**When**: This change is built. |
| 80 | +**Then**: No public API of `agent-core` or `agent-runtime` changes. The only API change is the addition of `Policy::reset_turn()` and `Policy::turn_count()` on the binary-local `Policy` type. The `CompactNow` trait added to expose manual compaction is `impl`-only for `StandardLoop` and lives in the binary crate. |
| 81 | + |
| 82 | +--- |
| 83 | + |
| 84 | +## 2. Scenarios |
| 85 | + |
| 86 | +### Scenario 1: Single approval, no repeats |
| 87 | + |
| 88 | +``` |
| 89 | +agent> In README.md add a line "Approved once." |
| 90 | +Approval for native.write_file (request #1 this turn): |
| 91 | + Action: native.write_file |
| 92 | + Effect: write README.md |
| 93 | +--- README.md 2026-10-09 |
| 94 | ++++ README.md 2026-10-09 |
| 95 | +@@ |
| 96 | + Marea Demo |
| 97 | +... |
| 98 | ++Approved once. |
| 99 | +Approve this exact change? [y/N] y |
| 100 | +Tool: write_file |
| 101 | +``` |
| 102 | + |
| 103 | +### Scenario 2: Denied then reformulated retry |
| 104 | + |
| 105 | +``` |
| 106 | +agent> In README.md add a line "Approved once." |
| 107 | +Approval for native.write_file (request #1 this turn): |
| 108 | + Action: native.write_file |
| 109 | + Effect: write README.md |
| 110 | +<diff A> |
| 111 | +Approve this exact change? [y/N] n |
| 112 | +Tool failed: tool: action denied by user; do not retry without a new user request |
| 113 | +
|
| 114 | +(model reformulates and asks again) |
| 115 | +
|
| 116 | +Approval for native.write_file (request #2 this turn, different from #1): |
| 117 | + Action: native.write_file |
| 118 | + Effect: write README.md |
| 119 | +<diff B> |
| 120 | +Approve this exact change? [y/N] |
| 121 | +``` |
| 122 | + |
| 123 | +### Scenario 3: `/compact` accepted |
| 124 | + |
| 125 | +``` |
| 126 | +agent> /compact |
| 127 | +Compact session now? Older history will be summarized; originals stay in the database. [y/N] y |
| 128 | +Context compacted; original history retained. |
| 129 | +agent> |
| 130 | +``` |
| 131 | + |
| 132 | +### Scenario 4: `/compact` declined |
| 133 | + |
| 134 | +``` |
| 135 | +agent> /compact |
| 136 | +Compact session now? Older history will be summarized; originals stay in the database. [y/N] n |
| 137 | +Compaction skipped. |
| 138 | +agent> |
| 139 | +``` |
| 140 | + |
| 141 | +### Scenario 5: `/summarize` alias |
| 142 | + |
| 143 | +Same behavior as Scenario 3 / 4, with the alias accepted by the parser. |
| 144 | + |
| 145 | +--- |
| 146 | + |
| 147 | +## 3. API Changes |
| 148 | + |
| 149 | +### Terminal |
| 150 | + |
| 151 | +```rust |
| 152 | +// apps/agent/src/terminal.rs |
| 153 | + |
| 154 | +pub struct Policy { |
| 155 | + pub input: Option<Arc<Input>>, |
| 156 | + pub allowed: BTreeSet<String>, |
| 157 | + turn_count: std::sync::Mutex<u64>, |
| 158 | +} |
| 159 | + |
| 160 | +impl Policy { |
| 161 | + pub fn reset_turn(&self) { ... } |
| 162 | + pub fn turn_count(&self) -> u64 { ... } |
| 163 | +} |
| 164 | +``` |
| 165 | + |
| 166 | +### Compact trait |
| 167 | + |
| 168 | +```rust |
| 169 | +// apps/agent/src/main.rs (or a new file under apps/agent/src/) |
| 170 | + |
| 171 | +#[async_trait::async_trait] |
| 172 | +pub trait CompactNow { |
| 173 | + async fn compact_now( |
| 174 | + &self, |
| 175 | + session: &mut Session, |
| 176 | + approvals: &dyn ApprovalPolicy, |
| 177 | + sink: &dyn EventSink, |
| 178 | + cancel: CancellationToken, |
| 179 | + ) -> Result<()>; |
| 180 | +} |
| 181 | + |
| 182 | +impl CompactNow for StandardLoop { ... } |
| 183 | +``` |
| 184 | + |
| 185 | +The composition kernel already returns `LoopService(pub Arc<dyn AgentLoop>)`. We either (a) downcast in the CLI when the concrete type is `StandardLoop` (no trait change), or (b) keep a parallel `Arc<StandardLoop>` for the binary. We choose (b) to avoid `Any::downcast` fragility; the loop is built locally in the binary so the concrete handle is available. |
| 186 | + |
| 187 | +### CLI command |
| 188 | + |
| 189 | +No new `clap` subcommand. `/compact` is intercepted inside the `chat` loop, before invoking `implementation.0.run(...)`. `Run` does not expose it. |
| 190 | + |
| 191 | +--- |
| 192 | + |
| 193 | +## 4. Configuration |
| 194 | + |
| 195 | +No configuration change. The temporary test config that lowers `context_tokens` to 4096 still triggers automatic compaction; `/compact` complements, not replaces, the threshold. |
| 196 | + |
| 197 | +--- |
| 198 | + |
| 199 | +## 5. Metrics and Observability |
| 200 | + |
| 201 | +No new metrics. The existing `Event::Compacted { through }` already records the path. Whether the user invoked `/compact` or the threshold fired is visible in the session log by the absence/presence of the user message preceding it. |
| 202 | + |
| 203 | +--- |
| 204 | + |
| 205 | +## 6. Testing Requirements |
| 206 | + |
| 207 | +### Unit / integration (apps/agent/tests/cli.rs) |
| 208 | + |
| 209 | +- `two_consecutive_approvals_number_them_and_deny_preserves_workspace` — drives the loop with a scripted provider that emits `write_file` twice in one turn, asserts the captured stderr contains `request #1 this turn` and `request #2 this turn, different from #1`, denies the second, and verifies the file is unchanged. |
| 210 | +- `compact_command_summarizes_when_accepted` — uses the mock provider to send a long history, types `/compact`, accepts, and asserts a `summary` is now present in the session. |
| 211 | +- `compact_command_skips_when_declined` — same setup, declines, asserts the session has no `summary`. |
| 212 | + |
| 213 | +### Existing tests must continue to pass |
| 214 | + |
| 215 | +- `cargo test -p agent-runtime --test transports` (6 tests) |
| 216 | +- `cargo test -p agent-runtime --test coding_workflow` (27 tests) |
| 217 | +- `cargo test -p cortex-agent --test cli` (2 tests; new tests added to this file) |
| 218 | + |
| 219 | +--- |
| 220 | + |
| 221 | +## 7. Open Questions (Deferred) |
| 222 | + |
| 223 | +- Per-turn approval summary line at the end of the turn. |
| 224 | +- A `--strict` mode that re-prompts on denial instead of letting the loop surface the error to the model. |
| 225 | +- A `/compact dry-run` that prints the projected summary size without running the model. |
0 commit comments