feat(agent): add context window management with token-aware compaction (#14)

* feat(agent): add context window guard to prevent token overflow

Implement token-aware context management that validates context window
size on agent initialization and provides intelligent message compaction
based on actual token usage rather than simple message count.

Key changes:
- Add context-window module with guard, token estimation, and types
- Support both "count" (legacy) and "tokens" (new default) compaction modes
- Warn when context window < 32K tokens, block when < 16K tokens
- Trigger compaction at 80% utilization, target 50% after compaction

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(agent): add summary-based compaction using LLM

Implement intelligent compaction that uses LLM to generate summaries
of older messages instead of simply truncating them. This preserves
important context like key decisions, TODOs, and technical details.

Key changes:
- Add summarization.ts with compactMessagesWithSummary functions
- Support chunked summarization for very large histories
- Add "summary" compaction mode alongside "count" and "tokens"
- Auto-resolve API key from environment based on provider
- Graceful fallback to "tokens" mode if model/apiKey unavailable

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jiayuan 2026-01-30 03:46:11 +08:00 committed by GitHub
parent 3024e89071
commit 67cd46a072
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 1116 additions and 16 deletions

View file

@ -5,12 +5,45 @@ import { createAgentOutput } from "./output.js";
import { resolveModel, resolveTools } from "./tools.js";
import { SessionManager } from "./session/session-manager.js";
import { ProfileManager } from "./profile/index.js";
import {
checkContextWindow,
DEFAULT_CONTEXT_TOKENS,
type ContextWindowGuardResult,
} from "./context-window/index.js";
/**
* provider API Key
*/
function resolveApiKey(provider: string): string | undefined {
const providerEnvMap: Record<string, string> = {
openai: "OPENAI_API_KEY",
anthropic: "ANTHROPIC_API_KEY",
google: "GOOGLE_API_KEY",
"google-genai": "GOOGLE_API_KEY",
kimi: "MOONSHOT_API_KEY",
"kimi-coding": "MOONSHOT_API_KEY",
deepseek: "DEEPSEEK_API_KEY",
groq: "GROQ_API_KEY",
mistral: "MISTRAL_API_KEY",
together: "TOGETHER_API_KEY",
};
const envVar = providerEnvMap[provider];
if (envVar) {
return process.env[envVar];
}
// 尝试通用格式: PROVIDER_API_KEY
const normalizedProvider = provider.toUpperCase().replace(/-/g, "_");
return process.env[`${normalizedProvider}_API_KEY`];
}
export class Agent {
private readonly agent: PiAgentCore;
private readonly output;
private readonly session: SessionManager;
private readonly profile?: ProfileManager;
private readonly contextWindowGuard: ContextWindowGuardResult;
/** 当前会话 ID */
readonly sessionId: string;
@ -23,34 +56,87 @@ export class Agent {
this.agent = new PiAgentCore();
// 加载 Agent Profile如果指定了 profileId
let systemPrompt: string | undefined;
if (options.profileId) {
this.profile = new ProfileManager({
profileId: options.profileId,
baseDir: options.profileBaseDir,
});
const systemPrompt = this.profile.buildSystemPrompt();
systemPrompt = this.profile.buildSystemPrompt();
if (systemPrompt) {
this.agent.setSystemPrompt(systemPrompt);
}
} else if (options.systemPrompt) {
// 直接使用传入的 systemPrompt
systemPrompt = options.systemPrompt;
this.agent.setSystemPrompt(options.systemPrompt);
}
this.sessionId = options.sessionId ?? uuidv7();
this.session = new SessionManager({ sessionId: this.sessionId });
const storedMeta = this.session.getMeta();
if (!options.thinkingLevel && storedMeta?.thinkingLevel) {
this.agent.setThinkingLevel(storedMeta.thinkingLevel as any);
} else if (options.thinkingLevel) {
this.agent.setThinkingLevel(options.thinkingLevel);
}
// 解析 model用于获取 context window
const storedMeta = (() => {
// 临时创建 session 获取 meta避免循环依赖
const tempSession = new SessionManager({ sessionId: this.sessionId });
return tempSession.getMeta();
})();
const model = options.provider && options.model ? resolveModel(options) : resolveModel({
...options,
provider: storedMeta?.provider,
model: storedMeta?.model,
});
// === Context Window Guard ===
this.contextWindowGuard = checkContextWindow({
modelContextWindow: model.contextWindow,
configContextTokens: options.contextWindowTokens,
defaultTokens: DEFAULT_CONTEXT_TOKENS,
});
// 警告context window 较小
if (this.contextWindowGuard.shouldWarn) {
stderr.write(
`[Context Window Guard] WARNING: Low context window: ${this.contextWindowGuard.tokens} tokens (source: ${this.contextWindowGuard.source})\n`,
);
}
// 阻止context window 太小
if (this.contextWindowGuard.shouldBlock) {
throw new Error(
`[Context Window Guard] Context window too small: ${this.contextWindowGuard.tokens} tokens. ` +
`Minimum required: 16,000 tokens. Please use a model with a larger context window.`,
);
}
// 确定 compaction 模式
const compactionMode = options.compactionMode ?? "tokens"; // 默认使用 token 模式
// 获取 API Key用于 summary 模式)
const apiKey = compactionMode === "summary" ? resolveApiKey(model.provider) : undefined;
// 创建 SessionManager带 context window 配置)
this.session = new SessionManager({
sessionId: this.sessionId,
compactionMode,
// Token 模式参数
contextWindowTokens: this.contextWindowGuard.tokens,
systemPrompt,
reserveTokens: options.reserveTokens,
targetRatio: options.compactionTargetRatio,
minKeepMessages: options.minKeepMessages,
// Summary 模式参数
model: compactionMode === "summary" ? model : undefined,
apiKey,
customInstructions: options.summaryInstructions,
});
if (!options.thinkingLevel && storedMeta?.thinkingLevel) {
this.agent.setThinkingLevel(storedMeta.thinkingLevel as any);
} else if (options.thinkingLevel) {
this.agent.setThinkingLevel(options.thinkingLevel);
}
this.agent.setModel(model);
this.agent.setTools(resolveTools(options));
@ -63,6 +149,7 @@ export class Agent {
provider: this.agent.state.model?.provider,
model: this.agent.state.model?.id,
thinkingLevel: this.agent.state.thinkingLevel,
contextWindowTokens: this.contextWindowGuard.tokens,
});
this.agent.subscribe((event: AgentEvent) => {