Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion docs/engineering-invariants.md
Original file line number Diff line number Diff line change
Expand Up @@ -429,9 +429,35 @@ Each entry below points at a release note in `docs/releases/` and the invariant(
- **Root cause:** the pinned host's `LLMRequestPrep.prepare` (v1.18.3, `session/llm/request.ts`) pre-joins the base prompt into `system[0]`, triggers `experimental.chat.system.transform` with `{sessionID, model}` and the shared array, coalesces only when `system.length > 2 && system[0] === header`, then materializes one `{role:'system'}` message per surviving entry. The plugin's guidance producers append entries, so every non-OAuth provider receives exactly 2 system messages — model-independent. The plugin chain read `input.model` only for context-window bookkeeping; no capability resolution existed anywhere in `src/`.
- **Fix:** `src/hooks/system-render-boundary.ts`, registered LAST in the system chain (after the role filter — pinned in `tests/unit/hooks/hook-composition-order.test.ts`). `resolveSystemRenderCapability` is a fail-open ladder evaluated in order: (1) `providerID` matching a known cache-capable prefix (`anthropic*`) → `multi-system` — the veto runs FIRST so a strict-family model id behind a cache-capable gateway is never collapsed; (2) `model.id` matching a documented strict family (`qwen*`, `gemma*`, segment-anchored) → `strict-single-system`; (3) anything else/unreadable → `multi-system`, byte-identical pre-fix behavior. Strict models with >1 entry get the array collapsed IN PLACE to exactly one blank-line-joined entry (empty strings dropped, base header first, no fabrication); multi-system arrays are never touched — the host's prompt-cache breakpoints on the first two system messages stay put. The companion failure interpretation: `classifyProviderFailure` now recognizes deterministic request-shape rejections (`REQUEST_SHAPE_REJECTION_PATTERN`, `src/utils/provider-error-classification.ts`) as `provider.request_shape` / `do_not_retry` — never the transient/generic-retry path (invariant 9: "deterministic provider payload failures are not generic retries").
- **Known limitation:** host request paths that bypass `output.system` — OpenAI OAuth and workflow models consume the system surface via `options.instructions` — cannot be shaped by any plugin hook and are out of scope.
- **Durable guards:** `tests/unit/hooks/system-render-boundary.test.ts` (ladder symmetry + gateway-relabel locks + in-place identity + separator pin + falsifiability via the pure seam); `tests/integration/system-render-boundary-registered.test.ts` (registered-host journeys: strict architect/build/auxiliary render exactly one system message with guidance retained; guidance-free turns byte-identical; cache-capable keeps the stable-header-first two-entry shape); `tests/unit/failures/invocation-failure-request-shape.test.ts` (exact category + transient negative controls + shell-text fallthrough unchanged).
- **Durable guards:** `tests/unit/hooks/system-render-boundary.test.ts` (ladder symmetry + gateway-relabel locks + in-place identity + separator pin + falsifiability via the pure seam); `tests/integration/system-render-boundary-registered.test.ts` (registered-host journeys: strict architect/build/auxiliary render exactly one system message while architect guidance remains on the trailing carrier; guidance-free turns byte-identical; cache-capable keeps the stable system prefix while guidance remains on the trailing carrier); `tests/unit/failures/invocation-failure-request-shape.test.ts` (exact category + transient negative controls + shell-text fallthrough unchanged).
- **Maps to AGENTS.md:** invariants 10 (chat/system-message hook contracts — in-place mutation) and 9 (guardrails/retry — deterministic non-retry).

### Issue #2759 — architect prompt-cache prefix stability

- **Symptom:** stateful architect guidance from `system-enhancer` changed the
host's cache-sensitive system tail on every turn, invalidating prompt-cache
reuse even when the persisted conversation history was unchanged.
- **Fix:** OpenCode v1.18.3 invokes `experimental.chat.messages.transform`
before `experimental.chat.system.transform`. Session-bound architect enhancer
output is staged in a request-local `WeakMap` at the start of the messages
chain, role-filtered and combined with the conditional command banner near
the end, then appended as one fenced user-role carrier. The system hook still
captures authoritative model/context metadata, but skips the architect
enhancer and command banner for that same request; non-architect and
sessionless agent-generation calls retain the system path.
- **Ordering contract:** after legacy system materialization, guidance carriers
are stably partitioned to the message-array tail in place, preserving relative
order and object identity. Final context accounting runs after that partition
and skips carriers when selecting the latest real user message. A bounded
one-shot compaction marker suppresses only the immediately following architect
bridge so compaction summaries do not gain live-turn guidance.
- **Durable guards:** the shared `isSessionBoundArchitect` predicate is used by
both surfaces; bounded live model identity is seeded at `chat.message`; the
request-local staging map cannot persist conversation text or cross sessions;
strict Qwen/Gemma system rendering remains on the system boundary.
- **Maps to AGENTS.md:** invariants 8 (bounded session state) and 10
(host-order, in-place chat/system message contracts).


## Invariants — anti-pattern, required pattern, verification

Expand Down
26 changes: 26 additions & 0 deletions docs/releases/pending/issue-2759-prompt-cache-prefix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Stable architect prompt-cache prefixes (#2759)

## What

- Moved session-bound architect enhancer guidance and the conditional `/swarm`
command rule to a trailing, host-renderable user-role carrier.
- Preserved the byte-identical conversation prefix across architect turns while
retaining the existing strict Qwen/Gemma system-rendering boundary.
- Added compaction-aware one-shot suppression so live-turn guidance is not copied
into compaction summaries.

## Why

Per-step architect guidance was changing the host's cache-sensitive system tail,
which prevented prompt-cache reuse even when the conversation history was
unchanged. The new request-boundary staging follows OpenCode's actual
`messages.transform`-before-`system.transform` order and keeps dynamic guidance
renderable without polluting the stable prefix.

## How to use

No configuration or workflow changes are required.

## Migration notes

None required.
2 changes: 1 addition & 1 deletion scripts/retention-registry.data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3063,7 +3063,7 @@ export const RETENTION_REGISTRY: readonly RetentionRow[] = [
writerModules: ['src/memory/run-log.ts', 'src/memory/injector.ts'],
writerCitations: [
'src/memory/run-log.ts:53 appendMemoryRunLog — appendCappedJsonl with MAX_RUN_LOG_ENTRIES 2000 FIFO per run file (:70-72)',
'src/memory/injector.ts:519 maybeWriteUnitIdProbe — env-gated diagnostic (OPENCODE_SWARM_MEMORY_UNITID_PROBE=1); MAX_UNITID_PROBE_ENTRIES 2000 FIFO (:517,:545-547)',
'src/memory/injector.ts:520 maybeWriteUnitIdProbe — env-gated diagnostic (OPENCODE_SWARM_MEMORY_UNITID_PROBE=1); MAX_UNITID_PROBE_ENTRIES 2000 FIFO (:518,:546-548)',
],
readerCitations: ['consumers read JSONL directly (injector/reflection paths); each file ≤2000 entries by the write-side cap'],
schemaVersion: 'run-log event shapes',
Expand Down
131 changes: 89 additions & 42 deletions src/commands/full-auto.regression.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,52 @@
* 4. Counter reset side-effects are visible after disable
*/

import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
} from 'bun:test';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { createSystemEnhancerHook } from '../hooks/system-enhancer';
import type { HostPartsMessage } from '../../tests/helpers/host-contract-v1_18_3';
import {
bootSwarmPluginHost,
createPluginHostProject,
} from '../../tests/helpers/plugin-host';
import { isGuidanceCarrier } from '../hooks/system-guidance-carrier';
import { getAgentSession, hasActiveFullAuto, swarmState } from '../state';
import { handleFullAutoCommand } from './full-auto';

describe('Full-Auto Mode Regression Tests', () => {
let testSessionId: string;
let tmpDir: string;
let originalXdg: string | undefined;
let registeredHostDirectory: string;
let registeredHost: Awaited<ReturnType<typeof bootSwarmPluginHost>>;

beforeAll(async () => {
registeredHostDirectory = createPluginHostProject(
'full-auto-regression-host',
);
registeredHost = await bootSwarmPluginHost(registeredHostDirectory, {
knowledge: { enabled: false, hive_enabled: false },
memory: { enabled: false },
hooks: { delegation_gate: false },
});
});

afterAll(() => {
try {
fs.rmSync(registeredHostDirectory, { recursive: true, force: true });
} catch {
// SQLite handles may remain open briefly on Windows; best effort only.
}
});

beforeEach(() => {
testSessionId = `full-auto-regression-${Date.now()}`;
Expand Down Expand Up @@ -88,6 +122,44 @@ describe('Full-Auto Mode Regression Tests', () => {
}
});

async function registeredGuidanceMessages(
sessionID: string | undefined = testSessionId,
): Promise<HostPartsMessage[]> {
const messages: HostPartsMessage[] = [
{
info: {
id: `full-auto-request-${sessionID ?? 'anonymous'}`,
role: 'user',
agent: 'architect',
...(sessionID ? { sessionID } : {}),
},
parts: [{ type: 'text', text: 'continue' }],
},
];
if (sessionID) {
await registeredHost.hooks['chat.message'](
{ sessionID, agent: 'architect' },
{},
);
}
await registeredHost.hooks['experimental.chat.messages.transform'](
{},
{ messages },
);
return messages;
}

function guidanceText(messages: HostPartsMessage[]): string {
return messages
.filter((message) => isGuidanceCarrier(message))
.flatMap((message) =>
message.parts
.filter((part) => part.type === 'text')
.map((part) => part.text ?? ''),
)
.join('\n');
}

// ============================================
// 1. /swarm full-auto command toggle behavior
// ============================================
Expand Down Expand Up @@ -238,14 +310,9 @@ describe('Full-Auto Mode Regression Tests', () => {
const session = getAgentSession(testSessionId);
session!.fullAutoMode = true;

const hook = createSystemEnhancerHook({} as any, tmpDir);
const output = { system: [] as string[], messages: [] as string[] };
// @ts-expect-error - testing internal hook interface
await hook['experimental.chat.system.transform'](
{ sessionID: testSessionId },
output,
const systemPrompt = guidanceText(
await registeredGuidanceMessages(testSessionId),
);
const systemPrompt = output.system.join('\n');

expect(systemPrompt).toContain('## ⚡ FULL-AUTO MODE ACTIVE');
expect(systemPrompt).toContain('without a human in the loop');
Expand All @@ -255,14 +322,9 @@ describe('Full-Auto Mode Regression Tests', () => {
const session = getAgentSession(testSessionId);
session!.fullAutoMode = false;

const hook = createSystemEnhancerHook({} as any, tmpDir);
const output = { system: [] as string[], messages: [] as string[] };
// @ts-expect-error - testing internal hook interface
await hook['experimental.chat.system.transform'](
{ sessionID: testSessionId },
output,
const systemPrompt = guidanceText(
await registeredGuidanceMessages(testSessionId),
);
const systemPrompt = output.system.join('\n');

expect(systemPrompt).not.toContain('## ⚡ FULL-AUTO MODE ACTIVE');
expect(systemPrompt).not.toContain('without a human in the loop');
Expand All @@ -272,20 +334,15 @@ describe('Full-Auto Mode Regression Tests', () => {
const session = getAgentSession(testSessionId);
session!.fullAutoMode = true;

const hook = createSystemEnhancerHook({} as any, tmpDir);
const output = { system: [] as string[], messages: [] as string[] };
// @ts-expect-error - testing internal hook interface
await hook['experimental.chat.system.transform'](
{ sessionID: testSessionId },
output,
const systemPrompt = guidanceText(
await registeredGuidanceMessages(testSessionId),
);
const systemPrompt = output.system.join('\n');

expect(systemPrompt).toContain('Autonomous Oversight Critic');
expect(systemPrompt).toContain('ESCALATE_TO_HUMAN');
});

it('3.4 injects banner when ANY session has fullAutoMode: true (global fallback)', async () => {
it('3.4 delivers the banner for an explicitly identified full-auto session', async () => {
const secondId = `full-auto-regression-global-${Date.now()}`;
swarmState.agentSessions.set(secondId, {
agentName: 'architect',
Expand Down Expand Up @@ -334,12 +391,9 @@ describe('Full-Auto Mode Regression Tests', () => {
prmHardStopPending: false,
});

// First session has fullAutoMode: false; call hook without sessionID
const hook = createSystemEnhancerHook({} as any, tmpDir);
const output = { system: [] as string[], messages: [] as string[] };
// @ts-expect-error - testing internal hook interface
await hook['experimental.chat.system.transform']({}, output);
const systemPrompt = output.system.join('\n');
const systemPrompt = guidanceText(
await registeredGuidanceMessages(secondId),
);

expect(systemPrompt).toContain('## ⚡ FULL-AUTO MODE ACTIVE');

Expand All @@ -349,11 +403,9 @@ describe('Full-Auto Mode Regression Tests', () => {
it('3.5 does NOT inject banner when no sessions exist', async () => {
swarmState.agentSessions.clear();

const hook = createSystemEnhancerHook({} as any, tmpDir);
const output = { system: [] as string[], messages: [] as string[] };
// @ts-expect-error - testing internal hook interface
await hook['experimental.chat.system.transform']({}, output);
const systemPrompt = output.system.join('\n');
const systemPrompt = guidanceText(
await registeredGuidanceMessages(undefined),
);

expect(systemPrompt).not.toContain('## ⚡ FULL-AUTO MODE ACTIVE');

Expand Down Expand Up @@ -411,14 +463,9 @@ describe('Full-Auto Mode Regression Tests', () => {
session!.fullAutoMode = true;
session!.turboMode = false; // only full-auto active

const hook = createSystemEnhancerHook({} as any, tmpDir);
const output = { system: [] as string[], messages: [] as string[] };
// @ts-expect-error - testing internal hook interface
await hook['experimental.chat.system.transform'](
{ sessionID: testSessionId },
output,
const systemPrompt = guidanceText(
await registeredGuidanceMessages(testSessionId),
);
const systemPrompt = output.system.join('\n');

expect(systemPrompt).toContain('## ⚡ FULL-AUTO MODE ACTIVE');
expect(systemPrompt).not.toContain('## 🚀 TURBO MODE ACTIVE');
Expand Down
Loading
Loading