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
34 changes: 34 additions & 0 deletions apps/desktop/src/main/main.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -142,6 +142,7 @@ import {
testConnection,
} from '@maka/runtime';
import type { BotIncomingMessage, ToolArtifactRecorderInput } from '@maka/runtime';
import type { ContextBudgetPolicy } from '@maka/runtime';
import { testProxyConnection } from '@maka/runtime/network/proxy-test';
import { fetchWeChatQrcode, pollWeChatQrcodeStatus } from './wechat-scan-login.js';
import {
Expand DownExpand Up@@ -735,6 +736,7 @@ backends.register('ai-sdk', async (ctx) => {
modelFactory: (input) => getAIModel({ ...input, fetch: modelFetch }),
tools: builtinTools,
providerOptions: buildProviderOptions(connection, model),
contextBudget: buildContextBudgetPolicy(connection),
systemPrompt: ({ cwd }) => buildSystemPrompt(ctx.header, cwd),
turnTailPrompt: ({ cwd }) => buildTurnTailPrompt(cwd),
recordLlmCall: (event) => recordLlmCall({ repo: telemetryRepo, lookupPricing }, event),
Expand All@@ -756,6 +758,38 @@ backends.register('ai-sdk', async (ctx) => {
});
});

function buildContextBudgetPolicy(connection: LlmConnection): ContextBudgetPolicy | undefined {
if (process.env.MAKA_CONTEXT_BUDGET === 'off') return undefined;
const maxHistoryEstimatedTokens =
parseOptionalPositiveInt(process.env.MAKA_CONTEXT_HISTORY_BUDGET_TOKENS) ??
defaultHistoryBudgetTokens(connection);
if (maxHistoryEstimatedTokens === undefined) return undefined;
const maxHistoryTurns = parseOptionalPositiveInt(process.env.MAKA_CONTEXT_HISTORY_BUDGET_TURNS);
const minRecentTurns = parsePositiveInt(process.env.MAKA_CONTEXT_MIN_RECENT_TURNS, 2);
return {
name: 'desktop-default-history-budget',
maxHistoryEstimatedTokens,
...(maxHistoryTurns !== undefined ? { maxHistoryTurns } : {}),
minRecentTurns,
};
}

function defaultHistoryBudgetTokens(connection: LlmConnection): number | undefined {
if (connection.providerType === 'deepseek') return undefined;
return 32_000;
}

function parsePositiveInt(value: string | undefined, fallback: number): number {
const parsed = parseOptionalPositiveInt(value);
return parsed ?? fallback;
}

function parseOptionalPositiveInt(value: string | undefined): number | undefined {
if (!value) return undefined;
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
}

function buildSubscriptionModelFetch(
connection: LlmConnection,
sessionId: string,
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,7 +19,8 @@
"check:stale": "node scripts/check-stale-dist.mjs",
"prepare:officecli": "node scripts/prepare-officecli.mjs",
"check:officecli-bundle": "node scripts/check-officecli-bundle.mjs",
"check:release": "npm run check:stale && npm run check:officecli-bundle"
"check:release": "npm run check:stale && npm run check:officecli-bundle",
"cost:deepseek-baseline": "node scripts/deepseek-live-cost-baseline.mjs"
},
"devDependencies": {
"@types/node": "^25.0.0",
Expand Down
8 changes: 7 additions & 1 deletion packages/core/src/events.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,7 +10,11 @@
*/

import type { PermissionRequest, PermissionResponse, ToolCategory } from './permission.js';
import type { PrefixChangeReason } from './usage-stats/types.js';
import type {
ContextBudgetDiagnostic,
PrefixChangeReason,
PromptSegmentEstimate,
} from './usage-stats/types.js';

export const TOOL_OUTPUT_STREAMS = ['stdout', 'stderr'] as const;
export const TOOL_OUTPUT_DELTA_MAX_CHARS = 8192;
Expand DownExpand Up@@ -335,6 +339,8 @@ export interface TokenUsageEvent extends BaseEvent {
contextRemaining?: number;
prefixHash?: string;
prefixChangeReason?: PrefixChangeReason;
promptSegments?: PromptSegmentEstimate[];
contextBudget?: ContextBudgetDiagnostic;
}

export interface ErrorEvent extends BaseEvent {
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -693,7 +693,10 @@ export {
// usage-stats/types.ts
export type {
LlmCallRecord,
ContextBudgetDiagnostic,
PricingConfig,
PromptSegmentEstimate,
PromptSegmentKind,
TimeRange,
ToolInvocationRecord,
UsageBucket,
Expand Down
8 changes: 7 additions & 1 deletion packages/core/src/runtime-event.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,7 +16,11 @@

import type { AttachmentRef } from './events.js';
import type { PermissionRequest, PermissionResponse } from './permission.js';
import type { PrefixChangeReason } from './usage-stats/types.js';
import type {
ContextBudgetDiagnostic,
PrefixChangeReason,
PromptSegmentEstimate,
} from './usage-stats/types.js';

// ============================================================================
// Role / Author / Status
Expand DownExpand Up@@ -180,6 +184,8 @@ export interface RuntimeEventTokenUsage {
contextRemaining?: number;
prefixHash?: string;
prefixChangeReason?: PrefixChangeReason;
promptSegments?: PromptSegmentEstimate[];
contextBudget?: ContextBudgetDiagnostic;
}

/**
Expand Down
8 changes: 7 additions & 1 deletion packages/core/src/session.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,7 +11,11 @@

import type { AttachmentRef, ToolResultContent } from './events.js';
import type { PermissionMode } from './permission.js';
import type { PrefixChangeReason } from './usage-stats/types.js';
import type {
ContextBudgetDiagnostic,
PrefixChangeReason,
PromptSegmentEstimate,
} from './usage-stats/types.js';

export const SESSION_STATUSES = [
'active',
Expand DownExpand Up@@ -243,6 +247,8 @@ export interface TokenUsageMessage {
costUsd?: number;
prefixHash?: string;
prefixChangeReason?: PrefixChangeReason;
promptSegments?: PromptSegmentEstimate[];
contextBudget?: ContextBudgetDiagnostic;
}

export interface TurnStateMessage {
Expand Down
33 changes: 33 additions & 0 deletions packages/core/src/usage-stats/types.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -72,6 +72,8 @@ export interface UsageLogRow {
turnId?: string;
prefixHash?: string;
prefixChangeReason?: PrefixChangeReason;
promptSegments?: PromptSegmentEstimate[];
contextBudget?: ContextBudgetDiagnostic;
}

export interface PricingConfig {
Expand DownExpand Up@@ -117,6 +119,8 @@ export interface LlmCallRecord {
startedAt: number;
prefixHash?: string;
prefixChangeReason?: PrefixChangeReason;
promptSegments?: PromptSegmentEstimate[];
contextBudget?: ContextBudgetDiagnostic;
}

export type PrefixChangeReason =
Expand All@@ -129,6 +133,35 @@ export type PrefixChangeReason =
| 'stable'
| 'unknown';

export type PromptSegmentKind =
| 'system_prompt'
| 'tool_schema'
| 'prior_history'
| 'current_user'
| 'turn_tail';

export interface PromptSegmentEstimate {
kind: PromptSegmentKind;
chars: number;
estimatedTokens: number;
messageCount?: number;
eventCount?: number;
toolCount?: number;
}

export interface ContextBudgetDiagnostic {
enabled: boolean;
policyName?: string;
maxHistoryEstimatedTokens?: number;
maxHistoryTurns?: number;
estimatedTokensBefore: number;
estimatedTokensAfter: number;
keptTurns: number;
droppedTurns: number;
keptEvents: number;
droppedEvents: number;
}

export interface ToolInvocationRecord {
sessionId?: string;
turnId?: string;
Expand Down
1 change: 1 addition & 0 deletions packages/runtime/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,7 @@
"./tool-output-delta": "./dist/tool-output-delta.js",
"./stream-watchdog": "./dist/stream-watchdog.js",
"./model-factory": "./dist/model-factory.js",
"./context-budget": "./dist/context-budget.js",
"./test-connection": "./dist/test-connection.js",
"./model-fetcher": "./dist/model-fetcher.js",
"./materializer": "./dist/materializer.js",
Expand Down
81 changes: 81 additions & 0 deletions packages/runtime/src/__tests__/ai-sdk-backend.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,6 +24,7 @@ import {
canonicalizeToolSet,
computeRequestShapeDiagnostic,
} from '../request-shape.js';
import { applyRuntimeEventContextBudget } from '../context-budget.js';

describe('AiSdkBackend model history', () => {
test('prefers RuntimeEvent prior messages and appends current user once', async () => {
Expand DownExpand Up@@ -800,6 +801,86 @@ describe('AiSdkBackend request-shape diagnostics', () => {
});
});

describe('AiSdkBackend context budget and prompt attribution', () => {
test('context budget keeps whole recent turns and drops older turns', () => {
const events = [
runtimeTextEvent({ id: 'old-u', turnId: 'old', role: 'user', author: 'user', text: 'old user text' }),
runtimeTextEvent({ id: 'old-a', turnId: 'old', role: 'model', author: 'agent', text: 'old assistant text' }),
runtimeTextEvent({ id: 'new-u', turnId: 'new', role: 'user', author: 'user', text: 'new user text' }),
runtimeTextEvent({ id: 'new-a', turnId: 'new', role: 'model', author: 'agent', text: 'new assistant text' }),
];

const budgeted = applyRuntimeEventContextBudget(events, {
name: 'test-budget',
maxHistoryEstimatedTokens: 1,
minRecentTurns: 1,
charsPerToken: 1,
});

assert.ok(budgeted);
assert.deepEqual([...new Set(budgeted.events.map((event) => event.turnId))], ['new']);
assert.equal(budgeted.diagnostic.droppedTurns, 1);
assert.equal(budgeted.diagnostic.keptTurns, 1);
assert.equal(budgeted.diagnostic.droppedEvents, 2);
});

test('usage events include prompt segments and context budget diagnostics', async () => {
const model = completionModel();
const events: SessionEvent[] = [];
const backend = new AiSdkBackend({
sessionId: 'session-1',
header: header(),
appendMessage: async () => {},
connection: connection(),
apiKey: 'sk-test',
modelId: 'mock-model-id',
permissionEngine: new PermissionEngine({ newId: () => 'permission-id', now: () => 1 }),
modelFactory: () => model,
tools: [testTool('Read', z.object({ path: z.string() }))],
newId: idGenerator(),
now: monotonicClock(),
systemPrompt: 'durable system',
turnTailPrompt: 'volatile tail',
contextBudget: {
name: 'test-budget',
maxHistoryEstimatedTokens: 1,
minRecentTurns: 1,
charsPerToken: 1,
},
});

for await (const event of backend.send({
turnId: 'turn-current',
text: 'current user',
context: [],
runtimeContext: [
runtimeTextEvent({ id: 'old-u', turnId: 'old', role: 'user', author: 'user', text: 'old user text' }),
runtimeTextEvent({ id: 'old-a', turnId: 'old', role: 'model', author: 'agent', text: 'old assistant text' }),
runtimeTextEvent({ id: 'new-u', turnId: 'new', role: 'user', author: 'user', text: 'new user text' }),
runtimeTextEvent({ id: 'new-a', turnId: 'new', role: 'model', author: 'agent', text: 'new assistant text' }),
],
})) {
events.push(event);
}

assert.deepEqual(compactPrompt(model), [
{ role: 'system', content: 'durable system' },
{ role: 'user', content: [{ type: 'text', text: 'new user text' }] },
{ role: 'assistant', content: [{ type: 'text', text: 'new assistant text' }] },
{ role: 'user', content: [{ type: 'text', text: 'current user\n\nvolatile tail' }] },
]);
const usage = events.find((event): event is Extract<SessionEvent, { type: 'token_usage' }> =>
event.type === 'token_usage'
);
assert.ok(usage);
assert.equal(usage.contextBudget?.policyName, 'test-budget');
assert.equal(usage.contextBudget?.droppedTurns, 1);
assert.equal(usage.promptSegments?.some((segment) => segment.kind === 'prior_history'), true);
assert.equal(usage.promptSegments?.some((segment) => segment.kind === 'tool_schema'), true);
assert.equal(usage.promptSegments?.some((segment) => segment.kind === 'turn_tail'), true);
});
});

describe('AiSdkBackend RunTrace', () => {
test('records turn, model, usage, and completion trace events without changing SessionEvents', async () => {
const trace: RunTraceEvent[] = [];
Expand Down
Loading