Q17: budget every model request against the provider's context window
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f42ab62dbd
commit
f4d16eb46a
26 files changed
+247
-52
No files matched your search
@@ -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'));
|
||||
`;
|
||||
@@ -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;
|
||||
|
||||
@@ -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<ProviderService, "runToolTurn">;
|
||||
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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.`);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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<string>();
|
||||
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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -34,6 +34,7 @@ export interface TaskExecutionProviderRow {
|
||||
label: string;
|
||||
provider_key: string;
|
||||
model: string | null;
|
||||
context_window_tokens: number;
|
||||
}
|
||||
|
||||
export interface ProjectRow {
|
||||
|
||||
@@ -28,6 +28,9 @@ export const zh: Record<string, string> = {
|
||||
"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 创建。",
|
||||
|
||||
@@ -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({
|
||||
<div className="card" style={{ padding: 0, overflowX: "auto", marginBottom: 28 }}>
|
||||
<div
|
||||
className="table-row head"
|
||||
style={{ gridTemplateColumns: POLICY_GRID, minWidth: 780 }}
|
||||
style={{ gridTemplateColumns: POLICY_GRID, minWidth: 900 }}
|
||||
>
|
||||
<div>{t("Provider")}</div><div>{t("Scope")}</div><div>{t("Data policy")}</div><div>{t("Background")}</div><div>{t("Monthly limit")}</div><div>{t("Usage")}</div>
|
||||
<div>{t("Provider")}</div><div>{t("Scope")}</div><div>{t("Data policy")}</div><div>{t("Background")}</div><div>{t("Monthly limit")}</div><div>{t("Context window (tokens)")}</div><div>{t("Usage")}</div>
|
||||
</div>
|
||||
{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<WorkspaceScope>(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)}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
aria-label={t("{label} context window", { label: provider.label })}
|
||||
title={t("The model's context window. Runs keep prompts, skills, history, and tool results inside it.")}
|
||||
type="number"
|
||||
min={24000}
|
||||
step={1000}
|
||||
value={contextWindowTokens}
|
||||
onChange={(event) => setContextWindowTokens(event.target.value)}
|
||||
/>
|
||||
<div>
|
||||
<div className="num mono">{rateLimit.requestCount} / {rateLimit.monthlyRequestLimit}</div>
|
||||
<button type="button" className="button small" aria-label={t("Save {label} policy", { label: provider.label })} onClick={savePolicy}>
|
||||
|
||||
@@ -61,6 +61,7 @@ export interface UpdateProviderPolicyInput {
|
||||
backgroundEnabled: boolean;
|
||||
monthlyRequestLimit: number;
|
||||
workspaceScope: "organization" | "workspace";
|
||||
contextWindowTokens: number;
|
||||
}
|
||||
|
||||
export interface CreateProviderResponseInput {
|
||||
|
||||
@@ -19,6 +19,8 @@ export interface ResolveAgentSkillsInput {
|
||||
orgId: string;
|
||||
agentId: string;
|
||||
runId: string;
|
||||
/** Token budget for the compiled skills. */
|
||||
maxTokens: number;
|
||||
}
|
||||
|
||||
export interface RunSkillTestsInput {
|
||||
|
||||
@@ -284,6 +284,8 @@ export interface ProviderRecord {
|
||||
backgroundEnabled: boolean;
|
||||
monthlyRequestLimit: number;
|
||||
workspaceScope: string;
|
||||
/** Model context window the run budget is computed against. */
|
||||
contextWindowTokens: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user