diff --git a/packages/plugin/src/config/project-security.test.ts b/packages/plugin/src/config/project-security.test.ts index 4f22164b3c..85f472bfcb 100644 --- a/packages/plugin/src/config/project-security.test.ts +++ b/packages/plugin/src/config/project-security.test.ts @@ -164,6 +164,19 @@ describe("stripUnsafeProjectConfigFields", () => { expect(warnings.some((w) => w.includes("pi.subagent_extensions"))).toBe(true); }); + it("strips the subagent switches from project config", () => { + const raw: Record = { + subagent: { compress: true, memory_write: true }, + dreamer: { model: "x" }, + }; + + const warnings = stripUnsafeProjectConfigFields(raw); + + expect("subagent" in raw).toBe(false); + expect(raw.dreamer).toEqual({ model: "x" }); + expect(warnings.some((w) => w.includes("subagent.*"))).toBe(true); + }); + it("strips user-only embedding fields from project config but keeps safe tuning fields", () => { const raw: Record = { embedding: { diff --git a/packages/plugin/src/config/project-security.ts b/packages/plugin/src/config/project-security.ts index 8fbebebc0b..b7522ef8fe 100644 --- a/packages/plugin/src/config/project-security.ts +++ b/packages/plugin/src/config/project-security.ts @@ -356,6 +356,8 @@ function makeProjectThresholdWarning(field: string, reason: string): string { * spending the user's provider account on folds. * - `pi.subagent_extensions` — a cloned repo must not choose which extensions * the user's Pi child processes load. + * - `subagent.*` — a cloned repo must not put its own child sessions on the + * historian-enabled compressed path or let them write the shared memory store. * - `prompt_surface.guidance_override_path` / `tool_descriptions` — a repository * may select a reviewed preset, but must not inject arbitrary guidance or tool * description text into the user's provider-visible prompt. @@ -490,6 +492,17 @@ export function stripUnsafeProjectConfigFields(projectRaw: Record { }, temporal_awareness: false, keep_subagents: false, + subagent: { compress: false, memory_write: false }, debug_rpc: false, todowrite: { enabled: false, diff --git a/packages/plugin/src/config/schema/magic-context.ts b/packages/plugin/src/config/schema/magic-context.ts index 9d62adda72..eb1aa3b65c 100644 --- a/packages/plugin/src/config/schema/magic-context.ts +++ b/packages/plugin/src/config/schema/magic-context.ts @@ -1085,6 +1085,16 @@ export interface MagicContextConfig { * of deleting them on success. For short-term inspection/data collection; * kept sessions accumulate until manually cleared. Default false. */ keep_subagents: boolean; + /** How Magic Context treats the caller's own subagent sessions. The stored + * `is_subagent` flag still records the parent link; these switches decide + * what that flag costs the session. Defaults keep the reduced path. + * USER-LEVEL ONLY: a project-scope config cannot set them. */ + subagent: { + /** Let children use compartments, the historian and full guidance. */ + compress: boolean; + /** Keep `ctx_memory` / `ctx_note` available to children. */ + memory_write: boolean; + }; /** Enable loopback-only diagnostic RPCs that expose memory usage and heap snapshots. */ debug_rpc?: boolean; /** @@ -1494,6 +1504,25 @@ export const MagicContextConfigSchema = z .describe( "Debug: keep every settled Magic Context child session instead of deleting it after success: historian, all Dreamer tasks, smart-note evaluation and compilation, user-memory review, and memory migration. Kept Dreamer children can contain memory-pool text and user messages from other sessions of the same operator; their full transcript (prompt, tool calls, token usage, output) stays in the host session store. Kept sessions accumulate until manually cleared; leave false for normal use. Requires a restart to take effect. On OpenCode 2, one hidden session holds many runs; it is kept once any of its runs settled, and only a Dreamer session none of whose runs settled is still deleted.", ), + subagent: z + .object({ + compress: z + .boolean() + .default(false) + .describe( + "Let child (subagent) sessions take the primary path: compartment injection, the historian, full guidance, mural, caveman and the Rust pass inputs. The stored is_subagent flag still records the parent link, and every gate that writes, delivers or reports (the memory tools unless subagent.memory_write, channel-2 nudges, /ctx-wrapup, the status surfaces) keeps reading it. Default false keeps every child on the reduced path.", + ), + memory_write: z + .boolean() + .default(false) + .describe( + "Keep ctx_memory and ctx_note available to child sessions. Independent of subagent.compress: turning compression on does not hand a child the tools that write project memory unless this is also true.", + ), + }) + .default({ compress: false, memory_write: false }) + .describe( + "How Magic Context treats the caller's own subagent sessions. USER-LEVEL ONLY: a project-scope config cannot set these. Two switches because they carry different risks: compress changes what a child sees and which context work it gets, memory_write lets a child write the shared memory store.", + ), debug_rpc: z .boolean() .default(false) diff --git a/packages/plugin/src/features/magic-context/storage-meta-session-v2.test.ts b/packages/plugin/src/features/magic-context/storage-meta-session-v2.test.ts index 99e3f02956..c7e4a9bb07 100644 --- a/packages/plugin/src/features/magic-context/storage-meta-session-v2.test.ts +++ b/packages/plugin/src/features/magic-context/storage-meta-session-v2.test.ts @@ -1,6 +1,8 @@ import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; import { mkdirSync, rmSync } from "node:fs"; import { join } from "node:path"; +import { resetSubagentPolicy, setSubagentPolicy } from "../../plugin/subagent-policy"; +import { hideSubagentTools, resolveStoredSubagentMode } from "../../plugin/subagent-tool-policy"; import { _resetHarnessForTesting, setHarness } from "../../shared/harness"; import { resetOpenCodeDbPathStateForTesting } from "../../shared/opencode-db-path"; import { Database } from "../../shared/sqlite"; @@ -37,6 +39,7 @@ beforeEach(() => { }); afterEach(() => { + resetSubagentPolicy(); meta.close(); host.close(); _resetHarnessForTesting(); @@ -149,3 +152,22 @@ test("OC2 caps parent read retries, then retries after cooldown without making u clock.mockRestore(); } }); + +test("OC2 keeps a compressed child a child for the stored-fact gates on its first pass", () => { + setSubagentPolicy({ compress: true, memoryWrite: false }); + // No session_meta row yet: this is the order the transform uses + // (hideSubagentTools runs before the pass stores the row). + const draft = { + sessionID: "child", + tools: { ctx_memory: {}, ctx_note: {}, ctx_search: {} }, + }; + hideSubagentTools(draft, meta); + expect(Object.keys(draft.tools)).toEqual(["ctx_search"]); + // The row now exists and still records the parent link... + expect( + meta.prepare("SELECT is_subagent FROM session_meta WHERE session_id = 'child'").get(), + ).toEqual({ is_subagent: 1 }); + // ...while the presentation projection hides it from the reduced-path gates. + expect(getOrCreateSessionMeta(meta, "child").isSubagent).toBe(false); + expect(resolveStoredSubagentMode(meta, "child")).toBe(true); +}); diff --git a/packages/plugin/src/features/magic-context/storage-meta-session.ts b/packages/plugin/src/features/magic-context/storage-meta-session.ts index 6dd258f893..b4620b42fa 100644 --- a/packages/plugin/src/features/magic-context/storage-meta-session.ts +++ b/packages/plugin/src/features/magic-context/storage-meta-session.ts @@ -1,5 +1,6 @@ import { Buffer } from "node:buffer"; import { clearLkgRecoveryFence } from "../../hooks/magic-context/lkg-recovery-fence"; +import { effectiveSubagent } from "../../plugin/subagent-policy"; import { cancelAutoSearchWork, clearUnpublishedAutoSearchNoHints, @@ -108,7 +109,14 @@ function findSessionMeta(db: Database, sessionId: string): SessionMeta | undefin * lock uses this: inserting would need that same lock, fail, and refuse the turn. */ export function readSessionMeta(db: Database, sessionId: string): SessionMeta { - return findSessionMeta(db, sessionId) ?? getDefaultSessionMeta(sessionId); + return applySubagentPolicy(findSessionMeta(db, sessionId) ?? getDefaultSessionMeta(sessionId)); +} + +/** The stored fact plus the configured policy: `compress` hides it from the reduced-path gates. */ +function applySubagentPolicy(meta: SessionMeta): SessionMeta { + const stored = meta.isSubagent; + const effective = effectiveSubagent(stored); + return effective === stored ? meta : { ...meta, isSubagent: effective }; } const parentReadRetries = new WeakMap< @@ -119,6 +127,10 @@ const PARENT_READ_ATTEMPTS = 5; const PARENT_READ_COOLDOWN_MS = 30_000; export function getOrCreateSessionMeta(db: Database, sessionId: string): SessionMeta { + return applySubagentPolicy(getOrCreateStoredSessionMeta(db, sessionId)); +} + +function getOrCreateStoredSessionMeta(db: Database, sessionId: string): SessionMeta { const existing = findSessionMeta(db, sessionId); const harness = getHarness(); if (existing) { diff --git a/packages/plugin/src/hooks/magic-context/channel2-delivery.ts b/packages/plugin/src/hooks/magic-context/channel2-delivery.ts index 0d31a109ed..9d48802d8e 100644 --- a/packages/plugin/src/hooks/magic-context/channel2-delivery.ts +++ b/packages/plugin/src/hooks/magic-context/channel2-delivery.ts @@ -36,7 +36,6 @@ // avoid it; that's fixed upstream now, so the separate client + probe are gone. import { randomUUID } from "node:crypto"; -import { getOrCreateSessionMeta } from "../../features/magic-context/storage"; import { casChannel2NudgeClaim, casChannel2NudgeState, @@ -44,6 +43,7 @@ import { getChannel2NudgeClaim, getChannel2NudgeState, } from "../../features/magic-context/storage-meta-persisted"; +import { resolveStoredSubagentMode } from "../../plugin/subagent-tool-policy"; import { sessionLog } from "../../shared/logger"; import { resolvePromptContext } from "../../shared/prompt-context"; import type { Database } from "../../shared/sqlite"; @@ -86,8 +86,9 @@ export interface Channel2DeliveryDeps { */ function subagentRunIsActive(deps: Channel2DeliveryDeps, sessionId: string): boolean { try { - const meta = getOrCreateSessionMeta(deps.db, sessionId); - if (!meta.isSubagent) return true; + // The stored fact, not the `subagent.compress` projection: a child whose + // run is terminal must not have a follow-up turn started for it. + if (!resolveStoredSubagentMode(deps.db, sessionId)) return true; return assistantAwaitingTools(deps, sessionId); } catch (error) { sessionLog( diff --git a/packages/plugin/src/hooks/magic-context/command-handler.ts b/packages/plugin/src/hooks/magic-context/command-handler.ts index c27054f1de..8dfdbf3cde 100644 --- a/packages/plugin/src/hooks/magic-context/command-handler.ts +++ b/packages/plugin/src/hooks/magic-context/command-handler.ts @@ -16,8 +16,9 @@ import { isCanonicalDreamTask, } from "../../features/magic-context/dreamer/task-registry"; import type { ManualRunResult } from "../../features/magic-context/dreamer/task-scheduler"; -import { getCompartments, getOrCreateSessionMeta } from "../../features/magic-context/storage"; +import { getCompartments } from "../../features/magic-context/storage"; import type { RustSessionStatus } from "../../plugin/rpc-handlers"; +import { resolveStoredSubagentMode } from "../../plugin/subagent-tool-policy"; import { sessionLog } from "../../shared"; import type { MagicContextBuiltinCommandName } from "../../shared/builtin-commands"; import type { ConfigParseFailure } from "../../shared/config-diagnostics"; @@ -61,17 +62,16 @@ interface RecompConfirmation { const recompConfirmationBySession = new Map(); const RECOMP_CONFIRMATION_WINDOW_MS = 60_000; +/** + * Gates anchored to the stored fact, never to `subagent.compress`: wrapup + * writes the session's own summary, so a compressed child stays ineligible. + * + * No catch: a metadata or database error has to surface here. Converting it to + * `false` would turn an unreadable child into a primary and let `/ctx-wrapup` + * write into a subagent session. + */ function isSubagentSession(db: Database, sessionId: string): boolean { - const meta = getOrCreateSessionMeta(db, sessionId); - if (meta.isSubagent) return true; - try { - const row = db - .prepare("SELECT is_subagent FROM session_meta WHERE session_id = ?") - .get(sessionId) as { is_subagent?: unknown } | null; - return row?.is_subagent === 1 || row?.is_subagent === true; - } catch { - return false; - } + return resolveStoredSubagentMode(db, sessionId); } const RECOMP_USAGE = [ diff --git a/packages/plugin/src/hooks/magic-context/execute-status.ts b/packages/plugin/src/hooks/magic-context/execute-status.ts index d540303854..03186e1c96 100644 --- a/packages/plugin/src/hooks/magic-context/execute-status.ts +++ b/packages/plugin/src/hooks/magic-context/execute-status.ts @@ -22,6 +22,7 @@ import { formatCoordinateRebaseNotice, readCoordinateRebaseNotice, } from "../../features/magic-context/store-generation-rebase"; +import { resolveStoredSubagentMode } from "../../plugin/subagent-tool-policy"; import { formatCacheTtlDisplay, resolveCacheTtlDisplay } from "../../shared/cache-ttl-display"; import { type ConfigParseFailure, @@ -219,7 +220,7 @@ export function executeStatus( "", `**Protected tool tags:** ${protectionWindow.status.protectedCount} (${protectionWindow.status.protectedMass.toLocaleString()} tokens)`, `**Protection floor:** ${protectionWindow.status.floor.toLocaleString()} tokens`, - `**Subagent session:** ${meta.isSubagent}`, + `**Subagent session:** ${resolveStoredSubagentMode(db, sessionId)}`, ]; const storage = getMagicContextStorageResolution(); diff --git a/packages/plugin/src/hooks/magic-context/hook-handlers.ts b/packages/plugin/src/hooks/magic-context/hook-handlers.ts index 57c7154679..dc97245a2f 100644 --- a/packages/plugin/src/hooks/magic-context/hook-handlers.ts +++ b/packages/plugin/src/hooks/magic-context/hook-handlers.ts @@ -22,6 +22,7 @@ import { setLastNudgeUndropped, } from "../../features/magic-context/storage-meta-persisted"; import { clearSidebarSnapshotCache } from "../../plugin/sidebar-snapshot-cache"; +import { resolveStoredSubagentMode } from "../../plugin/subagent-tool-policy"; import type { PluginContext } from "../../plugin/types"; import { seedSessionCacheTtlIfUnsynced } from "../../shared/cache-ttl-seed"; import { sessionLog } from "../../shared/logger"; @@ -767,9 +768,10 @@ export function createToolExecuteAfterHook(args: { (t as { status?: unknown }).status === "cancelled"), ) ) { - // Subagents never deliver note nudges (gated in postprocess), so don't - // accumulate orphan trigger state for them. - if (sessionMeta && !sessionMeta.isSubagent) { + // Subagents never deliver note nudges: the delivery gate reads the + // stored fact, so do not accumulate trigger state that + // `subagent.compress` cannot hand to them. + if (sessionMeta && !resolveStoredSubagentMode(args.db, typedInput.sessionID)) { onNoteTrigger(args.db, typedInput.sessionID, "todos_complete"); } } diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 783525558a..b357c1d0b7 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -73,6 +73,7 @@ import { createMessagesTransformHandler } from "./plugin/messages-transform"; import { disableNativeAutoCompaction } from "./plugin/native-compaction-guard"; import { isDebugRpcEnabled, registerRpcHandlers } from "./plugin/rpc-handlers"; import { bindStaleBuildNotice } from "./plugin/stale-build-notice"; +import { setSubagentPolicy } from "./plugin/subagent-policy"; import { primaryOnlyToolIds } from "./plugin/subagent-tool-policy"; import { createToolRegistry } from "./plugin/tool-registry"; import { getMagicContextBuiltinCommands } from "./shared/builtin-commands"; @@ -196,6 +197,11 @@ const server: Plugin = async (ctx) => { // Debug data-collection toggle: when on, keep subagent child sessions // (historian/dreamer/migration) instead of deleting on success. setKeepSubagents(pluginConfig.keep_subagents === true); + // How much of the primary path a caller's own subagent sessions get. + setSubagentPolicy({ + compress: pluginConfig.subagent.compress === true, + memoryWrite: pluginConfig.subagent.memory_write === true, + }); const autoUpdateAbort = new AbortController(); // Abort on process exit via the shared single-listener registry. Registering // a process.once("exit") here directly would add one listener PER plugin diff --git a/packages/plugin/src/plugin/subagent-policy.test.ts b/packages/plugin/src/plugin/subagent-policy.test.ts new file mode 100644 index 0000000000..fe611c359d --- /dev/null +++ b/packages/plugin/src/plugin/subagent-policy.test.ts @@ -0,0 +1,108 @@ +import { afterEach, expect, test } from "bun:test"; +import { tool } from "@opencode-ai/plugin"; +import { + type ContextDatabase, + closeDatabase, + getOrCreateSessionMeta, + openDatabase, + updateSessionMeta, +} from "../features/magic-context/storage"; +import { createTestTempDir } from "../shared/test-temp-dir"; +import { + effectiveSubagent, + hidesMemoryTools, + resetSubagentPolicy, + setSubagentPolicy, +} from "./subagent-policy"; +import { + hideSubagentTools, + resolveStoredSubagentMode, + storedSubagentMode, + subagentToolRefusal, +} from "./subagent-tool-policy"; + +let db: ContextDatabase; +let temp: ReturnType; + +function openFresh(): void { + temp = createTestTempDir("mc-subagent-policy-"); + process.env.MAGIC_CONTEXT_STORAGE_DIR = temp.dir; + db = openDatabase()!; + updateSessionMeta(db, "child", { isSubagent: true }); + updateSessionMeta(db, "primary", { isSubagent: false }); +} + +afterEach(() => { + resetSubagentPolicy(); + closeDatabase(); + delete process.env.MAGIC_CONTEXT_STORAGE_DIR; + temp.cleanup(); +}); + +const tools = () => ({ + ctx_memory: tool({ description: "m", args: {}, async execute() {} }), + ctx_note: tool({ description: "n", args: {}, async execute() {} }), + ctx_search: tool({ description: "s", args: {}, async execute() {} }), +}); + +test("stock policy keeps children reduced and their memory tools hidden", () => { + openFresh(); + expect(effectiveSubagent(true)).toBe(true); + expect(hidesMemoryTools(true)).toBe(true); + expect(getOrCreateSessionMeta(db, "child").isSubagent).toBe(true); + const draft = { sessionID: "child", tools: tools() }; + hideSubagentTools(draft, db); + expect(Object.keys(draft.tools)).toEqual(["ctx_search"]); +}); + +test("compress presents a child as primary without losing the stored flag", () => { + openFresh(); + setSubagentPolicy({ compress: true }); + expect(getOrCreateSessionMeta(db, "child").isSubagent).toBe(false); + expect(getOrCreateSessionMeta(db, "primary").isSubagent).toBe(false); + // The stored fact is untouched, so the switches stay reversible. + expect( + db + .prepare("SELECT is_subagent AS value FROM session_meta WHERE session_id = ?") + .get("child"), + ).toEqual({ value: 1 }); + expect(effectiveSubagent(true)).toBe(false); +}); + +test("compression alone does not hand a child the memory tools", () => { + openFresh(); + setSubagentPolicy({ compress: true }); + const draft = { sessionID: "child", tools: tools() }; + hideSubagentTools(draft, db); + expect(Object.keys(draft.tools)).toEqual(["ctx_search"]); + expect(subagentToolRefusal(db, "ctx_memory", "child")).toContain("unavailable in subagent"); +}); + +test("memory_write keeps the tools even while compression is off", () => { + openFresh(); + setSubagentPolicy({ memoryWrite: true }); + const draft = { sessionID: "child", tools: tools() }; + hideSubagentTools(draft, db); + expect(Object.keys(draft.tools)).toContain("ctx_memory"); + expect(subagentToolRefusal(db, "ctx_memory", "child")).toBeUndefined(); + expect(getOrCreateSessionMeta(db, "child").isSubagent).toBe(true); +}); + +test("primary sessions are unaffected by every switch", () => { + openFresh(); + setSubagentPolicy({ compress: true, memoryWrite: false }); + const draft = { sessionID: "primary", tools: tools() }; + hideSubagentTools(draft, db); + expect(Object.keys(draft.tools)).toHaveLength(3); + expect(subagentToolRefusal(db, "ctx_note", "primary")).toBeUndefined(); +}); + +test("stored-fact gates ignore compress", () => { + openFresh(); + setSubagentPolicy({ compress: true, memoryWrite: false }); + // What the session is shown follows the policy; the stored fact does not. + expect(effectiveSubagent(true)).toBe(false); + expect(storedSubagentMode(db, "child")).toBe(true); + expect(resolveStoredSubagentMode(db, "child")).toBe(true); + expect(resolveStoredSubagentMode(db, "primary")).toBe(false); +}); diff --git a/packages/plugin/src/plugin/subagent-policy.ts b/packages/plugin/src/plugin/subagent-policy.ts new file mode 100644 index 0000000000..2935659204 --- /dev/null +++ b/packages/plugin/src/plugin/subagent-policy.ts @@ -0,0 +1,74 @@ +/** + * Per-session policy for subagent sessions. + * + * Magic Context stores one fact about a session (`session_meta.is_subagent`), + * and two different answers are derived from it here. + * + * - What the session is SHOWN and which context-management work it gets: + * compartment injection, the historian, full guidance, mural, caveman, the + * Rust pass inputs, system-prompt identity and the todo snapshot. Those + * gates read the projection from `getOrCreateSessionMeta` / `readSessionMeta` + * and `compress` turns them on for a child. + * - What it may WRITE, be DELIVERED, or appear as in status: the memory tools, + * channel-2 nudges, `/ctx-wrapup`, note-nudge accumulation and the status + * surfaces. Those gates read the stored fact (`storedSubagentMode` / + * `resolveStoredSubagentMode`) and never move with `compress`. + * + * The split is deliberate. `compress` is a context-management choice, so a + * repository that turns it on in its own project config must not also gain + * shared-memory writes, a follow-up child turn after the run ended, or a + * wrapup. Where a gate belongs is decided by what it does, not by which module + * it lives in. + * + * Defaults reproduce the pre-existing behaviour: children stay reduced and the + * memory tools stay hidden. + */ + +export interface SubagentPolicy { + /** Children take the full path: compartments, historian, full guidance. */ + compress: boolean; + /** Children may call `ctx_memory` / `ctx_note`. */ + memoryWrite: boolean; +} + +const STOCK: SubagentPolicy = { compress: false, memoryWrite: false }; + +let policy: SubagentPolicy = STOCK; + +/** + * Set once per plugin instance from the resolved config, next to + * `setKeepSubagents`. Process-global: one plugin instance per open project in + * the same process means the last enabled project to boot owns the policy, and + * a config change needs a restart (`subagent` is not a live-reload path). + */ +export function setSubagentPolicy(next: Partial): void { + policy = { ...STOCK, ...next }; +} + +export function getSubagentPolicy(): SubagentPolicy { + return policy; +} + +/** Test seam: drop back to the stock policy. */ +export function resetSubagentPolicy(): void { + policy = STOCK; +} + +/** + * The `isSubagent` the presentation gates should act on. The stored value still + * says the session is a child; with `compress` on, callers that decide what a + * session sees get a primary session instead. Gates that write, deliver or + * report must NOT use this; they read the stored fact. + */ +export function effectiveSubagent(stored: boolean): boolean { + return stored && !policy.compress; +} + +/** + * Whether `ctx_memory` / `ctx_note` stay hidden. Independent of `compress`: + * turning compression on must not hand a child the tools that write project + * memory. + */ +export function hidesMemoryTools(stored: boolean): boolean { + return stored && !policy.memoryWrite; +} diff --git a/packages/plugin/src/plugin/subagent-tool-policy.test.ts b/packages/plugin/src/plugin/subagent-tool-policy.test.ts index 81b8b9a480..68833c4a24 100644 --- a/packages/plugin/src/plugin/subagent-tool-policy.test.ts +++ b/packages/plugin/src/plugin/subagent-tool-policy.test.ts @@ -9,6 +9,7 @@ import { import { createTestTempDir } from "../shared/test-temp-dir"; import { registerTools } from "../v2/hooks/tools"; import type { V2Context } from "../v2/hooks/types"; +import { resetSubagentPolicy, setSubagentPolicy } from "./subagent-policy"; import { guardSubagentTools, hideSubagentTools, @@ -29,6 +30,7 @@ beforeEach(() => { updateSessionMeta(db, "primary", { isSubagent: false }); }); afterEach(() => { + resetSubagentPolicy(); closeDatabase(); if (previousStorage === undefined) delete process.env.MAGIC_CONTEXT_STORAGE_DIR; else process.env.MAGIC_CONTEXT_STORAGE_DIR = previousStorage; @@ -162,3 +164,18 @@ test("OC2 execution refuses forged child calls before entering a module backend" } expect(backendCalls).toBe(0); }); + +test("primary_tools denies the memory tools only while memory_write is off", () => { + // OC1's host denies these to task children from its own list, so the list has + // to follow subagent.memory_write or the switch cannot work there. + expect(primaryOnlyToolIds().sort()).toEqual(["ctx_memory", "ctx_note"]); + expect(primaryOnlyToolIds(["selftest_run"]).sort()).toEqual([ + "ctx_memory", + "ctx_note", + "selftest_run", + ]); + setSubagentPolicy({ compress: false, memoryWrite: true }); + expect(primaryOnlyToolIds()).toEqual([]); + // A user-supplied entry is not ours to drop. + expect(primaryOnlyToolIds(["selftest_run"])).toEqual(["selftest_run"]); +}); diff --git a/packages/plugin/src/plugin/subagent-tool-policy.ts b/packages/plugin/src/plugin/subagent-tool-policy.ts index 1ff5810572..9c3148309b 100644 --- a/packages/plugin/src/plugin/subagent-tool-policy.ts +++ b/packages/plugin/src/plugin/subagent-tool-policy.ts @@ -1,12 +1,18 @@ import type { ToolDefinition } from "@opencode-ai/plugin"; import { getOrCreateSessionMeta } from "../features/magic-context/storage"; import type { Database } from "../shared/sqlite"; +import { hidesMemoryTools, getSubagentPolicy } from "./subagent-policy"; const PRIMARY_ONLY_TOOLS = new Set(["ctx_memory", "ctx_note"]); /** OC1's task tool turns primary_tools into child-session permission denies. */ export function primaryOnlyToolIds(existing: readonly string[] = []): string[] { - return [...new Set([...existing, ...PRIMARY_ONLY_TOOLS])]; + // The host's deny list outranks every gate in this package, so it has to follow + // the same policy: with `subagent.memory_write` on, a child is allowed the tools + // and adding them here would refuse the call before our own check runs. Entries + // the user supplied are kept either way. + const denied = getSubagentPolicy().memoryWrite ? [] : PRIMARY_ONLY_TOOLS; + return [...new Set([...existing, ...denied])]; } /** Use the transform's persisted mode, not an agent name that a caller can change. */ @@ -19,7 +25,7 @@ export function subagentToolRefusal( if ( PRIMARY_ONLY_TOOLS.has(toolId) && !isInternalChild(sessionId) && - getOrCreateSessionMeta(db, sessionId).isSubagent + hidesMemoryTools(resolveStoredSubagentMode(db, sessionId)) ) { return `Error: ${toolId} is unavailable in subagent sessions. Use the primary session instead.`; } @@ -58,7 +64,7 @@ export function hideSubagentTools( draft: { sessionID: string; tools: Record }, db: Database, ): void { - if (!getOrCreateSessionMeta(db, draft.sessionID).isSubagent) return; + if (!hidesMemoryTools(resolveStoredSubagentMode(db, draft.sessionID))) return; removePrimaryOnlyTools(draft.tools); } @@ -80,3 +86,21 @@ export function storedSubagentMode(db: Database, sessionId: string): boolean | u if (!row || typeof row.is_subagent !== "number") return undefined; return row.is_subagent === 1; } + +/** + * The stored fact, creating the session's row first when it is missing. + * + * A fresh child's parent link only becomes visible in the row the first + * `getOrCreateSessionMeta` writes, and that call returns the policy projection, + * so re-reading the raw column is the only way a gate can learn the truth on + * the same pass. Nothing here is projected: a compressed child is still a child + * for the gates that call this. + */ +export function resolveStoredSubagentMode(db: Database, sessionId: string): boolean { + const stored = storedSubagentMode(db, sessionId); + if (stored !== undefined) return stored; + getOrCreateSessionMeta(db, sessionId); + // Still undefined means the host could not name the parent yet (OC2 stores + // that as NULL and retries later): treat it as primary, the documented posture. + return storedSubagentMode(db, sessionId) ?? false; +} diff --git a/packages/plugin/src/v2/hooks/context.ts b/packages/plugin/src/v2/hooks/context.ts index 268322722f..ad7d410016 100644 --- a/packages/plugin/src/v2/hooks/context.ts +++ b/packages/plugin/src/v2/hooks/context.ts @@ -109,6 +109,7 @@ import { tryMessagesTransformLkgReplay, } from "../../plugin/messages-transform"; import { registerRpcHandlers } from "../../plugin/rpc-handlers"; +import { hidesMemoryTools, setSubagentPolicy } from "../../plugin/subagent-policy"; import { hideSubagentTools, removePrimaryOnlyTools, @@ -869,6 +870,13 @@ export async function registerContext(context: V2Context) { log(`[magic-context] OpenCode 2 keep_subagents=${config.keep_subagents === true}`); setHomeProjectPermission(config.allow_home_project); if (!config.enabled) return; + // Same subagent switches as the OpenCode 1 lane; the gates live in this package. + // Process-global by design (see plugin/subagent-policy.ts): one plugin instance + // per open project, so the last enabled project to boot owns the policy here. + setSubagentPolicy({ + compress: config.subagent.compress === true, + memoryWrite: config.subagent.memory_write === true, + }); const nativeRemove = nativeSessionRemove(context.session); if (!hasRequiredSessionAPI(context.session) || !nativeRemove) { const message = OPENCODE2_SESSION_API_NOTICE; @@ -2357,7 +2365,10 @@ export async function registerContext(context: V2Context) { ? storedSubagentMode(modeDb, draft.sessionID) : undefined; if (subagent === undefined) return undefined; - if (subagent) removePrimaryOnlyTools(draft.tools); + // The same policy the managed pass applies: `memory_write` keeps the + // primary-only tools on this path too. Still a pure read plus an + // in-memory check, so the replay writes nothing. + if (hidesMemoryTools(subagent)) removePrimaryOnlyTools(draft.tools); if (!rustModeModuleClient) { const candidates = syntheticCandidates( context, diff --git a/packages/plugin/src/v2/hooks/oc2-busy-replay-guards.test.ts b/packages/plugin/src/v2/hooks/oc2-busy-replay-guards.test.ts index 8dd57ea342..044ab3a4cf 100644 --- a/packages/plugin/src/v2/hooks/oc2-busy-replay-guards.test.ts +++ b/packages/plugin/src/v2/hooks/oc2-busy-replay-guards.test.ts @@ -4,6 +4,7 @@ import { Database as BunDatabase } from "bun:sqlite"; import { expect, spyOn, test } from "bun:test"; import { getSlot } from "../../hooks/magic-context/lkg-slot"; +import { resetSubagentPolicy, setSubagentPolicy } from "../../plugin/subagent-policy"; import * as models from "../../shared/models-dev-cache"; import { messageTimesQuery, V2StoreReader } from "../store-reader"; import { @@ -100,6 +101,61 @@ for (const busy of ["admission", "admission-exhausted"] as const) } }, 60000); +// `subagent.memory_write` keeps those tools on the managed pass, so a replay for +// the same session has to keep them as well: the two paths must serve one tool +// list, or the turn that exists to preserve the cached prefix changes it. +test("OC2 replay: memory_write keeps the primary-only tools on the replay", async () => { + const failure = installTransientFailure(); + const admission = installBusyAdmission(); + const limit = spyOn(models, "getSdkContextLimit").mockReturnValue(1_000_000); + const register = withDraftMutation((draft) => { + draft.tools = { + ...draft.tools, + ctx_memory: { description: "Memory", input: { type: "object" } }, + ctx_note: { description: "Notes", input: { type: "object" } }, + } as SessionContext["tools"]; + }); + const seed = 9203; + const f = await oc2ContextFixture(seed, { area: AREA, instructions: false }); + // After the fixture's boot: registerContext installs the project config, so + // setting the switch first would be overwritten by it. + setSubagentPolicy({ compress: false, memoryWrite: true }); + try { + const host = new BunDatabase(process.env.OPENCODE_DB!); + host.prepare("UPDATE session_v2 SET parent_id = 'ses-parent' WHERE id = ?").run(f.session); + host.close(); + f.compartment(4); + await f.pass(); + f.respond(); + f.user(`${seed} the saved turn: ${prose}`, 120000); + const saved = await f.pass(); + f.respond(); + f.user(`${seed} the busy turn: ${prose}`, 120000); + expect(getSlot(f.session)).toBeDefined(); + expect(Object.keys(saved.draft.tools ?? {}).sort()).toEqual([ + "ctx_memory", + "ctx_note", + "read", + ]); + admission.state.busyNext = true; + const replay = await f.attempt(); + expect(lkgEvents(replay.logs)).toBe("lkg_replay_served"); + expect(replay.draft.system).toEqual(saved.draft.system); + expect(Object.keys(replay.draft.tools ?? {}).sort()).toEqual([ + "ctx_memory", + "ctx_note", + "read", + ]); + } finally { + register.mockRestore(); + admission.restore(); + failure.restore(); + limit.mockRestore(); + resetSubagentPolicy(); + await f.cleanup(); + } +}, 60000); + // The stored-times read filters on session_id and id. On the host's schema // (built below) SQLite would rather use the (session_id, time_created, id) // index than the primary key, and that index walks every row of the session diff --git a/packages/plugin/src/v2/hooks/subagent-policy-wiring.test.ts b/packages/plugin/src/v2/hooks/subagent-policy-wiring.test.ts new file mode 100644 index 0000000000..346b13c72f --- /dev/null +++ b/packages/plugin/src/v2/hooks/subagent-policy-wiring.test.ts @@ -0,0 +1,148 @@ +import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import * as configLoader from "../../config"; +import { MagicContextConfigSchema, type MagicContextConfig } from "../../config/schema/magic-context"; +import { closeDatabase, openDatabase } from "../../features/magic-context/storage"; +import { resetCtxReduceRegisteredGloballyForTest } from "../../hooks/magic-context/ctx-reduce-availability"; +import { _resetDreamTimerForTests } from "../../plugin/dream-timer"; +import { getSubagentPolicy, resetSubagentPolicy } from "../../plugin/subagent-policy"; +import { _resetHarnessForTesting } from "../../shared/harness"; +import { cleanupTestTempDir, createTestTempDir } from "../../shared/test-temp-dir"; +import { setup } from "../server"; +import * as storageGate from "./storage-gate"; +import type { V2Context } from "./types"; + +/** + * OpenCode 2 boots through `setup` only. These tests pin that the boot installs + * the project's `subagent` switches (the OpenCode 1 lane was the only caller of + * `setSubagentPolicy` before), that a disabled project does not disturb a policy + * another project installed, and the documented process-wide limitation: the + * last enabled project to boot wins. + */ + +const directories: string[] = []; +const disposers: Array<() => Promise> = []; + +afterEach(async () => { + for (const dispose of disposers.splice(0)) await dispose().catch(() => {}); + _resetDreamTimerForTests(); + closeDatabase(); + resetCtxReduceRegisteredGloballyForTest(); + _resetHarnessForTesting(); + resetSubagentPolicy(); + for (const dir of directories.splice(0)) cleanupTestTempDir(dir); +}); + +function projectDirectory(): string { + const { dir } = createTestTempDir("mc-v2-subagent-wiring-"); + directories.push(dir); + return dir; +} + +function fakeHost(directory: string): V2Context { + return { + location: { directory }, + agent: { transform: async () => {}, reload: async () => {} }, + model: { list: () => [] }, + storage: { get: async () => undefined, set: async () => {} }, + event: { subscribe: async function* () {} }, + tool: { hook: async () => {} }, + session: { + remove: async () => {}, + compact: async () => {}, + interrupt: async () => ({ interrupted: true }), + hook: async () => {}, + get: async () => ({ location: { directory } }), + }, + } as unknown as V2Context; +} + +function configFor( + overrides: Partial<{ enabled: boolean; compress: boolean; memory_write: boolean }>, +): MagicContextConfig { + return MagicContextConfigSchema.parse({ + enabled: overrides.enabled ?? true, + auto_update: false, + historian: { disable: true }, + dreamer: { disable: true }, + memory: { enabled: false }, + embedding: { provider: "off" }, + subagent: { + compress: overrides.compress ?? false, + memory_write: overrides.memory_write ?? false, + }, + }); +} + +/** Serve a per-directory config, the way one process sees several open projects. */ +function mockConfigsByDirectory(configs: Map): void { + const detailed = configLoader.loadPluginConfigDetailed(projectDirectory(), false); + const load = spyOn(configLoader, "loadPluginConfigDetailed").mockImplementation( + (directory: string) => + ({ ...detailed, config: configs.get(directory) ?? configFor({}) }) as never, + ); + const db = openDatabase(); + if (!db) throw new Error("throwaway test store unavailable"); + const probe = spyOn(storageGate, "probeV2StorageAtBoot").mockResolvedValue(db); + disposers.push(async () => { + load.mockRestore(); + probe.mockRestore(); + }); +} + +describe("OpenCode 2 subagent policy wiring", () => { + test("setup installs the project's subagent switches", async () => { + const directory = projectDirectory(); + mockConfigsByDirectory( + new Map([[directory, configFor({ compress: true, memory_write: true })]]), + ); + expect(getSubagentPolicy()).toEqual({ compress: false, memoryWrite: false }); + + const dispose = await setup(fakeHost(directory)); + + expect(getSubagentPolicy()).toEqual({ compress: true, memoryWrite: true }); + await dispose(); + }); + + test("a disabled project does not reset a policy another project installed", async () => { + const enabledDirectory = projectDirectory(); + const disabledDirectory = projectDirectory(); + mockConfigsByDirectory( + new Map([ + [enabledDirectory, configFor({ compress: true, memory_write: true })], + [ + disabledDirectory, + configFor({ enabled: false, compress: false, memory_write: false }), + ], + ]), + ); + + const disposeEnabled = await setup(fakeHost(enabledDirectory)); + const disposeDisabled = await setup(fakeHost(disabledDirectory)); + + expect(getSubagentPolicy()).toEqual({ compress: true, memoryWrite: true }); + await disposeDisabled(); + await disposeEnabled(); + }); + + test("the last enabled project to boot owns the process-wide policy", async () => { + const first = projectDirectory(); + const second = projectDirectory(); + mockConfigsByDirectory( + new Map([ + [first, configFor({ compress: true, memory_write: true })], + [second, configFor({ compress: false, memory_write: true })], + ]), + ); + + const disposeFirst = await setup(fakeHost(first)); + expect(getSubagentPolicy()).toEqual({ compress: true, memoryWrite: true }); + + const disposeSecond = await setup(fakeHost(second)); + + // Documented limitation, not an accident: the policy lives in the module, + // and OpenCode 2 runs one plugin instance per project in the process. + expect(getSubagentPolicy()).toEqual({ compress: false, memoryWrite: true }); + await disposeSecond(); + await disposeFirst(); + }); +}); diff --git a/packages/plugin/src/v2/retrospective-raw-provider.ts b/packages/plugin/src/v2/retrospective-raw-provider.ts index c99178b314..05ad9e01f6 100644 --- a/packages/plugin/src/v2/retrospective-raw-provider.ts +++ b/packages/plugin/src/v2/retrospective-raw-provider.ts @@ -31,6 +31,8 @@ export class V2RetrospectiveRawProvider implements RetrospectiveRawProvider { } listProjectSessions(projectIdentity: string): RetrospectiveProjectSession[] { + // Root sessions only: `rootSessionActivity` reports native user activity, + // and a child's "user messages" are agent-authored task prompts. const sessions = this.contextDb .prepare<[string], { session_id: string }>(` SELECT sp.session_id FROM session_projects sp