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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
owner, immutable request snapshots remain enforced at AgentRun acceptance and backend dispatch,
and SessionEvent-to-RuntimeEvent conversion remains a pure mapper.
- Retired the Task Ledger domain: SessionTodo is now the sole authority for in-session work items, and the operational-state schema drops the `workflow_task_ledger_events` table on first open. **Unfinished Tasks are not migrated and are permanently deleted.** This affects workspaces last opened by `v0.1.0` through `v0.1.11`, `cli-v0.1.0-beta.1`, `v0.2.0-incubating-rc1`, or a `v0.2.0-dev` build; those releases wrote Tasks to a table that no shipped build ever bridged into SessionTodo. Before opening such a workspace with this build, finish or export the Tasks you still need, or copy the workspace's `runtime.sqlite` aside — the migration removes the only live copy, so afterwards recovery requires a backup made in advance.
- `token_usage` anchors now record the model and connection that produced them. A token count is a number in one model's tokenizer against one connection; carrying the route on the record lets any reader apply the rule the runtime already enforces, instead of pairing one model's usage with another model's window. The record decodes against a closed allowlist, so sessions written with these keys do not open in earlier releases, and the Runtime Host compatibility epoch moves to 107.
- Let the provider decide whether a request fits. Proactive compaction now uses only a user-declared Maka window and the previous accepted request's provider-reported `inputTokens + outputTokens`; no declaration means no proactive capacity threshold. `/models` and generated model metadata are display hints, not limits. `token_usage` records persist the last-request anchor under `lastRequestAnchor`; its new `{ inputTokens, outputTokens }` shape still decodes the retired `payloadChars` key from older sessions. Requests that are too large are compacted and retried once after a real provider rejection, then reported as a `context_overflow` provider error. Compaction is entered at most once per send, and a request rejected after a fold was actually applied is reported as still too large after compaction. A fold that failed open makes no such claim: that request went out with its full raw history. A reply cut at `finishReason: length` no longer triggers a fold, because the provider running out of window room and the provider's own lower output cap are indistinguishable from outside. Five system notes explain the provider-side cases: dropping context, a window worth declaring, an exchange past the declared window, a request accepted past the window the model reports (once per crossing, while nothing is declared), and a request still too large after compaction. The reply reserve that arms the proactive threshold is twice the last real reply, bounded at 8,000 tokens, rather than the model's maximum output. **Sessions this build writes do not open in earlier releases:** those decode `token_usage` against a closed allowlist, so the reshaped `lastRequestAnchor` key fails the record and, with it, the Session that contains it; downgrading therefore needs a copy of the workspace's `runtime.sqlite` taken before the upgrade. Nothing produces the `context_budget_exhausted` stop reason any more — a request that really is too large is compacted and retried once, then reported as a `context_overflow` provider error — though sessions that already recorded it still decode and present. The Runtime Host compatibility epoch moves to 106.
- Unified context management under one Runtime-owned policy. `MAKA_CONTEXT_*` environment overrides no longer tune or disable compaction and Tool Result pruning; model-visible archive placeholders are read on demand through bounded `ArchiveRead` calls instead of eager hydration. Previously supported overrides are ignored on upgrade: if Tool Result pruning was set to `off`, pruning is re-enabled, and there is currently no supported replacement opt-out.
- Moved Read image snapshots into the durable context-offload store with Runtime-owned
Expand Down
6 changes: 3 additions & 3 deletions apps/desktop/renderer-architecture.json
Original file line number Diff line number Diff line change
Expand Up @@ -788,7 +788,7 @@
"nonTriviaTokens": 1410
},
"src/renderer/app-shell.tsx": {
"importDeclarations": 103,
"importDeclarations": 102,
"bridgePaths": {
"window.maka.app.installUpdate": 1,
"window.maka.app.retryUpdateDownload": 1,
Expand Down Expand Up @@ -970,7 +970,7 @@
"@maka/core/onboarding-milestone": 1,
"@maka/core/orchestration": 1,
"@maka/core/project": 1,
"@maka/core/session": 2,
"@maka/core/session": 1,
"@maka/core/session-revisions": 1,
"@maka/core/settings": 1,
"@maka/core/slash-command-catalog": 2,
Expand All @@ -980,7 +980,7 @@
"react": 1
},
"importSpecifiers": 184,
"nonTriviaTokens": 15692
"nonTriviaTokens": 15687
},
"src/renderer/use-app-shell-composer-quotes.ts": {
"importDeclarations": 3,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ async function mountRegion(): Promise<{
composerRef: composer,
directoryComposerProps: {},
directoryPickerEnabled: false,

active: true,
onboardingComposerHidden: false,
activeInteraction: undefined,
Expand Down
124 changes: 124 additions & 0 deletions apps/desktop/src/main/__tests__/latest-request-usage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import assert from 'node:assert/strict';
import { test } from 'node:test';
import { selectLatestRequestUsage } from '../../renderer/chat-composer-region.js';

const ROUTE = { llmConnectionId: 'conn-a' };
const MODEL = 'model-a';

function usage(anchor?: {
inputTokens: number;
outputTokens?: number;
modelId?: string;
connectionId?: string;
}) {
return { type: 'token_usage', ...(anchor ? { lastRequestAnchor: anchor } : {}) };
}

test('reads the newest anchor on the active route', () => {
const tokens = selectLatestRequestUsage(
[
usage({ inputTokens: 10, outputTokens: 2, modelId: MODEL, connectionId: 'conn-a' }),
{ type: 'assistant' },
usage({ inputTokens: 100, outputTokens: 20, modelId: MODEL, connectionId: 'conn-a' }),
],
{ hasNewer: false },
MODEL,
ROUTE,
);
assert.equal(tokens, 120);
});

test('scans past an anchorless usage row, which is what manual compaction writes', () => {
// `/compact` appends a synthetic `token_usage` with no anchor. The runtime's
// own reader skips it and keeps the last real request; stopping there would
// blank the indicator after every manual compaction.
const tokens = selectLatestRequestUsage(
[
usage({ inputTokens: 100, outputTokens: 20, modelId: MODEL, connectionId: 'conn-a' }),
usage(),
],
{ hasNewer: false },
MODEL,
ROUTE,
);
assert.equal(tokens, 120);
});

test('refuses an anchor from another model', () => {
// A token count is a number in one model's tokenizer. Pairing model A's
// count with model B's window produces a precise-looking figure about a
// request the user is not making.
const tokens = selectLatestRequestUsage(
[usage({ inputTokens: 100_000, modelId: 'model-b', connectionId: 'conn-a' })],
{ hasNewer: false },
MODEL,
ROUTE,
);
assert.equal(tokens, undefined);
});

test('refuses an anchor from another connection', () => {
const tokens = selectLatestRequestUsage(
[usage({ inputTokens: 100, modelId: MODEL, connectionId: 'conn-b' })],
{ hasNewer: false },
MODEL,
ROUTE,
);
assert.equal(tokens, undefined);
});

test('refuses an anchor written before anchors carried their route', () => {
const tokens = selectLatestRequestUsage(
[usage({ inputTokens: 100, outputTokens: 20 })],
{ hasNewer: false },
MODEL,
ROUTE,
);
assert.equal(tokens, undefined);
});

test('refuses every anchor while the loaded range is not the session tail', () => {
// Browsing history must not report an older range's usage as current.
const tokens = selectLatestRequestUsage(
[usage({ inputTokens: 100, outputTokens: 20, modelId: MODEL, connectionId: 'conn-a' })],
{ hasNewer: true },
MODEL,
ROUTE,
);
assert.equal(tokens, undefined);
});

test('refuses when there is no active route yet', () => {
const anchored = [usage({ inputTokens: 100, modelId: MODEL, connectionId: 'conn-a' })];
assert.equal(selectLatestRequestUsage(anchored, undefined, undefined, ROUTE), undefined);
assert.equal(selectLatestRequestUsage(anchored, undefined, MODEL, undefined), undefined);
});

test('refuses a non-positive input count', () => {
const tokens = selectLatestRequestUsage(
[usage({ inputTokens: 0, modelId: MODEL, connectionId: 'conn-a' })],
{ hasNewer: false },
MODEL,
ROUTE,
);
assert.equal(tokens, undefined);
});
30 changes: 13 additions & 17 deletions apps/desktop/src/renderer/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ import type {
InlineReference,
QuoteRef,
} from '@maka/core/events';
import type { SessionSummary } from '@maka/core/session';
import type { OrchestrationMode } from '@maka/core/orchestration';
import type { ChatDefaultPermissionMode } from '@maka/core/settings';
import type { SlashCommandIdForSurface } from '@maka/core/slash-command-catalog';
Expand Down Expand Up @@ -86,7 +85,7 @@ import { deriveWorkspaceReadinessRecovery } from './workspace-readiness-recovery
import { LiveTurnReconciler } from './live-turn-reconciler';
import { useAppShellSessionUiReads } from './use-app-shell-session-ui-reads';
import { AgentGraphPanel } from './agent-graph-panel';
import { ChatComposerRegion } from './chat-composer-region';
import { ChatComposerRegion, selectLatestRequestUsage } from './chat-composer-region';
import {
WorkbarHost,
WorkbarTitlebarActions,
Expand Down Expand Up @@ -361,8 +360,7 @@ function AppShellContent({
sessionUiController,
} = useAppShellSessionWorkspace(toastApi);
const activeCatalogSession = sessions.find((session) => session.id === activeId);
const sharedSessionActive =
(activeCatalogSession as DesktopSessionSummary | undefined)?.shared === true;
const sharedSessionActive = activeCatalogSession?.shared === true;
const ownerActiveId = activeCatalogSession && !sharedSessionActive ? activeId : undefined;
const interactionHydrationEpochRef = useRef(new Map<string, number>());
const markInteractionChanged = useCallback((sessionId: string) => {
Expand Down Expand Up @@ -952,10 +950,9 @@ function AppShellContent({
openModelPicker: openComposerModelPicker,
refreshModelChoices: sessionHostConnections.refreshConnections,
});
const newChatProviderType = newChatModel
? connections.find((connection) => connection.slug === newChatModel.llmConnectionSlug)?.providerType
: undefined;

const newChatProviderType = connections.find(
(connection) => connection.slug === newChatModel?.llmConnectionSlug,
)?.providerType;
// PR109d-b: turn footer actions per turn. Derived from the
// materialized turn list (status + lineage descendants) + pending
// mask. Per @kenji PR109d review: pending state prevents double-click
Expand Down Expand Up @@ -1160,15 +1157,13 @@ function AppShellContent({

// Transient placeholder while the real SessionSummary loads, so the composer
// does not flash a value the session never had.
const activeSessionForView: SessionSummary | undefined =
activeSession ??
(activeId
? pendingSessionView({
sessionId: activeId,
name: shellCopy.newConversation,
permissionMode: newTaskPermissionMode,
})
: undefined);
const activeSessionForView = activeSession ?? (activeId
? pendingSessionView({
sessionId: activeId,
name: shellCopy.newConversation,
permissionMode: newTaskPermissionMode,
})
: undefined);
// Each control reads its own field. There is nothing to project and nothing
// to keep in sync: a Session in Plan with Swarm as its orchestration default
// says both, because it is both.
Expand Down Expand Up @@ -2996,6 +2991,7 @@ function AppShellContent({
activeModel={activeModel}
activeModelLabel={activeModelLabel}
activeProviderType={activeConnection?.providerType}
latestRequestUsageTokens={selectLatestRequestUsage(messages, activeTranscriptRange, activeModel, activeSessionForModelControls)}
modelChoices={chatModelChoices}
modelSwitchHasHistory={modelSwitchHasHistory}
hideUnavailableCurrentModel={sessionHealthNotice?.onClickTarget === 'model_picker'}
Expand Down
73 changes: 73 additions & 0 deletions apps/desktop/src/renderer/chat-composer-region.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,13 +114,70 @@ interface ChatComposerRegionProps
respondToUserQuestion: ComponentProps<typeof UserQuestionPrompt>['onRespond'];
stop: ComponentProps<typeof UserQuestionPrompt>['onStop'];
boundaryUnreadableNotice?: BoundaryUnreadableNotice;
/**
* Tokens the provider counted for the session's latest request on the active
* route, or nothing when that cannot be established. Resolved by the owner,
* which knows the transcript range and the route; this control never derives
* it from the rendered slice.
*/
latestRequestUsageTokens?: number;
directoryComposerProps: Pick<
ComponentProps<typeof Composer>,
'pendingDirectories' | 'onRemoveDirectory' | 'onPickDirectory'
>;
directoryPickerEnabled: boolean;
}

/**
* The session's latest provider-counted request, or nothing.
*
* A token count belongs to one request on one route: it is a number in that
* model's tokenizer, and it is only the session's latest if nothing newer
* exists. The runtime enforces both when it reads an anchor back, refusing one
* whose run header names another model or connection. A control that shows the
* number has to enforce the same two facts or it will display a precise-looking
* figure about a request the user is not making — model A's tokens against
* model B's window, or a historical range's usage presented as current.
*
* So this refuses rather than approximates, and the three refusals are the
* three normal states that break the pairing:
*
* - the loaded transcript range is not the session tail, so a newer request may
* exist that this range cannot see;
* - the newest usage row carries no anchor, which is what manual `/compact`
* writes, so the scan continues past it exactly as the runtime's does;
* - the anchor names a different route than the active one, or names none at
* all because it was written before anchors carried their route.
*/
export interface LatestRequestUsageAnchor {
inputTokens: number;
outputTokens?: number;
modelId?: string;
connectionId?: string;
}

export function selectLatestRequestUsage(
messages: readonly { type: string; lastRequestAnchor?: LatestRequestUsageAnchor }[],
/** `hasNewer` means the loaded range is not the session tail. */
range: { hasNewer?: boolean } | undefined,
model: string | undefined,
route: { llmConnectionId?: string } | undefined,
): number | undefined {
const connectionId = route?.llmConnectionId;
if (range?.hasNewer || model === undefined || connectionId === undefined) return undefined;
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
if (message?.type !== 'token_usage') continue;
const anchor = message.lastRequestAnchor;
if (!anchor) continue;
if (anchor.modelId !== model || anchor.connectionId !== connectionId) return undefined;
if (!Number.isFinite(anchor.inputTokens) || anchor.inputTokens <= 0) return undefined;
const output = Number.isFinite(anchor.outputTokens ?? 0) ? Math.max(0, anchor.outputTokens ?? 0) : 0;
return anchor.inputTokens + output;
}
return undefined;
}

export function ChatComposerRegion({
composerRef,
active,
Expand All @@ -135,6 +192,7 @@ export function ChatComposerRegion({
respondToUserQuestion,
stop,
boundaryUnreadableNotice,
latestRequestUsageTokens,
directoryComposerProps,
directoryPickerEnabled,
...composerRest
Expand All @@ -145,6 +203,20 @@ export function ChatComposerRegion({
const activeClientCapability =
activeInteraction?.type === 'client_capability_request' ? activeInteraction : undefined;
const activeQuestion = activeInteraction?.type === 'user_question_request' ? activeInteraction : undefined;
const activeModelChoice = composerRest.activeModel
? composerRest.modelChoices?.find(
(choice) =>
choice.connectionId === composerRest.activeModelConnectionId &&
choice.model === composerRest.activeModel,
)
: undefined;
const contextUsage = activeId
? {
usageTokens: latestRequestUsageTokens,
declaredContextWindow: activeModelChoice?.declaredContextWindow,
metadataContextWindow: activeModelChoice?.contextWindow,
}
: undefined;
const previousNewTaskDraftKey = useRef(newTaskDraftKey);
useLayoutEffect(() => {
const previous = previousNewTaskDraftKey.current;
Expand Down Expand Up @@ -254,6 +326,7 @@ export function ChatComposerRegion({
<Composer
ref={composerRef}
{...composerRest}
contextUsage={contextUsage}
// AppShell carries staged attachments into both queued and steering
// follow-ups. Other Composer hosts remain gated by default because a
// text-only running-turn submission would leave attachments behind.
Expand Down
14 changes: 14 additions & 0 deletions apps/desktop/src/renderer/styles/model-switcher.css
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,20 @@
max-width: 100%;
}

.maka-context-usage-indicator {
display: inline-flex;
align-items: center;
gap: var(--space-1);
min-width: 4ch;
color: var(--muted-foreground);
font: var(--maka-text-supporting);
white-space: nowrap;
}

.maka-context-usage-indicator svg {
flex: 0 0 auto;
}

/* The composer footer's model and thinking pickers are ghost-button
DropdownMenus — the same toolbar primitive as + and permission, so their
resting, hover, focus, and disabled chrome all derive from the Astryx
Expand Down
Loading