diff --git a/src/main/database/migrations/062-provider-context-window.ts b/src/main/database/migrations/062-provider-context-window.ts new file mode 100644 index 00000000..5c74cdb8 --- /dev/null +++ b/src/main/database/migrations/062-provider-context-window.ts @@ -0,0 +1,6 @@ +export const providerContextWindowSchema = ` + alter table providers add column context_window_tokens integer not null default 128000; + + insert into schema_migrations(version, name, applied_at) + values (62, 'provider_context_window', datetime('now')); +`; diff --git a/src/main/database/schema.ts b/src/main/database/schema.ts index 2d9f9768..aac108fe 100644 --- a/src/main/database/schema.ts +++ b/src/main/database/schema.ts @@ -60,6 +60,7 @@ import { integrationKeysSchema } from "./migrations/058-integration-keys"; import { taskHandoffSchema } from "./migrations/059-task-handoff"; import { chatWakeTextPurgeSchema } from "./migrations/060-chat-wake-text-purge"; import { taskRunContextSchema } from "./migrations/061-task-run-context"; +import { providerContextWindowSchema } from "./migrations/062-provider-context-window"; interface Migration { version: number; @@ -129,6 +130,7 @@ const MIGRATIONS: Migration[] = [ { version: 59, name: "task_handoff", sql: taskHandoffSchema }, { version: 60, name: "chat_wake_text_purge", sql: chatWakeTextPurgeSchema }, { version: 61, name: "task_run_context", sql: taskRunContextSchema }, + { version: 62, name: "provider_context_window", sql: providerContextWindowSchema }, ]; const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version; diff --git a/src/main/services/agent-goal-tools.ts b/src/main/services/agent-goal-tools.ts index 203261e8..b38b18a4 100644 --- a/src/main/services/agent-goal-tools.ts +++ b/src/main/services/agent-goal-tools.ts @@ -1,3 +1,4 @@ +import { fitContextBudget } from "./context-budget"; import { AuditService } from "./audit-service"; import { executeWorkspaceTool, WORKSPACE_TOOLS } from "./agent-workspace-tools"; import { optionalString, requireString } from "./agent-tool-args"; @@ -54,6 +55,7 @@ export interface GoalCompletionArgs { providers: Pick; providerId: string; workspaceId: string | null; + inputBudgetTokens: number; call: ToolCall; } @@ -129,6 +131,7 @@ async function auditGoalCompletion( const instructions = "You are the independent completion auditor. You did not do the work and owe its author nothing; your only loyalty is to what actually exists. Be strict: an unverifiable claim is a false claim."; for (let iteration = 1; iteration <= AUDIT_MAX_ITERATIONS; iteration += 1) { + fitContextBudget(instructions, messages, auditTools, args.inputBudgetTokens, iteration); const turn = await args.providers.runToolTurn({ orgId: args.ctx.orgId, providerId: args.providerId, diff --git a/src/main/services/agent-run-loop.ts b/src/main/services/agent-run-loop.ts index f36a34eb..fd209985 100644 --- a/src/main/services/agent-run-loop.ts +++ b/src/main/services/agent-run-loop.ts @@ -7,6 +7,7 @@ import { canonicalMentionHandle } from "../../shared/mention-handles"; import type { AgentTurnMessage, ToolDefinition } from "./provider-tool-turn"; import { isComposioTool, executeComposioTool } from "./composio/composio-tool-bridge"; import { compactToolHistory } from "./run-context-compaction"; +import { fitContextBudget } from "./context-budget"; const MAX_ITERATIONS = 12; @@ -22,6 +23,8 @@ export interface AgentRunLoopInput { pollNewMessages?: () => string | null; /** Called after each model turn the provider answered. */ onModelTurn?: () => void; + /** Input tokens the provider's context window leaves after the output reserve. */ + inputBudgetTokens: number; } export interface AgentRunLoopResult { @@ -56,6 +59,7 @@ export async function runAgentLoop(deps: AgentRunLoopDeps, input: AgentRunLoopIn messages.splice(0, messages.length, ...compaction.messages); console.info(`[run-loop] iteration ${iteration}: elided ${compaction.elided} earlier tool result(s)`); } + fitContextBudget(input.instructions, messages, allTools, input.inputBudgetTokens, iteration); const turn = await deps.providers.runToolTurn({ orgId: input.orgId, providerId: input.providerId, @@ -102,6 +106,7 @@ export async function runAgentLoop(deps: AgentRunLoopDeps, input: AgentRunLoopIn providers: deps.providers, providerId: input.providerId, workspaceId: input.workspaceId, + inputBudgetTokens: input.inputBudgetTokens, call, }); } else { diff --git a/src/main/services/context-budget.ts b/src/main/services/context-budget.ts new file mode 100644 index 00000000..1c9762d8 --- /dev/null +++ b/src/main/services/context-budget.ts @@ -0,0 +1,39 @@ +import { compactToolHistory } from "./run-context-compaction"; +import type { AgentTurnMessage, ToolDefinition } from "./provider-tool-turn"; + +/** Tokens kept free for the model's answer. */ +export const OUTPUT_RESERVE_TOKENS = 16_000; + +/** Share of the input budget the compiled skills may take. */ +export const SKILL_BUDGET_SHARE = 0.4; + +const CJK = /[\u3000-\u9fff\uac00-\ud7af\uf900-\ufaff\uff00-\uffef]/g; + +/** Conservative: CJK counts one token per character, other text one per three. */ +export function estimateTokens(text: string): number { + const cjk = text.match(CJK)?.length ?? 0; + return cjk + Math.ceil((text.length - cjk) / 3); +} + +export function inputBudgetTokens(contextWindowTokens: number): number { + return contextWindowTokens - OUTPUT_RESERVE_TOKENS; +} + +export function skillBudgetTokens(contextWindowTokens: number): number { + return Math.floor(inputBudgetTokens(contextWindowTokens) * SKILL_BUDGET_SHARE); +} + +export /** Shrinks history to fit the window, or fails with the numbers. */ +function fitContextBudget(instructions: string, messages: AgentTurnMessage[], tools: ToolDefinition[], budget: number, iteration: number): void { + const fixed = estimateTokens(instructions) + estimateTokens(JSON.stringify(tools)); + let total = fixed + estimateTokens(JSON.stringify(messages)); + if (total > budget) { + messages.splice(0, messages.length, ...compactToolHistory(messages, 1).messages); + const compacted = fixed + estimateTokens(JSON.stringify(messages)); + console.warn(`[run-loop] iteration ${iteration}: ~${total} tokens over the ${budget} budget; aggressive compaction brought it to ~${compacted}`); + total = compacted; + } + if (total > budget) { + throw new Error(`This run needs ~${total} input tokens but the provider's context window leaves ${budget}. Raise the provider's context window in Settings > Advanced if the model supports more, or split the task.`); + } +} diff --git a/src/main/services/provider-service.ts b/src/main/services/provider-service.ts index 7c790249..d5b1ae61 100644 --- a/src/main/services/provider-service.ts +++ b/src/main/services/provider-service.ts @@ -36,6 +36,9 @@ export type { ProviderResponseCreateInput }; const AUTH_SESSION_TTL_MS = 15 * 60 * 1000; +/** Room for instructions, the output reserve, and some history. */ +const MIN_CONTEXT_WINDOW_TOKENS = 24_000; + export class ProviderService { private readonly audit: AuditService; private readonly authBroker: AuthBrokerService; @@ -254,6 +257,10 @@ export class ProviderService { updatePolicy(input: UpdateProviderPolicyInput): void { const monthlyRequestLimit = Math.trunc(input.monthlyRequestLimit); if (monthlyRequestLimit < 1) throw new Error("Monthly request limit must be at least 1."); + const contextWindowTokens = Math.trunc(input.contextWindowTokens); + if (!(contextWindowTokens >= MIN_CONTEXT_WINDOW_TOKENS)) { + throw new Error(`Context window must be at least ${MIN_CONTEXT_WINDOW_TOKENS} tokens.`); + } if (!Object.values(PROVIDER_DATA_POLICY).includes(input.dataPolicy)) throw new Error("Provider data policy is invalid."); if (!WORKSPACE_SCOPES.has(input.workspaceScope)) throw new Error("Provider workspace scope is invalid."); if (input.dataPolicy === PROVIDER_DATA_POLICY.ZERO_DATA_RETENTION && input.backgroundEnabled) { @@ -265,9 +272,9 @@ export class ProviderService { this.db.prepare(` update providers - set data_policy = ?, background_enabled = ?, monthly_request_limit = ?, workspace_scope = ? + set data_policy = ?, background_enabled = ?, monthly_request_limit = ?, workspace_scope = ?, context_window_tokens = ? where id = ? and org_id = ? - `).run(input.dataPolicy, input.backgroundEnabled ? 1 : 0, monthlyRequestLimit, input.workspaceScope, input.providerId, input.orgId); + `).run(input.dataPolicy, input.backgroundEnabled ? 1 : 0, monthlyRequestLimit, input.workspaceScope, contextWindowTokens, input.providerId, input.orgId); this.audit.record({ orgId: input.orgId, entityType: "provider", @@ -281,6 +288,7 @@ export class ProviderService { backgroundEnabled: input.backgroundEnabled, monthlyRequestLimit, workspaceScope: input.workspaceScope, + contextWindowTokens, }, }); } diff --git a/src/main/services/run-context-compaction.ts b/src/main/services/run-context-compaction.ts index c343822d..d13d6630 100644 --- a/src/main/services/run-context-compaction.ts +++ b/src/main/services/run-context-compaction.ts @@ -5,10 +5,10 @@ const ELIDE_ABOVE_CHARS = 600; const PREVIEW_CHARS = 300; /** Older tool results shrink to a preview; the latest turns stay verbatim. */ -export function compactToolHistory(messages: AgentTurnMessage[]): { messages: AgentTurnMessage[]; elided: number } { +export function compactToolHistory(messages: AgentTurnMessage[], keepRecent = KEEP_RECENT_TOOL_TURNS): { messages: AgentTurnMessage[]; elided: number } { const toolTurnIndexes = messages.flatMap((message, index) => (message.role === "tool" ? [index] : [])); - const compactBefore = toolTurnIndexes.length > KEEP_RECENT_TOOL_TURNS - ? toolTurnIndexes[toolTurnIndexes.length - KEEP_RECENT_TOOL_TURNS] + const compactBefore = toolTurnIndexes.length > keepRecent + ? toolTurnIndexes[toolTurnIndexes.length - keepRecent] : 0; let elided = 0; const compacted = messages.map((message, index) => { diff --git a/src/main/services/skill-service.ts b/src/main/services/skill-service.ts index 632cb320..1ec573e7 100644 --- a/src/main/services/skill-service.ts +++ b/src/main/services/skill-service.ts @@ -1,3 +1,4 @@ +import { estimateTokens } from "./context-budget"; import { existsSync, readFileSync } from "node:fs"; import { createHash } from "node:crypto"; import { normalize } from "node:path"; @@ -34,13 +35,6 @@ export function forcedSkillSlugCandidates(language: string): string[] { return language === "zh" ? [HUMANIZER_SLUG_ZH, HUMANIZER_SLUG_EN] : [HUMANIZER_SLUG_EN, HUMANIZER_SLUG_ZH]; } -/** - * Compiled skill prompts stop packing past this size so one over-granted - * agent cannot overflow the model's context window. Skills pack whole, forced - * skills first, and anything omitted is named in the prompt. - */ -export const MAX_COMPILED_PROMPT_CHARS = 200_000; - export class SkillService { private readonly audit: AuditService; private readonly operations: OperationsService; @@ -243,8 +237,8 @@ export class SkillService { * Freezes the exact skill set this run carries: forced house-style skills * first (identical for everyone, CEO included), then the agent's enabled * bindings in priority order, deduplicated per skill. Skills pack whole - * until MAX_COMPILED_PROMPT_CHARS; whatever does not fit is named in the - * compiled prompt instead of silently overflowing the model's context. + * until input.maxTokens; whatever does not fit is named in the compiled + * prompt instead of silently overflowing the model's context. */ resolveRuntimeSnapshot(input: ResolveAgentSkillsInput): string { // Idempotent per run: a run interrupted after resolving (crash, quit) is @@ -280,7 +274,7 @@ export class SkillService { const tools = new Set(); const revisionIds: string[] = []; const omitted: string[] = []; - let compiledLength = 0; + let compiledTokens = 0; for (const row of rows) { if (!existsSync(row.object_key)) throw new Error(`Skill artifact is missing: ${row.object_key}`); const manifest = JSON.parse(row.manifest_json) as SkillManifest; @@ -289,14 +283,15 @@ export class SkillService { `Version: ${row.version_semver}`, readFileSync(row.object_key, "utf8").trim(), ].join("\n\n"); - if (compiledParts.length > 0 && compiledLength + part.length > MAX_COMPILED_PROMPT_CHARS) { + const partTokens = estimateTokens(part); + if (compiledParts.length > 0 && compiledTokens + partTokens > input.maxTokens) { omitted.push(manifest.name); continue; } for (const tool of manifest.requiredTools) tools.add(tool); revisionIds.push(row.revision_id); compiledParts.push(part); - compiledLength += part.length; + compiledTokens += partTokens; } if (omitted.length > 0) { compiledParts.push( diff --git a/src/main/services/task-execution-service.ts b/src/main/services/task-execution-service.ts index c405c971..9d1a2232 100644 --- a/src/main/services/task-execution-service.ts +++ b/src/main/services/task-execution-service.ts @@ -26,6 +26,7 @@ import { import { renderTaskHandoff } from "./agent-task-handoff"; import { createRunContextRecorder, createRunMessagePoller, loadChatWakeContext } from "./chat-wake-context"; import { runningWorkspaceWriter } from "./workspace-write-lock"; +import { inputBudgetTokens, skillBudgetTokens } from "./context-budget"; import { loadComposioTools } from "./composio/composio-tool-bridge"; import { connectorDisplayName } from "./composio/connector-brands"; import { ConnectorGrantsService } from "./composio/connector-grants"; @@ -155,7 +156,7 @@ export class TaskExecutionService { let providerRequestId: string | null = null; let skillRuntimeSnapshot: SkillRuntimeSnapshotRow | null = null; try { - skillRuntimeSnapshot = this.resolveSkillRuntimeSnapshot(orgId, agent.id, runId); + skillRuntimeSnapshot = this.resolveSkillRuntimeSnapshot(orgId, agent.id, runId, skillBudgetTokens(provider.context_window_tokens)); // Watermark for reply delivery and mid-run message polling. const runStartedAt = new Date().toISOString(); const chatContext = loadChatWakeContext(this.db, orgId, agent.id, isolation.secretScopeJson); @@ -208,6 +209,7 @@ export class TaskExecutionService { toolContext, externalTools, onModelTurn: contextRecorder.commit, + inputBudgetTokens: inputBudgetTokens(provider.context_window_tokens), pollNewMessages: createRunMessagePoller(this.db, { orgId, onInjected: contextRecorder.addMidRun, agentId: agent.id, sourceThreadId: chatContext?.threadId ?? null, runStartedAt }), }, ); @@ -312,7 +314,7 @@ export class TaskExecutionService { private requireAgentProvider(orgId: string, agent: TaskExecutionAgentRow): TaskExecutionProviderRow { if (!agent.model_provider_id) throw new Error("Task assignee requires a model provider."); const provider = this.db.prepare(` - select id, label, provider_key, model + select id, label, provider_key, model, context_window_tokens from providers where id = ? and org_id = ? `).get(agent.model_provider_id, orgId) as TaskExecutionProviderRow | undefined; @@ -614,9 +616,9 @@ export class TaskExecutionService { `).run(TASK_RUN_STATE.FAILED, providerRequestId, new Date().toISOString(), errorMessage, runId, orgId); } - private resolveSkillRuntimeSnapshot(orgId: string, agentId: string, runId: string): SkillRuntimeSnapshotRow | null { + private resolveSkillRuntimeSnapshot(orgId: string, agentId: string, runId: string, maxTokens: number): SkillRuntimeSnapshotRow | null { if (!this.skills.hasRuntimeSkills(orgId, agentId)) return null; - const snapshotId = this.skills.resolveRuntimeSnapshot({ orgId, agentId, runId }); + const snapshotId = this.skills.resolveRuntimeSnapshot({ orgId, agentId, runId, maxTokens }); const snapshot = this.db.prepare(` select id, compiled_prompt, tools_manifest, source_revision_ids from skill_runtime_snapshots diff --git a/src/main/services/task-prompts.ts b/src/main/services/task-prompts.ts index 614aaf00..20875096 100644 --- a/src/main/services/task-prompts.ts +++ b/src/main/services/task-prompts.ts @@ -34,6 +34,7 @@ export interface TaskExecutionProviderRow { label: string; provider_key: string; model: string | null; + context_window_tokens: number; } export interface ProjectRow { diff --git a/src/renderer/src/i18n/zh.ts b/src/renderer/src/i18n/zh.ts index 723ee5fc..0761c558 100644 --- a/src/renderer/src/i18n/zh.ts +++ b/src/renderer/src/i18n/zh.ts @@ -28,6 +28,9 @@ export const zh: Record = { "Advanced": "高级", "Integrations": "集成密钥", "Read by {names}": "已读:{names}", + "Context window (tokens)": "上下文窗口(token)", + "{label} context window": "{label} 上下文窗口", + "The model's context window. Runs keep prompts, skills, history, and tool results inside it.": "模型的上下文窗口。运行时提示词、技能、历史和工具结果都要放进这个范围。", "Keys are stored on this device, encrypted by the operating system, and shared by every company.": "密钥保存在本机,由操作系统加密,所有公司共用。", "Composio API key": "Composio API 密钥", "Powers the Connectors page and agent connector tools. Create one at platform.composio.dev.": "用于连接器页面和 Agent 的连接器工具。在 platform.composio.dev 创建。", diff --git a/src/renderer/src/pages/settings/ProvidersPanel.tsx b/src/renderer/src/pages/settings/ProvidersPanel.tsx index 7823f43b..0694e2af 100644 --- a/src/renderer/src/pages/settings/ProvidersPanel.tsx +++ b/src/renderer/src/pages/settings/ProvidersPanel.tsx @@ -26,7 +26,7 @@ interface ProvidersPanelProps { actions: SnapshotActions; } -const POLICY_GRID = "minmax(150px, 1.2fr) minmax(118px, 0.8fr) minmax(140px, 1fr) 112px 124px 92px"; +const POLICY_GRID = "minmax(150px, 1.2fr) minmax(118px, 0.8fr) minmax(140px, 1fr) 112px 124px 124px 92px"; type WorkspaceScope = "organization" | "workspace"; export function ProvidersPanel({ orgId, employeeId, providers, authProfiles, catalog, agents, providerRateLimits, actions }: ProvidersPanelProps) { @@ -138,9 +138,9 @@ function ProviderPolicyTable({
-
{t("Provider")}
{t("Scope")}
{t("Data policy")}
{t("Background")}
{t("Monthly limit")}
{t("Usage")}
+
{t("Provider")}
{t("Scope")}
{t("Data policy")}
{t("Background")}
{t("Monthly limit")}
{t("Context window (tokens)")}
{t("Usage")}
{providers.map((provider, index) => { const rateLimit = requireProviderRateLimit(provider, providerRateLimits); @@ -176,6 +176,7 @@ function ProviderPolicyRow({ const [dataPolicy, setDataPolicy] = useState(provider.dataPolicy); const [backgroundEnabled, setBackgroundEnabled] = useState(provider.backgroundEnabled); const [monthlyRequestLimit, setMonthlyRequestLimit] = useState(String(provider.monthlyRequestLimit)); + const [contextWindowTokens, setContextWindowTokens] = useState(String(provider.contextWindowTokens)); const [workspaceScope, setWorkspaceScope] = useState(provider.workspaceScope === "workspace" ? "workspace" : "organization"); const savePolicy = () => { @@ -186,6 +187,7 @@ function ProviderPolicyRow({ backgroundEnabled, monthlyRequestLimit: Number(monthlyRequestLimit), workspaceScope, + contextWindowTokens: Number(contextWindowTokens), })); }; @@ -199,7 +201,7 @@ function ProviderPolicyRow({ className="table-row" style={{ gridTemplateColumns: POLICY_GRID, - minWidth: 780, + minWidth: 900, borderBottom: last ? "none" : "1px solid var(--border-soft)", }} > @@ -242,6 +244,16 @@ function ProviderPolicyRow({ value={monthlyRequestLimit} onChange={(event) => setMonthlyRequestLimit(event.target.value)} /> + setContextWindowTokens(event.target.value)} + />
{rateLimit.requestCount} / {rateLimit.monthlyRequestLimit}