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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user