Skip to content
Closed
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
2 changes: 1 addition & 1 deletion CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ Higher-tier models with longer cache windows benefit from a longer TTL. Setting
| `execute_threshold_percentage` | `number` (20–90) or `object` | `65` | Context usage that forces queued ops to execute. Capped at 90% of the output-reserved safe window, leaving about 10% for mid-turn input growth. Supports per-model maps. |
| `execute_threshold_tokens` | `object` (per-model map) | — | **Optional absolute-tokens variant of `execute_threshold_percentage`.** Per-model map (e.g. `{ "default": 150000, "github-copilot/gpt-5.2-codex": 40000 }`). When set for a model, overrides the percentage-based threshold for that model. Clamped to `90% × context_limit` with a warn log. Requires a resolvable context limit — falls through to percentage if unavailable. See below. |
| `clear_reasoning_age` | `number` | `50` | Clear thinking/reasoning blocks older than N tags. |
| `historian_timeout_ms` | `number` | `300000` | Timeout per historian call (ms). |
| `historian_timeout_ms` | `number` | `600000` | Timeout per historian call (ms). |
| `history_budget_percentage` | `number` (0.05–0.5) | `0.15` | Fraction of usable context (`context_limit × execute_threshold`) reserved for the history block. Triggers compression when exceeded. |
| `compaction.enabled` | `boolean` | `true` | When `false`, use compaction-off mode: keep Magic Context's knowledge layer and let native compaction (or nothing) own the context window. Boot-resolved; restart after changing it. See below. |
| `commit_cluster_trigger` | `object` | See below | Controls the commit-cluster historian trigger. |
Expand Down
4 changes: 2 additions & 2 deletions assets/magic-context.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1271,8 +1271,8 @@
"maximum": 0.5
},
"historian_timeout_ms": {
"default": 300000,
"description": "Timeout for each historian prompt call in milliseconds (default: 300000)",
"default": 600000,
"description": "Timeout for each historian prompt call in milliseconds (default: 600000)",
"type": "number",
"minimum": 60000
},
Expand Down
2 changes: 1 addition & 1 deletion packages/docs/src/content/docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ The background agent that condenses old conversation into compact history.
| `historian.two_pass` | boolean | `false` | Run a second editor pass over historian output to clean low-signal U: lines and cross-compartment duplicates. Adds ~1 extra API call and ~1.3x cost per historian run. Useful for models without extended thinking support. (default: false) |
| `historian.thinking_level` | `"off"` \\| `"minimal"` \\| `"low"` \\| `"medium"` \\| `"high"` \\| `"xhigh"` \\| `"max"` | — | Pi only: explicit thinking level passed as --thinking <level> to Pi historian subagent invocations. Required when using reasoning models (e.g. github-copilot/gpt-5.4) because Pi's default thinking-level resolution can pick a value the provider rejects. OpenCode users set variant instead. Valid: off \| minimal \| low \| medium \| high \| xhigh \| max |
| `historian.disallowed_tools` | `"*"` \\| `"read"` \\| `"aft_outline"` \\| `"aft_zoom"` \\| `"aft_search"`[] | `[]` | OpenCode only. Tools to REMOVE from the historian's default allow-list [read, aft_outline, aft_zoom, aft_search]. Applies to both historian and historian-editor agents. Use ["*"] to strip all tool definitions from the model request — this prevents weak instruction-following models (e.g. mistral-small-latest) from entering tool-calling loops. Individual tool names remove just that tool. Note: a user-supplied historian.permission override can re-allow a tool that disallowed_tools removed — disallowed_tools sets the baseline, permission overrides take precedence. (default: []) |
| `historian_timeout_ms` | number (60000–) | `300000` | Timeout for each historian prompt call in milliseconds (default: 300000) |
| `historian_timeout_ms` | number (60000–) | `600000` | Timeout for each historian prompt call in milliseconds (default: 600000) |
| `commit_cluster_trigger` | object | — | Commit-cluster trigger: fire historian when enough commit clusters accumulate in the unsummarized tail |
| `commit_cluster_trigger.enabled` | boolean | `true` | Enable commit-cluster based historian triggering (default: true) |
| `commit_cluster_trigger.min_clusters` | number (1–) | `3` | Minimum commit clusters required to trigger historian (min: 1, default: 3) |
Expand Down
3 changes: 1 addition & 2 deletions packages/plugin/src/config/schema/magic-context.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { describe, expect, it } from "bun:test";
import {
DEFAULT_HISTORIAN_TIMEOUT_MS,
DEFAULT_HISTORY_BUDGET_PERCENTAGE,
DEFAULT_LOCAL_EMBEDDING_MODEL,
type MagicContextConfig,
Expand All @@ -25,7 +24,7 @@ describe("MagicContextConfigSchema", () => {
protected_tags: 20,
clear_reasoning_age: 50,
history_budget_percentage: DEFAULT_HISTORY_BUDGET_PERCENTAGE,
historian_timeout_ms: DEFAULT_HISTORIAN_TIMEOUT_MS,
historian_timeout_ms: 600_000,
embedding: {
provider: "local",
model: DEFAULT_LOCAL_EMBEDDING_MODEL,
Expand Down
5 changes: 3 additions & 2 deletions packages/plugin/src/config/schema/magic-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ export const DEFAULT_EXECUTE_THRESHOLD_PERCENTAGE = 65;
// escalation derives above the effective threshold and the 95% wall stays fixed.
export const EXECUTE_THRESHOLD_CAP_MESSAGE =
"execute_threshold is capped at 90% for cache safety: output capacity is reserved from the usable context window, and the remaining 10% absorbs mid-turn growth before the absolute 95% emergency wall. Use a value between 20 and 90.";
export const DEFAULT_HISTORIAN_TIMEOUT_MS = 300_000;
export const DEFAULT_HISTORIAN_TIMEOUT_MS = 600_000;
export const MAX_HISTORIAN_PROMPT_ATTEMPTS = 3;
export const DEFAULT_HISTORY_BUDGET_PERCENTAGE = 0.15;

export const DEFAULT_LOCAL_EMBEDDING_MODEL = "Xenova/all-MiniLM-L6-v2";
Expand Down Expand Up @@ -784,7 +785,7 @@ export const MagicContextConfigSchema = z
.number()
.min(60_000)
.default(DEFAULT_HISTORIAN_TIMEOUT_MS)
.describe("Timeout for each historian prompt call in milliseconds (default: 300000)"),
.describe("Timeout for each historian prompt call in milliseconds (default: 600000)"),
commit_cluster_trigger: z
.object({
enabled: z
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ import { Database } from "../../../shared/sqlite";
import { closeQuietly } from "../../../shared/sqlite-helpers";
import {
CURATE_CHILD_TITLE,
HISTORIAN_CHILD_TITLE,
historianOrphanStaleMs,
historianOrphanSweepTitleMatches,
MAINTAIN_DOCS_CHILD_TITLE,
privacyOrphanSweepTitleMatches,
REFRESH_PRIMERS_CHILD_TITLE,
RETROSPECTIVE_CHILD_TITLE,
retrospectiveOrphanStaleMs,
Expand Down Expand Up @@ -48,6 +52,21 @@ describe("retrospectiveOrphanStaleMs", () => {
expect(retrospectiveOrphanStaleMs([10, 45, undefined])).toBe(135 * 60_000);
expect(retrospectiveOrphanStaleMs(undefined)).toBe(60 * 60_000);
});

test("covers every outer retry, fallback, and model-suggestion call", () => {
expect(historianOrphanStaleMs(600_000, 3)).toBe(12 * 60 * 60_000);
});
});

describe("orphan sweep title groups", () => {
test("keeps historian and privacy titles in separate groups", () => {
const privacyTitles = privacyOrphanSweepTitleMatches();
const historianTitles = historianOrphanSweepTitleMatches();

expect(privacyTitles.exact).toContain(RETROSPECTIVE_CHILD_TITLE);
expect(privacyTitles.exact).not.toContain(HISTORIAN_CHILD_TITLE);
expect(historianTitles).toEqual({ exact: [HISTORIAN_CHILD_TITLE], prefixes: [] });
});
});

describe("sweepOrphanedRetrospectiveChildren", () => {
Expand Down Expand Up @@ -75,7 +94,8 @@ describe("sweepOrphanedRetrospectiveChildren", () => {
insert(db, "old", RETROSPECTIVE_CHILD_TITLE, DIR, now - staleMs - 6);
insert(db, "old-curate", CURATE_CHILD_TITLE, DIR, now - staleMs - 5);
insert(db, "old-docs", MAINTAIN_DOCS_CHILD_TITLE, DIR, now - staleMs - 4);
insert(db, "old-refresh", REFRESH_PRIMERS_CHILD_TITLE, DIR, now - staleMs - 3);
insert(db, "old-refresh", REFRESH_PRIMERS_CHILD_TITLE, DIR, now - staleMs - 4);
insert(db, "old-historian", HISTORIAN_CHILD_TITLE, DIR, now - staleMs - 3);
insert(
db,
"old-compile",
Expand All @@ -92,6 +112,7 @@ describe("sweepOrphanedRetrospectiveChildren", () => {
);
// recent child (live run) → NOT swept
insert(db, "fresh", RETROSPECTIVE_CHILD_TITLE, DIR, now - 1000);
insert(db, "fresh-historian", HISTORIAN_CHILD_TITLE, DIR, now - 1000);
// old but a different title → NOT swept
insert(db, "other-title", "magic-context-dream-verify", DIR, now - staleMs - 1);
// old retrospective but ANOTHER directory → NOT swept
Expand All @@ -112,10 +133,11 @@ describe("sweepOrphanedRetrospectiveChildren", () => {
"old-curate",
"old-docs",
"old-refresh",
"old-historian",
"old-compile",
"old-confirm",
]);
expect(count).toBe(7);
expect(count).toBe(8);
});

test("treats a delete error (404 / already removed) as success", async () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { MAX_HISTORIAN_PROMPT_ATTEMPTS } from "../../../config/schema/magic-context";
import type { PluginContext } from "../../../plugin/types";
import { log } from "../../../shared/logger";
import type { Database } from "../../../shared/sqlite";
Expand All @@ -6,7 +7,7 @@ import type { DreamTaskName } from "./task-registry";
type OpencodeClient = PluginContext["client"];

/**
* Privacy backstop for dreamer children that carry raw user or project text.
* Privacy backstop for internal children that carry raw user or project text.
*
* These child sessions normally delete themselves in `finally`, but a hard
* SIGKILL/OOM BETWEEN session-create and that delete would leave their prompts
Expand All @@ -15,10 +16,13 @@ type OpencodeClient = PluginContext["client"];
* CONCURRENCY: `session.delete` has no cross-process "active session" lease (OC
* peer confirmed), so the ONLY safe filter is AGE — a child older than any
* legitimate run cannot belong to a live run on another OpenCode process.
* Callers sweep dreamer/privacy titles and historian titles separately because
* their maximum legitimate runtimes are calculated from different budgets.
* OpenCode sets `title` + `time_created` immediately at create (not lazily), so
* the age gate is airtight. 404 on delete = already-swept = success.
*/
export const RETROSPECTIVE_CHILD_TITLE = "magic-context-dream-retrospective";
export const HISTORIAN_CHILD_TITLE = "magic-context-compartment";
export const USER_MEMORIES_CHILD_TITLE = "magic-context-dream-user-memories";
export const CURATE_CHILD_TITLE = "magic-context-dream-curate";
export const MAINTAIN_DOCS_CHILD_TITLE = "magic-context-dream-maintain-docs";
Expand All @@ -43,13 +47,28 @@ export interface PrivacySensitiveChildTitleMatches {
export const PRIVACY_SENSITIVE_CHILD_TITLE_MATCHES: PrivacySensitiveChildTitleMatches = {
exact: [
RETROSPECTIVE_CHILD_TITLE,
HISTORIAN_CHILD_TITLE,
USER_MEMORIES_CHILD_TITLE,
CURATE_CHILD_TITLE,
MAINTAIN_DOCS_CHILD_TITLE,
REFRESH_PRIMERS_CHILD_TITLE,
],
prefixes: [SMART_NOTE_COMPILE_CHILD_TITLE_PREFIX, SMART_NOTE_CONFIRM_CHILD_TITLE_PREFIX],
};
const PRIMARY_AND_SUGGESTED_CALLS_PER_CANDIDATE = 2;

export function privacyOrphanSweepTitleMatches(): PrivacySensitiveChildTitleMatches {
return {
exact: PRIVACY_SENSITIVE_CHILD_TITLE_MATCHES.exact.filter(
(title) => title !== HISTORIAN_CHILD_TITLE,
),
prefixes: PRIVACY_SENSITIVE_CHILD_TITLE_MATCHES.prefixes,
};
}

export function historianOrphanSweepTitleMatches(): PrivacySensitiveChildTitleMatches {
return { exact: [HISTORIAN_CHILD_TITLE], prefixes: [] };
}

/** Stale threshold from task timeout(s): max(60min, maxTimeout×3) — comfortably
* past every swept child type so a live child is never swept. */
Expand All @@ -67,6 +86,17 @@ export function retrospectiveOrphanStaleMs(
return Math.max(60 * 60_000, timeoutMs * 3);
}

export function historianOrphanStaleMs(timeoutMs: number, fallbackCount: number): number {
const candidateCount = Math.max(0, fallbackCount) + 1;
const attemptBudgetMinutes =
(timeoutMs *
MAX_HISTORIAN_PROMPT_ATTEMPTS *
PRIMARY_AND_SUGGESTED_CALLS_PER_CANDIDATE *
candidateCount) /
60_000;
return retrospectiveOrphanStaleMs(attemptBudgetMinutes);
}

interface OrphanRow {
id: string;
time_created: number;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { mkdirSync, unlinkSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { HISTORIAN_AGENT, HISTORIAN_EDITOR_AGENT } from "../../agents/historian";
import { DEFAULT_HISTORIAN_TIMEOUT_MS } from "../../config/schema/magic-context";
import {
DEFAULT_HISTORIAN_TIMEOUT_MS,
MAX_HISTORIAN_PROMPT_ATTEMPTS,
} from "../../config/schema/magic-context";
import { openDatabase } from "../../features/magic-context/storage";
import type { SubagentKind } from "../../features/magic-context/storage-subagent-invocations";
import {
Expand All @@ -19,7 +22,6 @@ import {
getProjectMagicContextHistorianDir,
} from "../../shared/data-path";
import { describeError, getErrorMessage } from "../../shared/error-message";
import { shouldKeepSubagents } from "../../shared/keep-subagents";
import { isRecord } from "../../shared/record-type-guard";
import type { Database } from "../../shared/sqlite";
import { createChildSessionWithFence } from "./child-session-spawn";
Expand All @@ -46,7 +48,7 @@ import {
function historianResponseDumpDir(directory: string): string {
return getProjectMagicContextHistorianDir(directory);
}
const MAX_HISTORIAN_RETRIES = 2;
const MAX_HISTORIAN_RETRIES = MAX_HISTORIAN_PROMPT_ATTEMPTS - 1;

interface HistorianModelOverride {
providerID: string;
Expand Down Expand Up @@ -325,9 +327,6 @@ async function runHistorianPrompt(args: {
let agentSessionId: string | null = null;
const startedAt = Date.now();
let invocationRecorded = false;
// Keep FAILED historian child sessions for debugging (the model output, the
// exact prompt, and the error are all inspectable in the child session). Only
// delete on SUCCESS, where the result is already persisted as a compartment.
let outcomeOk = false;

const recordInvocation = (params: {
Expand Down Expand Up @@ -489,23 +488,15 @@ async function runHistorianPrompt(args: {
error: `Historian failed while processing this session: ${desc.brief}`,
};
} finally {
// Delete the child session ONLY on success. On failure, keep it so the
// failed model output / prompt / error can be inspected for debugging
// (the run is already recorded as failed in subagent_invocations +
// historian_runs; the live child session is the missing piece). A periodic
// sweep can GC old failed child sessions later if needed.
if (agentSessionId && outcomeOk && !shouldKeepSubagents()) {
await client.session.delete({ path: { id: agentSessionId } }).catch((e: unknown) => {
shared.sessionLog(
parentSessionId,
"compartment agent: session cleanup failed",
getErrorMessage(e),
);
});
} else if (agentSessionId && (!outcomeOk || shouldKeepSubagents())) {
// OpenCode can persist detached summary/step parts after prompt completion
// and even after the session reports idle. Immediate deletion races those
// writes and produces foreign-key failures in opencode.db. Keep the child
// here; the age-gated orphan sweep removes it after every legitimate writer
// has had ample time to finish.
if (agentSessionId) {
shared.sessionLog(
parentSessionId,
`historian: KEEPING child session ${agentSessionId} (${outcomeOk ? "keep_subagents" : "failed"}) — not deleted`,
`historian: KEEPING child session ${agentSessionId} (${outcomeOk ? "deferred cleanup" : "failed"}) — not deleted`,
);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1296,6 +1296,7 @@ describe("runCompartmentAgent", () => {
},
],
}));
const statusSession = mock(async () => ({ data: { "ses-agent": { type: "idle" } } }));
const deleteSession = mock(async () => ({}));

const client = {
Expand All @@ -1304,6 +1305,7 @@ describe("runCompartmentAgent", () => {
create: createSession,
prompt: promptSession,
messages,
status: statusSession,
delete: deleteSession,
},
} as unknown as PluginContext["client"];
Expand All @@ -1327,6 +1329,8 @@ describe("runCompartmentAgent", () => {
query: { directory: "/tmp/parent" },
});
expect(promptSession.mock.calls[0]?.[0]?.body.agent).toBe("historian");
expect(statusSession).not.toHaveBeenCalled();
expect(deleteSession).not.toHaveBeenCalled();
});

it("keeps a committed publish succeeded and signaled when post-commit project registration throws", async () => {
Expand Down
49 changes: 49 additions & 0 deletions packages/plugin/src/hooks/magic-context/empty-task-output.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { describe, expect, test } from "bun:test";
import { annotateEmptyTaskOutput, EMPTY_TASK_OUTPUT_SENTINEL } from "./empty-task-output";

describe("annotateEmptyTaskOutput", () => {
test("surfaces a completed native task that returned no final text", () => {
const output = {
output: '<task id="ses-child" state="completed">\n<task_result>\n\n</task_result>\n</task>',
};

annotateEmptyTaskOutput("task", output);

expect(output.output).toContain(EMPTY_TASK_OUTPUT_SENTINEL);
});

test("leaves non-empty and non-task outputs unchanged", () => {
const taskOutput = { output: "completed" };
const toolOutput = { output: "" };

annotateEmptyTaskOutput("task", taskOutput);
annotateEmptyTaskOutput("read", toolOutput);

expect(taskOutput.output).toBe("completed");
expect(toolOutput.output).toBe("");
});

test("leaves bare empty and non-completed task results unchanged", () => {
const outputs = [
{ output: "" },
{ output: '<task id="error" state="error"><task_result></task_result></task>' },
{ output: '<task id="aborted" state="aborted"><task_result></task_result></task>' },
{ output: '<task id="running" state="running"><task_result></task_result></task>' },
];

for (const output of outputs) annotateEmptyTaskOutput("task", output);

expect(outputs.every(({ output }) => !output.includes(EMPTY_TASK_OUTPUT_SENTINEL))).toBe(
true,
);
});

test("does not throw when a completed empty task output is frozen", () => {
const output = Object.freeze({
output: '<task id="frozen" state="completed"><task_result></task_result></task>',
});

expect(() => annotateEmptyTaskOutput("task", output)).not.toThrow();
expect(output.output).not.toContain(EMPTY_TASK_OUTPUT_SENTINEL);
});
});
19 changes: 19 additions & 0 deletions packages/plugin/src/hooks/magic-context/empty-task-output.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { isRecord } from "../../shared/record-type-guard";

export const EMPTY_TASK_OUTPUT_SENTINEL = "<magic-context-empty-task-output>";
const EMPTY_COMPLETED_TASK_RESULT =
/^<task\b[^>]*\bstate="completed"[^>]*>[\s\S]*<task_result>\s*<\/task_result>\s*<\/task>\s*$/;

export function annotateEmptyTaskOutput(tool: string, output: unknown): void {
if (tool !== "task" || !isRecord(output)) return;
if (typeof output.output !== "string") return;
if (output.output.includes(EMPTY_TASK_OUTPUT_SENTINEL)) return;
if (!EMPTY_COMPLETED_TASK_RESULT.test(output.output)) return;

Reflect.set(
output,
"output",
`${output.output}\n${EMPTY_TASK_OUTPUT_SENTINEL}
The subagent completed without a final text response. Context-fill truncation may have omitted its final output, or its provider may have emitted reasoning only; inspect the child session and retry with a low-reasoning model or variant.`,
);
}
Loading
Loading