Phase 15: SPEC contract for mid-turn compaction reinject (B-003)

Document source fallback on session_compact, defer steer delivery when
!isIdle, kept-window skill-reinject:inject entries, and double-compact
acceptance criteria before implementation.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-18 22:55:31 +07:00
parent ca97660bac
commit a07ddefb23
+53 -20
View File
@@ -118,7 +118,7 @@ References are relative to {baseDir}.
Для N skills при доставке через `sendUserMessage`: отдельное сообщение на skill, порядок = порядок первого вызова в сессии. Для N skills при доставке через `sendUserMessage`: отдельное сообщение на skill, порядок = порядок первого вызова в сессии.
При доставке через `before_agent_start` (§6.5, режим `defer`, в т.ч. с [pi-auto-compact](https://github.com/capyup/pi-auto-compact)): **одно** injected message со всеми skill-блоками подряд (меньше turn'ов, нет гонки с follow-up). При доставке через `before_agent_start` или mid-turn `sendMessage`/`steer` (§6.5, режим `defer`, в т.ч. с [pi-auto-compact](https://github.com/capyup/pi-auto-compact)): **одно** injected message с `customType: "skill-reinject:inject"` и всеми skill-блоками подряд (меньше turn'ов, нет гонки с follow-up).
--- ---
@@ -141,7 +141,7 @@ interface ExtensionState {
sessionOverride: boolean | null; sessionOverride: boolean | null;
skills: TrackedSkill[]; // dedupe by name, preserve insertion order skills: TrackedSkill[]; // dedupe by name, preserve insertion order
lastCompactionSource: "auto" | "manual" | null; lastCompactionSource: "auto" | "manual" | null;
/** Skills, ожидающие re-inject на следующем before_agent_start (§6.5) */ /** Skills, ожидающие re-inject (§6.5): idle → before_agent_start; mid-turn → steer на session_compact */
pendingReinject: string[]; // skill names pendingReinject: string[]; // skill names
} }
@@ -182,7 +182,12 @@ In-memory кэш + персистенция через `pi.appendEntry` при
Перед re-inject: Перед re-inject:
1. Взять entries от `compactionEntry.firstKeptEntryId` до хвоста ветки. 1. Взять entries от `compactionEntry.firstKeptEntryId` до хвоста ветки.
2. Для каждого tracked skill: если в kept user messages есть `<skill name="{name}"`**пропустить** re-inject для этого skill. 2. Для каждого tracked skill: если в kept window есть `<skill name="{name}"`**пропустить** re-inject для этого skill.
3. Источники текста в kept slice:
- user messages (`type === "message"`, `role === "user"`);
- наши reinject-сообщения: `type === "custom_message"` с `customType === "skill-reinject:inject"` (§6.5.1) — skill-блоки в `content`, не в user role.
Пункт 3 закрывает ложное «skill вне kept» после успешного defer-reinject: `custom_message` не user message, но skill уже в контексте turn'а. Без этого второй compaction подряд снова планирует reinject (лишний inject; см. §16.6).
### 6.5. Доставка после compaction ### 6.5. Доставка после compaction
@@ -191,9 +196,20 @@ In-memory кэш + персистенция через `pi.appendEntry` при
#### 6.5.1. Режим `defer` (рекомендуемый по умолчанию при наличии pi-auto-compact) #### 6.5.1. Режим `defer` (рекомендуемый по умолчанию при наличии pi-auto-compact)
1. На `session_compact`: записать имена skills в `state.pendingReinject` (не вызывать `sendUserMessage`). 1. На `session_compact`: записать имена skills в `state.pendingReinject` (не вызывать `sendUserMessage`).
2. На следующем `before_agent_start`: если `pendingReinject` не пуст — вернуть injected `message` с объединёнными skill-блоками, очистить очередь. 2. Доставка — **одна точка решения** на `session_compact` по `ctx.isIdle()`:
Skills попадают в контекст **в том же turn**, что и kickoff-сообщение (в т.ч. auto-continue от pi-auto-compact), **без гонки** `sendUserMessage`. | Условие на `session_compact` | Доставка |
|------------------------------|----------|
| `ctx.isIdle()` | Оставить `pendingReinject`; consume на следующем `before_agent_start` (kickoff pi-auto-compact follow-up / user prompt) |
| `!ctx.isIdle()` (mid-turn compaction) | Немедленно: `pi.sendMessage({ customType: "skill-reinject:inject", content, display: true }, { deliverAs: "steer" })`; очистить `pendingReinject`; записать `compactionEntry.id` в runtime, чтобы `before_agent_start` **не** дублировал inject для этого compaction |
3. На `before_agent_start` (idle-path): если `pendingReinject` не пуст и steer для этого compaction ещё не доставлялся — вернуть injected `message` с `customType: "skill-reinject:inject"` и объединёнными skill-блоками, очистить очередь.
**Mid-turn steer:** `deliverAs: "steer"` ставит skill-блоки в очередь до следующего LLM-вызова **в том же turn** (после tool result, без user prompt). Это закрывает сценарий, когда pi-auto-compact компактирует mid-turn и **не** шлёт follow-up (агент не idle) — consume только на `before_agent_start` теряет skill до следующего сообщения пользователя (B-003).
**Не** вызывать `sendUserMessage` в `session_compact` (§16.2). Steer — не user message и не конкурирует с pi-auto-compact follow-up на turn boundary.
На turn-boundary path skills попадают в контекст **в том же turn**, что и kickoff-сообщение (в т.ч. auto-continue от pi-auto-compact), **без гонки** `sendUserMessage`.
#### 6.5.2. Режим `immediate` (`sendUserMessage`) #### 6.5.2. Режим `immediate` (`sendUserMessage`)
@@ -287,19 +303,30 @@ Extension event `session_compact` **не** передаёт `reason` ([SessionCo
**Стратегия v1:** **Стратегия v1:**
```typescript ```typescript
// input handler (до expansion) // input handler (до expansion) — единственный путь manual
if (text.startsWith("/compact")) pendingCompactionSource = "manual"; if (text.startsWith("/compact")) pendingCompactionSource = "manual";
// session_before_compact function ensureCompactionSourceMarked(runtime) {
if (pendingCompactionSource !== "manual") pendingCompactionSource = "auto"; if (runtime.pendingCompactionSource !== "manual") {
runtime.pendingCompactionSource = "auto";
}
}
// session_before_compact
ensureCompactionSourceMarked(runtime);
// session_compact — safety net, если before_compact не пришёл (mid-turn, гонка хуков)
ensureCompactionSourceMarked(runtime);
// session_compact
const shouldReinject = const shouldReinject =
effectiveEnabled && effectiveEnabled &&
(pendingCompactionSource === "auto" || settings.reinjectOnManualCompaction); (pendingCompactionSource === "auto" || settings.reinjectOnManualCompaction);
state.lastCompactionSource = pendingCompactionSource;
pendingCompactionSource = null; pendingCompactionSource = null;
``` ```
**Fallback на `session_compact`:** если `session_before_compact` не отработал, `pendingCompactionSource` остаётся `null` → без fallback `lastCompactionSource: null`, `shouldReinject === false`, status `last compaction: none` при реальном auto compact (B-003). Всё, что не помечено явным `/compact` в `input`, на `session_compact` считается **auto**.
Ручной RPC compact (`{type: "compact"}`) тоже помечать как `manual` если extension получает соответствующий сигнал (v2). Ручной RPC compact (`{type: "compact"}`) тоже помечать как `manual` если extension получает соответствующий сигнал (v2).
--- ---
@@ -420,9 +447,10 @@ cp -r src/index.ts ~/.pi/agent/extensions/skill-reinject.ts
- [ ] Tracked skills: `/skill:name`, skill-block в user msg, `read` на `SKILL.md` - [ ] Tracked skills: `/skill:name`, skill-block в user msg, `read` на `SKILL.md`
- [ ] State переживает `/resume` той же сессии - [ ] State переживает `/resume` той же сессии
- [ ] Footer status показывает on/off и count - [ ] Footer status показывает on/off и count
- [ ] Нет duplicate skill blocks для skills уже в kept window - [ ] Нет duplicate skill blocks для skills уже в kept window (включая `skill-reinject:inject` в kept slice, §6.4)
- [ ] С [pi-auto-compact](https://github.com/capyup/pi-auto-compact): auto-detect, режим `defer`, нет гонки `sendUserMessage`, continue после compaction работает - [ ] С [pi-auto-compact](https://github.com/capyup/pi-auto-compact): auto-detect, режим `defer`, нет гонки `sendUserMessage`, continue после compaction работает
- [ ] С pi-auto-compact: ручной `/compact` не ломает ни один extension - [ ] С pi-auto-compact: ручной `/compact` не ломает ни один extension
- [ ] **Два auto compaction подряд** (в т.ч. второй mid-turn без user prompt): после каждого — tracked skills в контексте или явный skip (`skipped-kept` в `debug`); `/skill-reinject``last compaction: auto`, не `none` (§8 fallback, §6.5.1 steer)
--- ---
@@ -442,7 +470,7 @@ cp -r src/index.ts ~/.pi/agent/extensions/skill-reinject.ts
|------|-----------| |------|-----------|
| Re-inject раздувает контекст (N больших skills) | `skillReinject.maxSkills` (default unlimited; soft warn > 3) | | Re-inject раздувает контекст (N больших skills) | `skillReinject.maxSkills` (default unlimited; soft warn > 3) |
| `sendUserMessage` запускает нежеланный turn | `defer` по умолчанию при pi-auto-compact; иначе `triggerTurn: false` | | `sendUserMessage` запускает нежеланный turn | `defer` по умолчанию при pi-auto-compact; иначе `triggerTurn: false` |
| Гонка `sendUserMessage` с pi-auto-compact follow-up | §16: не слать в `session_compact`; `before_agent_start` | | Гонка `sendUserMessage` с pi-auto-compact follow-up | §16: не слать `sendUserMessage` в `session_compact`; idle → `before_agent_start`; mid-turn → `sendMessage`/`steer` (§6.5.1) |
| Нельзя отличить auto/manual без эвристики | input hook на `/compact`; pi-auto-compact manual `/compact` не в его `onComplete` | | Нельзя отличить auto/manual без эвристики | input hook на `/compact`; pi-auto-compact manual `/compact` не в его `onComplete` |
| Двойной compaction (Pi default + pi-auto-compact) | документировать; опционально warn в status | | Двойной compaction (Pi default + pi-auto-compact) | документировать; опционально warn в status |
| Pi изменит формат skill block | version в state; тест на `parseSkillBlock` regex | | Pi изменит формат skill block | version в state; тест на `parseSkillBlock` regex |
@@ -473,7 +501,7 @@ cp -r src/index.ts ~/.pi/agent/extensions/skill-reinject.ts
1. Агент перестаёт быть idle → pi-auto-compact **не** шлёт follow-up → **сессия замирает** после compaction. 1. Агент перестаёт быть idle → pi-auto-compact **не** шлёт follow-up → **сессия замирает** после compaction.
2. Или оба шлют сообщения в одном tick → `"Agent is already processing"` ([комментарий в auto-compact.ts](https://github.com/capyup/pi-auto-compact/blob/main/extensions/auto-compact.ts)). 2. Или оба шлют сообщения в одном tick → `"Agent is already processing"` ([комментарий в auto-compact.ts](https://github.com/capyup/pi-auto-compact/blob/main/extensions/auto-compact.ts)).
**Вывод:** при обнаружении pi-auto-compact re-inject **обязан** идти через `defer` + `before_agent_start`, не через конкурирующий `sendUserMessage`. **Вывод:** при обнаружении pi-auto-compact re-inject **обязан** идти через режим `defer`, не через конкурирующий `sendUserMessage` в `session_compact`. Idle: `before_agent_start`; mid-turn (`!isIdle`): `sendMessage` с `deliverAs: "steer"` (§6.5.1) — тот же инвариант §16.5, без user message.
### 16.3. Целевой совместный flow ### 16.3. Целевой совместный flow
@@ -483,15 +511,18 @@ turn_start / turn_end / emergency
pi-auto-compact: ctx.compact() pi-auto-compact: ctx.compact()
├── session_before_compact (наш hook: только mark source=auto) ├── session_before_compact (наш hook: mark source=auto)
├── … Pi summarization … ├── … Pi summarization …
├── session_compact (наш hook: pendingReinject := skills kept) ├── session_compact (наш hook: pendingReinject := skills kept)
└── onComplete → setImmediate → sendUserMessage("Auto-compact ran…") │ │
│ ├── isIdle → pending для before_agent_start
│ └── !isIdle → sendMessage steer (§6.5.1), без follow-up
└── onComplete → setImmediate → if isIdle: sendUserMessage("Auto-compact ran…")
prompt() → before_agent_start (наш hook) prompt() → before_agent_start (наш hook, idle-path)
├── inject: <skill>…</skill> × N ← re-inject ├── inject: <skill>…</skill> × N ← re-inject (если не steer'нули)
└── user prompt: "Auto-compact ran…" ← kickoff pi-auto-compact └── user prompt: "Auto-compact ran…" ← kickoff pi-auto-compact
@@ -519,7 +550,7 @@ function detectPiAutoCompact(pi: ExtensionAPI): boolean {
| Значение | Поведение | | Значение | Поведение |
|----------|-----------| |----------|-----------|
| `"auto"` (default) | `defer` если `detectPiAutoCompact()`, иначе по `triggerTurn` | | `"auto"` (default) | `defer` если `detectPiAutoCompact()`, иначе по `triggerTurn` |
| `"defer"` | всегда `before_agent_start`, даже без pi-auto-compact | | `"defer"` | idle: `before_agent_start`; mid-turn (`!isIdle`): steer на `session_compact` (§6.5.1); никогда `sendUserMessage` в `session_compact` |
| `"immediate"` | всегда `sendUserMessage` (для отладки; с pi-auto-compact — риск §16.2) | | `"immediate"` | всегда `sendUserMessage` (для отладки; с pi-auto-compact — риск §16.2) |
| `"off"` | игнорировать detect; только `triggerTurn` | | `"off"` | игнорировать detect; только `triggerTurn` |
@@ -538,10 +569,12 @@ function detectPiAutoCompact(pi: ExtensionAPI): boolean {
| Сценарий | Результат | | Сценарий | Результат |
|----------|-----------| |----------|-----------|
| Auto-compact + follow-up | Следующий `before_agent_start` — наш inject; skills в контексте | | Auto-compact + follow-up (idle) | Следующий `before_agent_start` — наш inject; skills в контексте |
| User печатает во время compaction | pi-auto-compact молчит; user prompt → наш inject на его turn | | Auto-compact **mid-turn** (`!isIdle`, нет follow-up) | Steer на `session_compact` (§6.5.1); skills до следующего LLM-вызова в том же turn |
| User печатает во время compaction | pi-auto-compact молчит; user prompt → наш inject на его turn (`before_agent_start`) |
| pi-auto-compact `keep-bookends` / `summarize-all` | Kept window определяем по `compactionEntry.firstKeptEntryId` в session branch (§6.4), не по их процентам | | pi-auto-compact `keep-bookends` / `summarize-all` | Kept window определяем по `compactionEntry.firstKeptEntryId` в session branch (§6.4), не по их процентам |
| Два compaction подряд | Каждый `session_compact` пересчитывает `pendingReinject`; дедуп по kept window | | Два compaction подряд | Каждый `session_compact` пересчитывает `pendingReinject`; source fallback §8; дедуп по kept window включая `skill-reinject:inject` (§6.4) |
| Turn-boundary compact после steer | `before_agent_start` не дублирует inject, если steer уже доставил для `compactionEntry.id` этого compaction |
### 16.7. Coexistence с Pi default auto-compaction ### 16.7. Coexistence с Pi default auto-compaction