Skip to content
Merged
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
268 changes: 268 additions & 0 deletions docs/context-os.md

Large diffs are not rendered by default.

119 changes: 107 additions & 12 deletions packages/core/src/agent-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ import { createSettingsTools } from './tools/settings.js';
import { createMultiModalTools } from './tools/multimodal.js';
import { createFeishuTools, type FeishuToolsConfig } from './tools/feishu.js';
import { createRecallTool, type RecallCallbacks } from './tools/recall.js';
import { createSessionTool, type SessionRepo } from './tools/session.js';
import { createSessionTool, createMemorySessionRepo, type SessionRepo, type SessionCompactor, type SessionSlotStore, type SessionFragmentStore } from './tools/session.js';
import { SemanticMemorySearch, OpenAIEmbeddingProvider, LocalVectorStore } from './memory/semantic-search.js';
import type { SkillRegistry } from './skills/types.js';
import { clickChromeAllowDialog } from './tools/chrome-dialog-clicker.js';
Expand Down Expand Up @@ -418,7 +418,6 @@ export class AgentManager {
onEnd: (activityId: string, summary: { endedAt: string; totalTokens: number; totalTools: number; success: boolean }) => void;
};
private recallCallbacks?: RecallCallbacks;
private sessionRepo?: SessionRepo;
private delegationManager: DelegationManager;
private _maxToolIterations = Infinity;
private _cognitiveConfig?: CognitiveConfig;
Expand Down Expand Up @@ -1203,6 +1202,36 @@ export class AgentManager {

const agent = new Agent(agentOpts);

// (0.9.7 context-root-fix) Agent-driven context compaction: agents may
// actively collapse stale session history on demand via the session tool.
// ContextOS: also expose slot pinning + fragment archive management.
const mem = agent.getMemory();
const compactor: SessionCompactor = {
compactOnDemand: (sessionId: string, keepLast: number) =>
mem.compactSession(sessionId, keepLast),
compactWithAnchor: (sessionId, keepLast, anchor) => {
// Pin the structured anchor into the durable [SLOTS] segment so the
// agent keeps its position even after compaction, then compact.
if (mem.setSlot && anchor.goal) mem.setSlot(sessionId, 'goal', anchor.goal);
if (mem.setSlot && anchor.done) mem.setSlot(sessionId, 'done', anchor.done);
if (mem.setSlot && anchor.next) mem.setSlot(sessionId, 'next', anchor.next);
const res = mem.compactSession(sessionId, keepLast);
return { ...res, anchorKey: ['goal', 'done', 'next'].filter((k) => anchor[k as keyof typeof anchor]).join(',') || 'anchor' };
},
};
const slotStore: SessionSlotStore = {
getSlots: (sid) => mem.getSlots ? mem.getSlots(sid) : [],
setSlot: (sid, k, v) => { if (mem.setSlot) mem.setSlot(sid, k, v); },
removeSlot: (sid, k) => { if (mem.removeSlot) mem.removeSlot(sid, k); },
serialize: (sid) => mem.serializeSlots ? mem.serializeSlots(sid) : '',
};
const fragmentStore: SessionFragmentStore = {
retrieveFragments: (q, mx) => mem.retrieveFragments ? mem.retrieveFragments(q, mx) : [],
includeFragment: (sid, fid) => mem.includeFragment ? mem.includeFragment(sid, fid) : { ok: false, message: 'unavailable' },
purgeSessionFragments: (sid) => mem.purgeSessionFragments ? mem.purgeSessionFragments(sid) : 0,
sessionStats: (sid) => mem.sessionStats ? mem.sessionStats(sid) : { messageCount: 0, slotKeys: [], fragmentCount: 0 },
};

// Progressive disclosure: skill catalog is metadata-only (name + description).
// Full SKILL.md bodies enter context only after discover_tools activation.
if (this.skillRegistry) {
Expand Down Expand Up @@ -1428,10 +1457,15 @@ export class AgentManager {
if (this.recallCallbacks) {
agent.registerTool(createRecallTool({ agentId: id, ...this.recallCallbacks }));
}
// Session tool — agents can query their own conversation sessions
if (this.sessionRepo) {
agent.registerTool(createSessionTool({ agentId: id, chatSessionRepo: this.sessionRepo }));
}
// Session tool — agents can query and manage their OWN conversation sessions.
// Uses the Memory-backed repo (the same session space the agent's context is
// built from) so list/get/compact/pin/status/fragments stay consistent.
// ContextOS fix: previously wired to the Sqlite `cs_*` repo here (split-brain
// with MemoryStore `sess_*`); memory is the single source of truth.
agent.registerTool(createSessionTool({
agentId: id, chatSessionRepo: createMemorySessionRepo(mem),
compactor, slotStore, fragmentStore,
}));

// Settings tools — agents can list providers and switch models via chat
for (const tool of createSettingsTools({
Expand Down Expand Up @@ -2307,8 +2341,36 @@ export class AgentManager {
if (this.recallCallbacks) {
agent.registerTool(createRecallTool({ agentId: id, ...this.recallCallbacks }));
}
if (this.sessionRepo) {
agent.registerTool(createSessionTool({ agentId: id, chatSessionRepo: this.sessionRepo }));
// Memory-backed session repo — see createMemorySessionRepo doc for the
// split-brain fix (was: this.sessionRepo Sqlite cs_*).
{
const mem2 = agent.getMemory();
agent.registerTool(createSessionTool({
agentId: id,
chatSessionRepo: createMemorySessionRepo(mem2),
compactor: {
compactOnDemand: (sid, keep) => mem2.compactSession(sid, keep),
compactWithAnchor: (sid, keep, anchor) => {
if (mem2.setSlot && anchor.goal) mem2.setSlot(sid, 'goal', anchor.goal);
if (mem2.setSlot && anchor.done) mem2.setSlot(sid, 'done', anchor.done);
if (mem2.setSlot && anchor.next) mem2.setSlot(sid, 'next', anchor.next);
const res = mem2.compactSession(sid, keep);
return { ...res, anchorKey: ['goal', 'done', 'next'].filter((k) => anchor[k as keyof typeof anchor]).join(',') || 'anchor' };
},
},
slotStore: {
getSlots: (sid) => mem2.getSlots ? mem2.getSlots(sid) : [],
setSlot: (sid, k, v) => { if (mem2.setSlot) mem2.setSlot(sid, k, v); },
removeSlot: (sid, k) => { if (mem2.removeSlot) mem2.removeSlot(sid, k); },
serialize: (sid) => mem2.serializeSlots ? mem2.serializeSlots(sid) : '',
},
fragmentStore: {
retrieveFragments: (q, mx) => mem2.retrieveFragments ? mem2.retrieveFragments(q, mx) : [],
includeFragment: (sid, fid) => mem2.includeFragment ? mem2.includeFragment(sid, fid) : { ok: false, message: 'unavailable' },
purgeSessionFragments: (sid) => mem2.purgeSessionFragments ? mem2.purgeSessionFragments(sid) : 0,
sessionStats: (sid) => mem2.sessionStats ? mem2.sessionStats(sid) : { messageCount: 0, slotKeys: [], fragmentCount: 0 },
},
}));
}

for (const tool of createSettingsTools({
Expand Down Expand Up @@ -3012,11 +3074,44 @@ export class AgentManager {
}
}

/** Wire the session query tool to a chat session repo for all agents. */
setSessionRepo(repo: SessionRepo): void {
this.sessionRepo = repo;
/**
* Legacy wiring hook kept for API compatibility. The session tool is now
* ALWAYS Memory-backed (createSessionRepo) — the agent's context is built
* from MemoryStore sessions, so the tool must read/write that same space.
* Wiring it to the Sqlite `cs_*` repo (as before) split brain: ownership
* checks passed for cs_* ids but MemoryStore had no such sessions, so
* pin/compact silently persisted nothing. The external `repo` is ignored
* for tool wiring (kept for any external consumers of this hook).
*/
setSessionRepo(_repo: SessionRepo): void {
for (const [id, agent] of this.agents) {
agent.registerTool(createSessionTool({ agentId: id, chatSessionRepo: repo }));
const mem = agent.getMemory();
agent.registerTool(createSessionTool({
agentId: id,
chatSessionRepo: createMemorySessionRepo(mem),
compactor: {
compactOnDemand: (sid, keep) => mem.compactSession(sid, keep),
compactWithAnchor: (sid, keep, anchor) => {
if (mem.setSlot && anchor.goal) mem.setSlot(sid, 'goal', anchor.goal);
if (mem.setSlot && anchor.done) mem.setSlot(sid, 'done', anchor.done);
if (mem.setSlot && anchor.next) mem.setSlot(sid, 'next', anchor.next);
const res = mem.compactSession(sid, keep);
return { ...res, anchorKey: ['goal', 'done', 'next'].filter((k) => anchor[k as keyof typeof anchor]).join(',') || 'anchor' };
},
},
slotStore: {
getSlots: (sid) => mem.getSlots ? mem.getSlots(sid) : [],
setSlot: (sid, k, v) => { if (mem.setSlot) mem.setSlot(sid, k, v); },
removeSlot: (sid, k) => { if (mem.removeSlot) mem.removeSlot(sid, k); },
serialize: (sid) => mem.serializeSlots ? mem.serializeSlots(sid) : '',
},
fragmentStore: {
retrieveFragments: (q, mx) => mem.retrieveFragments ? mem.retrieveFragments(q, mx) : [],
includeFragment: (sid, fid) => mem.includeFragment ? mem.includeFragment(sid, fid) : { ok: false, message: 'unavailable' },
purgeSessionFragments: (sid) => mem.purgeSessionFragments ? mem.purgeSessionFragments(sid) : 0,
sessionStats: (sid) => mem.sessionStats ? mem.sessionStats(sid) : { messageCount: 0, slotKeys: [], fragmentCount: 0 },
},
}));
}
}

Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3654,6 +3654,8 @@ export class Agent {
...this.getPrepareBudgetOpts(),
toolDefinitions: llmTools,
systemCacheSegments,
slotsSegment: this.memory.serializeSlots ? this.memory.serializeSlots(sessionId) : undefined,
summarySegment: this.memory.serializeSummary ? this.memory.serializeSummary(sessionId) : undefined,
});

// Afford fail-closed (Afford.S1): shared helper for stream + non-stream.
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/capability-packs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export const TOOL_DEF_CORE_KEEP = new Set([
'agent_list_colleagues',
'deliverable_search',
'requirement_comment',
'session',
]);

/** MCP / skill-namespaced tools — evict these before core Markus tools. */
Expand Down
63 changes: 55 additions & 8 deletions packages/core/src/context-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import {
SYSTEM_TEAM_PROJECT_DESC_CHARS,
CONTEXT_ABSURD_MESSAGE_CHARS,
CONTEXT_PROACTIVE_COMPACT_RATIO,
CONTEXT_WARN_RATIO,
CONTEXT_CRIT_RATIO,
PROMPT_AFFORD_OUTPUT_RESERVE,
SYSTEM_COLLEAGUES_MAX,
SYSTEM_OTHER_TEAMS_MAX,
Expand Down Expand Up @@ -154,6 +156,12 @@ export interface PreparedContext {
messages: LLMMessage[];
usage: ContextUsageStats;
systemCacheSegments?: SystemPromptSegment[];
/** ContextOS: agent-visible context water-level hint, e.g. `[CONTEXT 68% used …]`. */
contextHint?: string;
/** ContextOS: fixed段 C — the agent-pinned [SLOTS] segment (never compacted). */
slotsSegment?: string;
/** ContextOS: fixed段 C — the durable compaction summary [CONTEXT SUMMARY] segment. */
summarySegment?: string;
}

export interface SystemPromptSegment {
Expand Down Expand Up @@ -1756,6 +1764,11 @@ export class ContextEngine {
inputSchema: Record<string, unknown>;
}>;
systemCacheSegments?: SystemPromptSegment[];
/** ContextOS: fixed段 C — agent-pinned slots, injected verbatim and never compacted. */
slotsSegment?: string;
/** ContextOS: fixed段 C — durable compaction summary anchor, injected verbatim
* as its own [CONTEXT SUMMARY] block (semantically separate from slots). */
summarySegment?: string;
}): Promise<PreparedContext> {
// No silent defaults: a missing/zero context window is exactly what
// silently drove the message budget negative and made the agent return
Expand All @@ -1774,13 +1787,20 @@ export class ContextEngine {
const toolDefTokens = opts.toolDefinitions
? estimateTokens(JSON.stringify(opts.toolDefinitions), this.tokenCounter)
: 0;
// ContextOS: pinned slots are part of the fixed segment — reserve budget for
// them and never let them enter the variable-segment compression path.
const slotsSegment = opts.slotsSegment ?? '';
const slotsTokens = slotsSegment ? estimateTokens(slotsSegment, this.tokenCounter) : 0;
// ContextOS: durable compaction summary is a separate fixed segment block.
const summarySegment = opts.summarySegment ?? '';
const summaryTokens = summarySegment ? estimateTokens(summarySegment, this.tokenCounter) : 0;
let safetyMargin = Math.ceil(Math.min(contextWindow * 0.08, 16_000));
let messageBudget = contextWindow - systemTokens - toolDefTokens - maxOutput - safetyMargin;
let messageBudget = contextWindow - systemTokens - toolDefTokens - slotsTokens - summaryTokens - maxOutput - safetyMargin;

// ── Defensive budget reclamation ────────────────────────────────────
const MIN_MESSAGE_BUDGET = 1500;
const MIN_OUTPUT_RESERVE = 2048;
const staticOverhead = systemTokens + toolDefTokens;
const staticOverhead = systemTokens + toolDefTokens + slotsTokens + summaryTokens;
if (messageBudget < MIN_MESSAGE_BUDGET) {
safetyMargin = Math.min(safetyMargin, 4000);
const roomForOutput = contextWindow - staticOverhead - safetyMargin - MIN_MESSAGE_BUDGET;
Expand Down Expand Up @@ -1844,8 +1864,8 @@ export class ContextEngine {
const currentTurnStart = this.findCurrentTurnStart(messages);
let totalTokens = this.sumTokens(messages);

const packingCeiling = systemTokens + toolDefTokens + messageBudget;
const preCompressionUsed = systemTokens + toolDefTokens + totalTokens;
const packingCeiling = systemTokens + toolDefTokens + slotsTokens + messageBudget;
const preCompressionUsed = systemTokens + toolDefTokens + slotsTokens + totalTokens;
const effectiveBudget = Math.min(contextWindow - maxOutput, packingCeiling);
const preCompressionPct = effectiveBudget > 0 ? (preCompressionUsed / effectiveBudget) * 100 : 0;
const perMessageCap = Math.max(8_000, Math.floor(messageBudget / 4));
Expand Down Expand Up @@ -1913,10 +1933,23 @@ export class ContextEngine {
});
}

const totalUsed = systemTokens + toolDefTokens + totalTokens;
const totalUsed = systemTokens + toolDefTokens + slotsTokens + totalTokens;
const available = Math.max(0, messageBudget - totalTokens);
const usagePercent = effectiveBudget > 0 ? (totalUsed / effectiveBudget) * 100 : 0;

// ── ContextOS: build the agent-visible [CONTEXT] water-level hint ──
// Three segments: fixed (system + tools + slots) vs variable (history).
// Rendered as a compact one-liner + a WARN/CRIT escalation line.
const fixedTokens = systemTokens + toolDefTokens + slotsTokens;
const usedPct = Math.round((totalUsed / effectiveBudget) * 1000) / 10;
let hintLines = `[CONTEXT ${usedPct}% used — window ${Math.round(contextWindow / 1000)}k · fixed ${fixedTokens} (system ${systemTokens} + tools ${toolDefTokens}${slotsTokens ? ` + slots ${slotsTokens}` : ''}) · variable ${totalTokens} · output reserve ${maxOutput}]`;
if (usedPct >= CONTEXT_CRIT_RATIO * 100) {
hintLines += `\n[CONTEXT CRIT] at ${usedPct}%: system will hard-trim oldest turns. Run session_compact now, and session_pin a goal/done/next anchor to keep your position.`;
} else if (usedPct >= CONTEXT_WARN_RATIO * 100) {
hintLines += `\n[CONTEXT WARN] at ${usedPct}% → compress now (session_compact) or pin an anchor (session_pin).`;
}
const contextHint = hintLines;

log.info('Context assembled', {
contextWindow,
messageBudget,
Expand All @@ -1942,7 +1975,18 @@ export class ContextEngine {
}

return {
messages: [{ role: 'system', content: opts.systemPrompt }, ...messages],
messages: [
{
role: 'system',
content: [
opts.systemPrompt,
slotsSegment ? `\n\n${slotsSegment}` : '',
summarySegment ? `\n\n${summarySegment}` : '',
`\n\n${contextHint}`, // ContextOS: agent-visible water level, rebuilt every turn
].join(''),
},
...messages,
],
usage: {
contextWindow,
systemTokens,
Expand All @@ -1959,6 +2003,9 @@ export class ContextEngine {
promptAffordTokens: promptAfford,
},
systemCacheSegments: opts.systemCacheSegments,
contextHint,
slotsSegment,
summarySegment,
};
}

Expand Down Expand Up @@ -2027,7 +2074,7 @@ export class ContextEngine {
});
const summaryMessage: LLMMessage = {
role: 'user',
content: `[Conversation history summary — ${older.length} earlier messages were compacted by LLM]\n${summary}\n[End of summary. The conversation continues below.]`,
content: `[SYSTEM] [Conversation history summary — ${older.length} earlier messages were compacted by LLM]\n${summary}\n[End of summary. The conversation continues below.]`,
};
// No side-effects: prepareMessages must be pure (no writeDailyLog here)
return [...protectedPrefix, summaryMessage, ...retained];
Expand All @@ -2044,7 +2091,7 @@ export class ContextEngine {
if (heuristicSummary) {
const summaryMessage: LLMMessage = {
role: 'user',
content: `[Conversation history summary — ${heuristicOlder.length} earlier messages were compacted]\n${heuristicSummary}\n[End of summary.]`,
content: `[SYSTEM] [Conversation history summary — ${heuristicOlder.length} earlier messages were compacted]\n${heuristicSummary}\n[End of summary.]`,
};
return [...protectedPrefix, summaryMessage, ...heuristicRetained];
}
Expand Down
Loading
Loading